import AppKit import Combine import Foundation 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 { didSet { autostartManager.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: WallhavenSearching private let wallpaperManager: WallpaperManaging private let autostartManager: AutostartManaging private let chooseFolder: @MainActor (URL, @escaping (URL?) -> Void) -> Void private let terminateApplication: @MainActor () -> Void private var cancellables = Set() init( api: WallhavenSearching? = nil, wallpaperManager: WallpaperManaging? = nil, autostartManager: AutostartManaging? = nil, chooseFolder: (@MainActor (URL, @escaping (URL?) -> Void) -> Void)? = nil, terminateApplication: (@MainActor () -> Void)? = nil, observeChanges: Bool = true ) { let api = api ?? WallhavenAPI() let wallpaperManager = wallpaperManager ?? WallpaperManager.shared let autostartManager = autostartManager ?? AutostartManager.shared self.api = api self.wallpaperManager = wallpaperManager self.autostartManager = autostartManager self.chooseFolder = chooseFolder ?? AppState.presentFolderPicker self.terminateApplication = terminateApplication ?? { NSApp.terminate(nil) } self.launchAtLogin = autostartManager.isEnabled // 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 } guard observeChanges else { return } // Debounce search $searchText .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) .sink { [weak self] _ in self?.scheduleSearch() } .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 scheduleSearch() { Task { [weak self] in await self?.fetchWallpapers() } } 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.download(from: URL(string: wallpaper.path)!) try await MainActor.run { try wallpaperManager.setDesktopImage(from: localURL, screen: screen) } // Add to history historyManager.addToHistory(wallpaper) if saveToFolderEnabled { let destFolder = URL(fileURLWithPath: saveFolderPath) try wallpaperManager.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.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.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.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) chooseFolder(URL(fileURLWithPath: saveFolderPath)) { url in if let url { self.saveFolderPath = url.path } MenuController.shared?.endModalPresentation() } } static func makeFolderPanel(at directory: URL) -> NSOpenPanel { let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.canCreateDirectories = true panel.allowsMultipleSelection = false panel.directoryURL = directory panel.prompt = "Choose" return panel } static func presentFolderPicker( at directory: URL, completion: @escaping (URL?) -> Void ) { presentFolderPicker(panel: makeFolderPanel(at: directory), completion: completion) } static func presentFolderPicker( panel: NSOpenPanel, completion: @escaping (URL?) -> Void ) { panel.begin { response in completion(response == .OK ? panel.url : nil) } } func quit() { terminateApplication() } }