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 sourceFolderPaths: [String] {78 didSet { UserDefaults.standard.set(sourceFolderPaths, forKey: "sourceFolderPaths") }79 }80 @Published var launchAtLogin: Bool {81 didSet { autostartManager.isEnabled = launchAtLogin }82 }8384 85 @Published var categories: (general: Bool, anime: Bool, people: Bool) = (true, true, true)86 @Published var purity: (sfw: Bool, sketchy: Bool) = (true, false)8788 let availableResolutions = ["Any", "1920x1080", "2560x1440", "3840x2160", "5120x2880"]89 let availableRatios = ["Any", "16x9", "16x10", "21x9", "32x9", "4x3"]9091 private let api: WallhavenSearching92 private let wallpaperManager: WallpaperManaging93 private let autostartManager: AutostartManaging94 private let chooseFolder: @MainActor (URL, @escaping (URL?) -> Void) -> Void95 private let chooseSourceFolders: @MainActor ([URL], @escaping ([URL]) -> Void) -> Void96 private let terminateApplication: @MainActor () -> Void97 private let removeTempFile: @MainActor (URL) throws -> Void98 private var cancellables = Set<AnyCancellable>()99 100 101 102 103 private var searchGeneration: UInt64 = 0104105 init(106 api: WallhavenSearching? = nil,107 wallpaperManager: WallpaperManaging? = nil,108 autostartManager: AutostartManaging? = nil,109 chooseFolder: (@MainActor (URL, @escaping (URL?) -> Void) -> Void)? = nil,110 chooseSourceFolders: (@MainActor ([URL], @escaping ([URL]) -> Void) -> Void)? = nil,111 terminateApplication: (@MainActor () -> Void)? = nil,112 removeTempFile: @escaping @MainActor (URL) throws -> Void = {113 try FileManager.default.removeItem(at: $0)114 },115 picturesDirectoryProvider: @escaping () -> URL? = {116 FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first117 },118 observeChanges: Bool = true119 ) {120 let api = api ?? WallhavenAPI()121 let wallpaperManager = wallpaperManager ?? WallpaperManager.shared122 let autostartManager = autostartManager ?? AutostartManager.shared123 self.api = api124 self.wallpaperManager = wallpaperManager125 self.autostartManager = autostartManager126 self.chooseFolder = chooseFolder ?? AppState.presentFolderPicker127 self.chooseSourceFolders = chooseSourceFolders ?? AppState.chooseDefaultSourceFolders128 self.terminateApplication = terminateApplication ?? { NSApp.terminate(nil) }129 self.removeTempFile = removeTempFile130 self.launchAtLogin = autostartManager.isEnabled131 132 self.saveToFolderEnabled = UserDefaults.standard.bool(forKey: "saveToFolderEnabled")133 if let path = UserDefaults.standard.string(forKey: "saveFolderPath") {134 self.saveFolderPath = path135 } else {136 let pictures = picturesDirectoryProvider() ?? FileManager.default.temporaryDirectory137 self.saveFolderPath = pictures.appendingPathComponent("Haven").path138 }139 140 141 142 if let storedSources = UserDefaults.standard.array(forKey: "sourceFolderPaths")143 as? [String]144 {145 self.sourceFolderPaths = storedSources146 } else if let legacyPath = UserDefaults.standard.string(forKey: "saveFolderPath") {147 let seeded = [legacyPath]148 self.sourceFolderPaths = seeded149 UserDefaults.standard.set(seeded, forKey: "sourceFolderPaths")150 } else {151 self.sourceFolderPaths = []152 }153154 guard observeChanges else { return }155156 157 158 159 160 161 $searchText162 .dropFirst()163 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)164 .sink { [weak self] _ in self?.scheduleSearch() }165 .store(in: &cancellables)166167 168 $categories169 .dropFirst()170 .map { _ in () }171 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)172 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }173 .store(in: &cancellables)174175 $purity176 .dropFirst()177 .map { _ in () }178 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)179 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }180 .store(in: &cancellables)181182 183 184 185 $selectedResolution186 .dropFirst()187 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)188 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }189 .store(in: &cancellables)190191 $selectedRatio192 .dropFirst()193 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)194 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }195 .store(in: &cancellables)196197 $feedMode198 .dropFirst()199 .sink { [weak self] _ in200 Task { [weak self] in await self?.fetchWallpapers(reset: true) }201 }202 .store(in: &cancellables)203204 updateLocalWallpapersMonitoring()205206 $saveToFolderEnabled207 .dropFirst()208 .sink { [weak self] _ in209 self?.updateLocalWallpapersMonitoring()210 }211 .store(in: &cancellables)212213 $saveFolderPath214 .dropFirst()215 .sink { [weak self] _ in216 self?.updateLocalWallpapersMonitoring()217 }218 .store(in: &cancellables)219220 $sourceFolderPaths221 .dropFirst()222 .sink { [weak self] _ in223 self?.updateLocalWallpapersMonitoring()224 }225 .store(in: &cancellables)226 }227228 private func updateLocalWallpapersMonitoring() {229 if saveToFolderEnabled {230 LocalWallpapersManager.shared.startMonitoring(folderPath: saveFolderPath)231 } else {232 LocalWallpapersManager.shared.stopMonitoring()233 }234 }235236 func scheduleSearch() { Task { [weak self] in await self?.fetchWallpapers() } }237238 func fetchWallpapers(reset: Bool = true, refreshing: Bool = false) async {239 if reset {240 isFetchingMore = false241 searchGeneration &+= 1242 }243 let requestGeneration = searchGeneration244 let pageToRequest: Int245 if reset {246 if !refreshing {247 isLoading = true248 wallpapers = []249 }250 errorMessage = nil251 currentPage = 1252 canLoadMore = true253 pageToRequest = 1254 } else {255 guard !isFetchingMore && canLoadMore else { return }256 isFetchingMore = true257 pageToRequest = currentPage258 }259260 do {261 let resolutions =262 selectedResolution == "Any" || selectedResolution.isEmpty ? [] : [selectedResolution]263 let ratios = selectedRatio == "Any" || selectedRatio.isEmpty ? [] : [selectedRatio]264265 266 let catStr =267 "\(categories.general ? "1" : "0")\(categories.anime ? "1" : "0")\(categories.people ? "1" : "0")"268 269 let purityStr = "\(purity.sfw ? "1" : "0")\(purity.sketchy ? "1" : "0")0"270271 let (newWallpapers, meta) = try await api.searchWallpapers(272 query: searchText,273 categories: catStr,274 purity: purityStr,275 sorting: feedMode.sorting,276 resolutions: resolutions,277 ratios: ratios,278 limit: 24,279 page: pageToRequest280 )281282 if requestGeneration != searchGeneration { return }283 if reset {284 self.wallpapers = newWallpapers285 } else {286 self.wallpapers.append(contentsOf: newWallpapers)287 }288289 if let meta = meta {290 let nextPage = meta.current_page + 1291 self.canLoadMore = meta.current_page < meta.last_page292 293 294 295 if requestGeneration == searchGeneration {296 self.currentPage = nextPage297 }298 } else if !newWallpapers.isEmpty {299 self.canLoadMore = true300 if requestGeneration == searchGeneration {301 self.currentPage += 1302 }303 } else {304 self.canLoadMore = false305 }306307 } catch {308 if reset && requestGeneration == searchGeneration {309 self.errorMessage = error.localizedDescription310 }311 }312313 if reset {314 if requestGeneration == searchGeneration {315 isLoading = false316 }317 } else if requestGeneration == searchGeneration {318 isFetchingMore = false319 }320 }321322 func loadMore() async {323 await fetchWallpapers(reset: false)324 }325326 func setWallpaper(_ wallpaper: Wallpaper, on screen: NSScreen? = nil) async {327 guard let remoteURL = URL(string: wallpaper.path) else {328 self.errorMessage = "Failed to set wallpaper: invalid image URL"329 return330 }331 do {332 333 334 335 let localURL = try await wallpaperManager.cachedLocalFile(for: remoteURL)336337 338 339 try wallpaperManager.setDesktopImage(from: localURL, screen: screen)340341 342 historyManager.addToHistory(wallpaper)343344 if saveToFolderEnabled {345 let destFolder = URL(fileURLWithPath: saveFolderPath)346 try wallpaperManager.saveToFolder(from: localURL, to: destFolder)347 }348349 } catch {350 self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"351 }352 }353354 func setLocalWallpaper(_ wallpaper: LocalWallpaper, on screen: NSScreen? = nil) async {355 do {356 357 358 try wallpaperManager.setDesktopImage(from: wallpaper.url, screen: screen)359360 let historyWallpaper = Wallpaper(361 id: wallpaper.id,362 url: wallpaper.url.path,363 path: wallpaper.url.path,364 resolution: wallpaper.resolution,365 purity: "local",366 file_size: Int(wallpaper.fileSize),367 thumbs: Wallpaper.Thumbs(368 large: wallpaper.url.path,369 original: wallpaper.url.path,370 small: wallpaper.url.path371 )372 )373 historyManager.addToHistory(historyWallpaper)374375 } catch {376 self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"377 }378 }379380 func downloadWallpaper(_ wallpaper: Wallpaper) async {381 guard let remoteURL = URL(string: wallpaper.path) else {382 self.errorMessage = "Failed to download: invalid image URL"383 return384 }385 do {386 let localURL = try await wallpaperManager.download(from: remoteURL)387 defer { try? removeTempFile(localURL) }388389 390 let destFolder = URL(fileURLWithPath: saveFolderPath)391 try wallpaperManager.saveToFolder(from: localURL, to: destFolder)392393 } catch {394 self.errorMessage = "Failed to download: \(error.localizedDescription)"395 }396 }397398 func selectSaveFolder() {399 400 401 MenuController.shared?.beginModalPresentation()402 NSApp.activate(ignoringOtherApps: true)403404 chooseFolder(URL(fileURLWithPath: saveFolderPath)) { url in405 if let url { self.saveFolderPath = url.path }406 MenuController.shared?.endModalPresentation()407 }408 }409410 static func makeFolderPanel(at directory: URL) -> NSOpenPanel {411 let panel = NSOpenPanel()412 panel.canChooseFiles = false413 panel.canChooseDirectories = true414 panel.canCreateDirectories = true415 panel.allowsMultipleSelection = false416 panel.directoryURL = directory417 panel.prompt = "Choose"418 return panel419 }420421 static func presentFolderPicker(422 at directory: URL, completion: @escaping (URL?) -> Void423 ) {424 presentFolderPicker(panel: makeFolderPanel(at: directory), completion: completion)425 }426427 static func presentFolderPicker(428 panel: NSOpenPanel, completion: @escaping (URL?) -> Void429 ) {430 panel.begin { response in completion(response == .OK ? panel.url : nil) }431 }432433 434 435 func addSourceFolders() {436 MenuController.shared?.beginModalPresentation()437 NSApp.activate(ignoringOtherApps: true)438439 chooseSourceFolders([]) { urls in440 let merged = (self.sourceFolderPaths + urls.map(\.path))441 .reduce(into: [String]()) { acc, path in442 if !acc.contains(path) { acc.append(path) }443 }444 self.sourceFolderPaths = merged445 MenuController.shared?.endModalPresentation()446 }447 }448449 450 451 func removeSourceFolder(_ path: String) {452 sourceFolderPaths.removeAll { $0 == path }453 }454455 static func makeSourceFoldersPanel() -> NSOpenPanel {456 let panel = NSOpenPanel()457 panel.canChooseFiles = false458 panel.canChooseDirectories = true459 panel.canCreateDirectories = true460 panel.allowsMultipleSelection = true461 panel.prompt = "Add"462 return panel463 }464465 static func presentSourceFoldersPicker(466 panel: NSOpenPanel, completion: @escaping ([URL]) -> Void467 ) {468 panel.begin { response in469 completion(response == .OK ? panel.urls : [])470 }471 }472473 static func presentSourceFoldersPicker(474 _ directory: URL, completion: @escaping ([URL]) -> Void475 ) {476 let panel = makeSourceFoldersPanel()477 panel.directoryURL = directory478 presentSourceFoldersPicker(panel: panel, completion: completion)479 }480481 static let chooseDefaultSourceFolders: @MainActor ([URL], @escaping ([URL]) -> Void) -> Void =482 { directories, completion in483 let start = directories.last ?? FileManager.default.homeDirectoryForCurrentUser484 presentSourceFoldersPicker(start, completion: completion)485 }486487 func quit() {488 terminateApplication()489 }490}