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 let removeTempFile: @MainActor (URL) throws -> Void94 private var cancellables = Set<AnyCancellable>()95 96 97 98 99 private var searchGeneration: UInt64 = 0100101 init(102 api: WallhavenSearching? = nil,103 wallpaperManager: WallpaperManaging? = nil,104 autostartManager: AutostartManaging? = nil,105 chooseFolder: (@MainActor (URL, @escaping (URL?) -> Void) -> Void)? = nil,106 terminateApplication: (@MainActor () -> Void)? = nil,107 removeTempFile: @escaping @MainActor (URL) throws -> Void = {108 try FileManager.default.removeItem(at: $0)109 },110 picturesDirectoryProvider: @escaping () -> URL? = {111 FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first112 },113 observeChanges: Bool = true114 ) {115 let api = api ?? WallhavenAPI()116 let wallpaperManager = wallpaperManager ?? WallpaperManager.shared117 let autostartManager = autostartManager ?? AutostartManager.shared118 self.api = api119 self.wallpaperManager = wallpaperManager120 self.autostartManager = autostartManager121 self.chooseFolder = chooseFolder ?? AppState.presentFolderPicker122 self.terminateApplication = terminateApplication ?? { NSApp.terminate(nil) }123 self.removeTempFile = removeTempFile124 self.launchAtLogin = autostartManager.isEnabled125 126 self.saveToFolderEnabled = UserDefaults.standard.bool(forKey: "saveToFolderEnabled")127 if let path = UserDefaults.standard.string(forKey: "saveFolderPath") {128 self.saveFolderPath = path129 } else {130 let pictures = picturesDirectoryProvider() ?? FileManager.default.temporaryDirectory131 self.saveFolderPath = pictures.appendingPathComponent("Haven").path132 }133134 guard observeChanges else { return }135136 137 $searchText138 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)139 .sink { [weak self] _ in self?.scheduleSearch() }140 .store(in: &cancellables)141142 143 $categories144 .map { _ in () }145 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)146 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }147 .store(in: &cancellables)148149 $purity150 .map { _ in () }151 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)152 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }153 .store(in: &cancellables)154155 156 157 158 $selectedResolution159 .dropFirst()160 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)161 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }162 .store(in: &cancellables)163164 $selectedRatio165 .dropFirst()166 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)167 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }168 .store(in: &cancellables)169170 $feedMode171 .dropFirst()172 .sink { [weak self] _ in173 Task { [weak self] in await self?.fetchWallpapers(reset: true) }174 }175 .store(in: &cancellables)176177 updateLocalWallpapersMonitoring()178179 $saveToFolderEnabled180 .dropFirst()181 .sink { [weak self] _ in182 self?.updateLocalWallpapersMonitoring()183 }184 .store(in: &cancellables)185186 $saveFolderPath187 .dropFirst()188 .sink { [weak self] _ in189 self?.updateLocalWallpapersMonitoring()190 }191 .store(in: &cancellables)192 }193194 private func updateLocalWallpapersMonitoring() {195 if saveToFolderEnabled {196 LocalWallpapersManager.shared.startMonitoring(folderPath: saveFolderPath)197 } else {198 LocalWallpapersManager.shared.stopMonitoring()199 }200 }201202 func scheduleSearch() { Task { [weak self] in await self?.fetchWallpapers() } }203204 func fetchWallpapers(reset: Bool = true, refreshing: Bool = false) async {205 if reset {206 isFetchingMore = false207 searchGeneration &+= 1208 }209 let requestGeneration = searchGeneration210 let pageToRequest: Int211 if reset {212 if !refreshing {213 isLoading = true214 wallpapers = []215 }216 errorMessage = nil217 currentPage = 1218 canLoadMore = true219 pageToRequest = 1220 } else {221 guard !isFetchingMore && canLoadMore else { return }222 isFetchingMore = true223 pageToRequest = currentPage224 }225226 do {227 let resolutions =228 selectedResolution == "Any" || selectedResolution.isEmpty ? [] : [selectedResolution]229 let ratios = selectedRatio == "Any" || selectedRatio.isEmpty ? [] : [selectedRatio]230231 232 let catStr =233 "\(categories.general ? "1" : "0")\(categories.anime ? "1" : "0")\(categories.people ? "1" : "0")"234 235 let purityStr = "\(purity.sfw ? "1" : "0")\(purity.sketchy ? "1" : "0")0"236237 let (newWallpapers, meta) = try await api.searchWallpapers(238 query: searchText,239 categories: catStr,240 purity: purityStr,241 sorting: feedMode.sorting,242 resolutions: resolutions,243 ratios: ratios,244 limit: 24,245 page: pageToRequest246 )247248 if requestGeneration != searchGeneration { return }249 if reset {250 self.wallpapers = newWallpapers251 } else {252 self.wallpapers.append(contentsOf: newWallpapers)253 }254255 if let meta = meta {256 let nextPage = meta.current_page + 1257 self.canLoadMore = meta.current_page < meta.last_page258 259 260 261 if requestGeneration == searchGeneration {262 self.currentPage = nextPage263 }264 } else if !newWallpapers.isEmpty {265 self.canLoadMore = true266 if requestGeneration == searchGeneration {267 self.currentPage += 1268 }269 } else {270 self.canLoadMore = false271 }272273 } catch {274 if reset && requestGeneration == searchGeneration {275 self.errorMessage = error.localizedDescription276 }277 }278279 if reset {280 if requestGeneration == searchGeneration {281 isLoading = false282 }283 } else if requestGeneration == searchGeneration {284 isFetchingMore = false285 }286 }287288 func loadMore() async {289 await fetchWallpapers(reset: false)290 }291292 func setWallpaper(_ wallpaper: Wallpaper, on screen: NSScreen? = nil) async {293 guard let remoteURL = URL(string: wallpaper.path) else {294 self.errorMessage = "Failed to set wallpaper: invalid image URL"295 return296 }297 do {298 let localURL = try await wallpaperManager.download(from: remoteURL)299 defer { try? removeTempFile(localURL) }300301 302 303 try wallpaperManager.setDesktopImage(from: localURL, screen: screen)304305 306 historyManager.addToHistory(wallpaper)307308 if saveToFolderEnabled {309 let destFolder = URL(fileURLWithPath: saveFolderPath)310 try wallpaperManager.saveToFolder(from: localURL, to: destFolder)311 }312313 } catch {314 self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"315 }316 }317318 func setLocalWallpaper(_ wallpaper: LocalWallpaper, on screen: NSScreen? = nil) async {319 do {320 321 322 try wallpaperManager.setDesktopImage(from: wallpaper.url, screen: screen)323324 let historyWallpaper = Wallpaper(325 id: wallpaper.id,326 url: wallpaper.url.path,327 path: wallpaper.url.path,328 resolution: wallpaper.resolution,329 purity: "local",330 file_size: Int(wallpaper.fileSize),331 thumbs: Wallpaper.Thumbs(332 large: wallpaper.url.path,333 original: wallpaper.url.path,334 small: wallpaper.url.path335 )336 )337 historyManager.addToHistory(historyWallpaper)338339 } catch {340 self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"341 }342 }343344 func downloadWallpaper(_ wallpaper: Wallpaper) async {345 guard let remoteURL = URL(string: wallpaper.path) else {346 self.errorMessage = "Failed to download: invalid image URL"347 return348 }349 do {350 let localURL = try await wallpaperManager.download(from: remoteURL)351 defer { try? removeTempFile(localURL) }352353 354 let destFolder = URL(fileURLWithPath: saveFolderPath)355 try wallpaperManager.saveToFolder(from: localURL, to: destFolder)356357 } catch {358 self.errorMessage = "Failed to download: \(error.localizedDescription)"359 }360 }361362 func selectSaveFolder() {363 364 365 MenuController.shared?.beginModalPresentation()366 NSApp.activate(ignoringOtherApps: true)367368 chooseFolder(URL(fileURLWithPath: saveFolderPath)) { url in369 if let url { self.saveFolderPath = url.path }370 MenuController.shared?.endModalPresentation()371 }372 }373374 static func makeFolderPanel(at directory: URL) -> NSOpenPanel {375 let panel = NSOpenPanel()376 panel.canChooseFiles = false377 panel.canChooseDirectories = true378 panel.canCreateDirectories = true379 panel.allowsMultipleSelection = false380 panel.directoryURL = directory381 panel.prompt = "Choose"382 return panel383 }384385 static func presentFolderPicker(386 at directory: URL, completion: @escaping (URL?) -> Void387 ) {388 presentFolderPicker(panel: makeFolderPanel(at: directory), completion: completion)389 }390391 static func presentFolderPicker(392 panel: NSOpenPanel, completion: @escaping (URL?) -> Void393 ) {394 panel.begin { response in completion(response == .OK ? panel.url : nil) }395 }396397 func quit() {398 terminateApplication()399 }400}