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>()9495 init(96 api: WallhavenSearching? = nil,97 wallpaperManager: WallpaperManaging? = nil,98 autostartManager: AutostartManaging? = nil,99 chooseFolder: (@MainActor (URL, @escaping (URL?) -> Void) -> Void)? = nil,100 terminateApplication: (@MainActor () -> Void)? = nil,101 observeChanges: Bool = true102 ) {103 let api = api ?? WallhavenAPI()104 let wallpaperManager = wallpaperManager ?? WallpaperManager.shared105 let autostartManager = autostartManager ?? AutostartManager.shared106 self.api = api107 self.wallpaperManager = wallpaperManager108 self.autostartManager = autostartManager109 self.chooseFolder = chooseFolder ?? AppState.presentFolderPicker110 self.terminateApplication = terminateApplication ?? { NSApp.terminate(nil) }111 self.launchAtLogin = autostartManager.isEnabled112 113 self.saveToFolderEnabled = UserDefaults.standard.bool(forKey: "saveToFolderEnabled")114 if let path = UserDefaults.standard.string(forKey: "saveFolderPath") {115 self.saveFolderPath = path116 } else {117 let pictures = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first!118 self.saveFolderPath = pictures.appendingPathComponent("Haven").path119 }120121 guard observeChanges else { return }122123 124 $searchText125 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)126 .sink { [weak self] _ in self?.scheduleSearch() }127 .store(in: &cancellables)128129 130 $categories131 .map { _ in () }132 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)133 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }134 .store(in: &cancellables)135136 $purity137 .map { _ in () }138 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)139 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }140 .store(in: &cancellables)141142 $feedMode143 .dropFirst()144 .sink { [weak self] _ in145 Task { [weak self] in await self?.fetchWallpapers(reset: true) }146 }147 .store(in: &cancellables)148149 updateLocalWallpapersMonitoring()150151 $saveToFolderEnabled152 .dropFirst()153 .sink { [weak self] _ in154 self?.updateLocalWallpapersMonitoring()155 }156 .store(in: &cancellables)157158 $saveFolderPath159 .dropFirst()160 .sink { [weak self] _ in161 self?.updateLocalWallpapersMonitoring()162 }163 .store(in: &cancellables)164 }165166 private func updateLocalWallpapersMonitoring() {167 if saveToFolderEnabled {168 LocalWallpapersManager.shared.startMonitoring(folderPath: saveFolderPath)169 } else {170 LocalWallpapersManager.shared.stopMonitoring()171 }172 }173174 func scheduleSearch() { Task { [weak self] in await self?.fetchWallpapers() } }175176 func fetchWallpapers(reset: Bool = true, refreshing: Bool = false) async {177 if reset {178 if !refreshing {179 isLoading = true180 wallpapers = []181 }182 errorMessage = nil183 currentPage = 1184 canLoadMore = true185 } else {186 guard !isFetchingMore && canLoadMore else { return }187 isFetchingMore = true188 }189190 do {191 let resolutions =192 selectedResolution == "Any" || selectedResolution.isEmpty ? [] : [selectedResolution]193 let ratios = selectedRatio == "Any" || selectedRatio.isEmpty ? [] : [selectedRatio]194195 196 let catStr =197 "\(categories.general ? "1" : "0")\(categories.anime ? "1" : "0")\(categories.people ? "1" : "0")"198 199 let purityStr = "\(purity.sfw ? "1" : "0")\(purity.sketchy ? "1" : "0")0"200201 let (newWallpapers, meta) = try await api.searchWallpapers(202 query: searchText,203 categories: catStr,204 purity: purityStr,205 sorting: feedMode.sorting,206 resolutions: resolutions,207 ratios: ratios,208 limit: 24,209 page: currentPage210 )211212 if reset {213 self.wallpapers = newWallpapers214 } else {215 self.wallpapers.append(contentsOf: newWallpapers)216 }217218 if let meta = meta {219 self.canLoadMore = meta.current_page < meta.last_page220 self.currentPage = meta.current_page + 1221 } else {222 self.canLoadMore = !newWallpapers.isEmpty223 if !newWallpapers.isEmpty { self.currentPage += 1 }224 }225226 } catch {227 if reset {228 self.errorMessage = error.localizedDescription229 }230 }231232 if reset {233 isLoading = false234 } else {235 isFetchingMore = false236 }237 }238239 func loadMore() async {240 await fetchWallpapers(reset: false)241 }242243 func setWallpaper(_ wallpaper: Wallpaper, on screen: NSScreen? = nil) async {244 do {245 let localURL = try await wallpaperManager.download(from: URL(string: wallpaper.path)!)246247 try await MainActor.run {248 try wallpaperManager.setDesktopImage(from: localURL, screen: screen)249 }250251 252 historyManager.addToHistory(wallpaper)253254 if saveToFolderEnabled {255 let destFolder = URL(fileURLWithPath: saveFolderPath)256 try wallpaperManager.saveToFolder(from: localURL, to: destFolder)257 }258259 } catch {260 self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"261 }262 }263264 func setLocalWallpaper(_ wallpaper: LocalWallpaper, on screen: NSScreen? = nil) async {265 do {266 try await MainActor.run {267 try wallpaperManager.setDesktopImage(from: wallpaper.url, screen: screen)268 }269270 let historyWallpaper = Wallpaper(271 id: wallpaper.id,272 url: wallpaper.url.path,273 path: wallpaper.url.path,274 resolution: wallpaper.resolution,275 purity: "local",276 file_size: Int(wallpaper.fileSize),277 thumbs: Wallpaper.Thumbs(278 large: wallpaper.url.path,279 original: wallpaper.url.path,280 small: wallpaper.url.path281 )282 )283 historyManager.addToHistory(historyWallpaper)284285 } catch {286 self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"287 }288 }289290 func downloadWallpaper(_ wallpaper: Wallpaper) async {291 do {292 let localURL = try await wallpaperManager.download(from: URL(string: wallpaper.path)!)293294 295 let destFolder = URL(fileURLWithPath: saveFolderPath)296 try wallpaperManager.saveToFolder(from: localURL, to: destFolder)297298 } catch {299 self.errorMessage = "Failed to download: \(error.localizedDescription)"300 }301 }302303 func selectSaveFolder() {304 305 306 MenuController.shared?.beginModalPresentation()307 NSApp.activate(ignoringOtherApps: true)308309 chooseFolder(URL(fileURLWithPath: saveFolderPath)) { url in310 if let url { self.saveFolderPath = url.path }311 MenuController.shared?.endModalPresentation()312 }313 }314315 static func makeFolderPanel(at directory: URL) -> NSOpenPanel {316 let panel = NSOpenPanel()317 panel.canChooseFiles = false318 panel.canChooseDirectories = true319 panel.canCreateDirectories = true320 panel.allowsMultipleSelection = false321 panel.directoryURL = directory322 panel.prompt = "Choose"323 return panel324 }325326 static func presentFolderPicker(327 at directory: URL, completion: @escaping (URL?) -> Void328 ) {329 presentFolderPicker(panel: makeFolderPanel(at: directory), completion: completion)330 }331332 static func presentFolderPicker(333 panel: NSOpenPanel, completion: @escaping (URL?) -> Void334 ) {335 panel.begin { response in completion(response == .OK ? panel.url : nil) }336 }337338 func quit() {339 terminateApplication()340 }341}