1import AppKit2import Combine3import Foundation45enum AppTab {6 case explore7 case local8 case favorites9 case history10 case settings11}12131415enum FeedMode: String, CaseIterable, Identifiable {16 case latest17 case toplist18 case random1920 var id: String { rawValue }2122 var sorting: WallhavenSorting {23 switch self {24 case .latest: return .dateAdded25 case .toplist: return .toplist26 case .random: return .random27 }28 }2930 var displayName: String {31 switch self {32 case .latest: return "Latest"33 case .toplist: return "Toplist"34 case .random: return "Random"35 }36 }3738 var systemImage: String {39 switch self {40 case .latest: return "clock"41 case .toplist: return "diamond"42 case .random: return "arrow.triangle.2.circlepath"43 }44 }45}4647@MainActor48class AppState: ObservableObject {49 50 @Published var activeTab: AppTab = .explore5152 53 @Published var wallpapers: [Wallpaper] = []54 @Published var favoritesManager = FavoritesManager.shared55 @Published var historyManager = HistoryManager.shared5657 58 @Published var feedMode: FeedMode = .latest59 @Published var searchText: String = ""60 @Published var selectedResolution: String = ""61 @Published var selectedRatio: String = ""62 @Published var isLoading: Bool = false63 @Published var errorMessage: String?6465 66 @Published var canLoadMore = true67 private var currentPage = 168 private var isFetchingMore = false6970 71 @Published var saveToFolderEnabled: Bool {72 didSet { UserDefaults.standard.set(saveToFolderEnabled, forKey: "saveToFolderEnabled") }73 }74 @Published var saveFolderPath: String {75 didSet { UserDefaults.standard.set(saveFolderPath, forKey: "saveFolderPath") }76 }77 @Published var launchAtLogin: Bool {78 didSet { autostartManager.isEnabled = launchAtLogin }79 }8081 82 @Published var categories: (general: Bool, anime: Bool, people: Bool) = (true, true, true)83 @Published var purity: (sfw: Bool, sketchy: Bool) = (true, false)8485 let availableResolutions = ["Any", "1920x1080", "2560x1440", "3840x2160", "5120x2880"]86 let availableRatios = ["Any", "16x9", "16x10", "21x9", "32x9", "4x3"]8788 private let api: WallhavenSearching89 private let wallpaperManager: WallpaperManaging90 private let autostartManager: AutostartManaging91 private let chooseFolder: @MainActor (URL, @escaping (URL?) -> Void) -> Void92 private let terminateApplication: @MainActor () -> Void93 private var cancellables = Set<AnyCancellable>()94 95 96 97 98 private var searchGeneration: UInt64 = 099100 init(101 api: WallhavenSearching? = nil,102 wallpaperManager: WallpaperManaging? = nil,103 autostartManager: AutostartManaging? = nil,104 chooseFolder: (@MainActor (URL, @escaping (URL?) -> Void) -> Void)? = nil,105 terminateApplication: (@MainActor () -> Void)? = nil,106 observeChanges: Bool = true107 ) {108 let api = api ?? WallhavenAPI()109 let wallpaperManager = wallpaperManager ?? WallpaperManager.shared110 let autostartManager = autostartManager ?? AutostartManager.shared111 self.api = api112 self.wallpaperManager = wallpaperManager113 self.autostartManager = autostartManager114 self.chooseFolder = chooseFolder ?? AppState.presentFolderPicker115 self.terminateApplication = terminateApplication ?? { NSApp.terminate(nil) }116 self.launchAtLogin = autostartManager.isEnabled117 118 self.saveToFolderEnabled = UserDefaults.standard.bool(forKey: "saveToFolderEnabled")119 if let path = UserDefaults.standard.string(forKey: "saveFolderPath") {120 self.saveFolderPath = path121 } else {122 let pictures = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first!123 self.saveFolderPath = pictures.appendingPathComponent("Haven").path124 }125126 guard observeChanges else { return }127128 129 $searchText130 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)131 .sink { [weak self] _ in self?.scheduleSearch() }132 .store(in: &cancellables)133134 135 $categories136 .map { _ in () }137 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)138 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }139 .store(in: &cancellables)140141 $purity142 .map { _ in () }143 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)144 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }145 .store(in: &cancellables)146147 148 149 150 $selectedResolution151 .dropFirst()152 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)153 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }154 .store(in: &cancellables)155156 $selectedRatio157 .dropFirst()158 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)159 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }160 .store(in: &cancellables)161162 $feedMode163 .dropFirst()164 .sink { [weak self] _ in165 Task { [weak self] in await self?.fetchWallpapers(reset: true) }166 }167 .store(in: &cancellables)168169 updateLocalWallpapersMonitoring()170171 $saveToFolderEnabled172 .dropFirst()173 .sink { [weak self] _ in174 self?.updateLocalWallpapersMonitoring()175 }176 .store(in: &cancellables)177178 $saveFolderPath179 .dropFirst()180 .sink { [weak self] _ in181 self?.updateLocalWallpapersMonitoring()182 }183 .store(in: &cancellables)184 }185186 private func updateLocalWallpapersMonitoring() {187 if saveToFolderEnabled {188 LocalWallpapersManager.shared.startMonitoring(folderPath: saveFolderPath)189 } else {190 LocalWallpapersManager.shared.stopMonitoring()191 }192 }193194 func scheduleSearch() { Task { [weak self] in await self?.fetchWallpapers() } }195196 func fetchWallpapers(reset: Bool = true, refreshing: Bool = false) async {197 if reset {198 isFetchingMore = false199 searchGeneration &+= 1200 }201 let requestGeneration = searchGeneration202 let pageToRequest: Int203 if reset {204 if !refreshing {205 isLoading = true206 wallpapers = []207 }208 errorMessage = nil209 currentPage = 1210 canLoadMore = true211 pageToRequest = 1212 } else {213 guard !isFetchingMore && canLoadMore else { return }214 isFetchingMore = true215 pageToRequest = currentPage216 }217218 do {219 let resolutions =220 selectedResolution == "Any" || selectedResolution.isEmpty ? [] : [selectedResolution]221 let ratios = selectedRatio == "Any" || selectedRatio.isEmpty ? [] : [selectedRatio]222223 224 let catStr =225 "\(categories.general ? "1" : "0")\(categories.anime ? "1" : "0")\(categories.people ? "1" : "0")"226 227 let purityStr = "\(purity.sfw ? "1" : "0")\(purity.sketchy ? "1" : "0")0"228229 let (newWallpapers, meta) = try await api.searchWallpapers(230 query: searchText,231 categories: catStr,232 purity: purityStr,233 sorting: feedMode.sorting,234 resolutions: resolutions,235 ratios: ratios,236 limit: 24,237 page: pageToRequest238 )239240 if requestGeneration != searchGeneration { return }241 if reset {242 self.wallpapers = newWallpapers243 } else {244 self.wallpapers.append(contentsOf: newWallpapers)245 }246247 if let meta = meta {248 let nextPage = meta.current_page + 1249 self.canLoadMore = meta.current_page < meta.last_page250 251 252 253 if requestGeneration == searchGeneration {254 self.currentPage = nextPage255 }256 } else if !newWallpapers.isEmpty {257 self.canLoadMore = true258 if requestGeneration == searchGeneration {259 self.currentPage += 1260 }261 } else {262 self.canLoadMore = false263 }264265 } catch {266 if reset && requestGeneration == searchGeneration {267 self.errorMessage = error.localizedDescription268 }269 }270271 if reset {272 if requestGeneration == searchGeneration {273 isLoading = false274 }275 } else if requestGeneration == searchGeneration {276 isFetchingMore = false277 }278 }279280 func loadMore() async {281 await fetchWallpapers(reset: false)282 }283284 func setWallpaper(_ wallpaper: Wallpaper, on screen: NSScreen? = nil) async {285 guard let remoteURL = URL(string: wallpaper.path) else {286 self.errorMessage = "Failed to set wallpaper: invalid image URL"287 return288 }289 do {290 let localURL = try await wallpaperManager.download(from: remoteURL)291292 293 294 try wallpaperManager.setDesktopImage(from: localURL, screen: screen)295296 297 historyManager.addToHistory(wallpaper)298299 if saveToFolderEnabled {300 let destFolder = URL(fileURLWithPath: saveFolderPath)301 try wallpaperManager.saveToFolder(from: localURL, to: destFolder)302 }303304 } catch {305 self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"306 }307 }308309 func setLocalWallpaper(_ wallpaper: LocalWallpaper, on screen: NSScreen? = nil) async {310 do {311 312 313 try wallpaperManager.setDesktopImage(from: wallpaper.url, screen: screen)314315 let historyWallpaper = Wallpaper(316 id: wallpaper.id,317 url: wallpaper.url.path,318 path: wallpaper.url.path,319 resolution: wallpaper.resolution,320 purity: "local",321 file_size: Int(wallpaper.fileSize),322 thumbs: Wallpaper.Thumbs(323 large: wallpaper.url.path,324 original: wallpaper.url.path,325 small: wallpaper.url.path326 )327 )328 historyManager.addToHistory(historyWallpaper)329330 } catch {331 self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"332 }333 }334335 func downloadWallpaper(_ wallpaper: Wallpaper) async {336 guard let remoteURL = URL(string: wallpaper.path) else {337 self.errorMessage = "Failed to download: invalid image URL"338 return339 }340 do {341 let localURL = try await wallpaperManager.download(from: remoteURL)342343 344 let destFolder = URL(fileURLWithPath: saveFolderPath)345 try wallpaperManager.saveToFolder(from: localURL, to: destFolder)346347 } catch {348 self.errorMessage = "Failed to download: \(error.localizedDescription)"349 }350 }351352 func selectSaveFolder() {353 354 355 MenuController.shared?.beginModalPresentation()356 NSApp.activate(ignoringOtherApps: true)357358 chooseFolder(URL(fileURLWithPath: saveFolderPath)) { url in359 if let url { self.saveFolderPath = url.path }360 MenuController.shared?.endModalPresentation()361 }362 }363364 static func makeFolderPanel(at directory: URL) -> NSOpenPanel {365 let panel = NSOpenPanel()366 panel.canChooseFiles = false367 panel.canChooseDirectories = true368 panel.canCreateDirectories = true369 panel.allowsMultipleSelection = false370 panel.directoryURL = directory371 panel.prompt = "Choose"372 return panel373 }374375 static func presentFolderPicker(376 at directory: URL, completion: @escaping (URL?) -> Void377 ) {378 presentFolderPicker(panel: makeFolderPanel(at: directory), completion: completion)379 }380381 static func presentFolderPicker(382 panel: NSOpenPanel, completion: @escaping (URL?) -> Void383 ) {384 panel.begin { response in completion(response == .OK ? panel.url : nil) }385 }386387 func quit() {388 terminateApplication()389 }390}