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 let removeTempFile: @MainActor (URL) throws -> Void private var cancellables = Set() // Monotonic token incremented on every reset; only the search started with // the latest token may commit results. This keeps a slow older request from // overwriting the wallpapers/canLoadMore/errorMessage after a newer search // (or pagination) has begun. private var searchGeneration: UInt64 = 0 init( api: WallhavenSearching? = nil, wallpaperManager: WallpaperManaging? = nil, autostartManager: AutostartManaging? = nil, chooseFolder: (@MainActor (URL, @escaping (URL?) -> Void) -> Void)? = nil, terminateApplication: (@MainActor () -> Void)? = nil, removeTempFile: @escaping @MainActor (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) }, picturesDirectoryProvider: @escaping () -> URL? = { FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first }, 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.removeTempFile = removeTempFile 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 = picturesDirectoryProvider() ?? FileManager.default.temporaryDirectory 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) // Resolution and ratio pickers fire one value per menu interaction. // Debouncing them keeps a single user gesture producing a single // network request, matching the categories/purity/searchText flow. $selectedResolution .dropFirst() .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) .sink { [weak self] _ in Task { await self?.fetchWallpapers() } } .store(in: &cancellables) $selectedRatio .dropFirst() .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 { isFetchingMore = false searchGeneration &+= 1 } let requestGeneration = searchGeneration let pageToRequest: Int if reset { if !refreshing { isLoading = true wallpapers = [] } errorMessage = nil currentPage = 1 canLoadMore = true pageToRequest = 1 } else { guard !isFetchingMore && canLoadMore else { return } isFetchingMore = true pageToRequest = currentPage } 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: pageToRequest ) if requestGeneration != searchGeneration { return } if reset { self.wallpapers = newWallpapers } else { self.wallpapers.append(contentsOf: newWallpapers) } if let meta = meta { let nextPage = meta.current_page + 1 self.canLoadMore = meta.current_page < meta.last_page // A page that's been superseded by a newer reset would leave the next // page index pointing into the stale query; only advance when this // response is still the current generation. if requestGeneration == searchGeneration { self.currentPage = nextPage } } else if !newWallpapers.isEmpty { self.canLoadMore = true if requestGeneration == searchGeneration { self.currentPage += 1 } } else { self.canLoadMore = false } } catch { if reset && requestGeneration == searchGeneration { self.errorMessage = error.localizedDescription } } if reset { if requestGeneration == searchGeneration { isLoading = false } } else if requestGeneration == searchGeneration { isFetchingMore = false } } func loadMore() async { await fetchWallpapers(reset: false) } func setWallpaper(_ wallpaper: Wallpaper, on screen: NSScreen? = nil) async { guard let remoteURL = URL(string: wallpaper.path) else { self.errorMessage = "Failed to set wallpaper: invalid image URL" return } do { let localURL = try await wallpaperManager.download(from: remoteURL) defer { try? removeTempFile(localURL) } // AppState is @MainActor isolated; the prior MainActor.run hop was // redundant because the call site already runs on the main actor. 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 { // AppState is @MainActor isolated; the prior MainActor.run hop was // redundant because the call site already runs on the main actor. 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 { guard let remoteURL = URL(string: wallpaper.path) else { self.errorMessage = "Failed to download: invalid image URL" return } do { let localURL = try await wallpaperManager.download(from: remoteURL) defer { try? removeTempFile(localURL) } // 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() } }