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 $searchText158 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)159 .sink { [weak self] _ in self?.scheduleSearch() }160 .store(in: &cancellables)161162 163 $categories164 .map { _ in () }165 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)166 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }167 .store(in: &cancellables)168169 $purity170 .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 176 177 178 $selectedResolution179 .dropFirst()180 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)181 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }182 .store(in: &cancellables)183184 $selectedRatio185 .dropFirst()186 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)187 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }188 .store(in: &cancellables)189190 $feedMode191 .dropFirst()192 .sink { [weak self] _ in193 Task { [weak self] in await self?.fetchWallpapers(reset: true) }194 }195 .store(in: &cancellables)196197 updateLocalWallpapersMonitoring()198199 $saveToFolderEnabled200 .dropFirst()201 .sink { [weak self] _ in202 self?.updateLocalWallpapersMonitoring()203 }204 .store(in: &cancellables)205206 $saveFolderPath207 .dropFirst()208 .sink { [weak self] _ in209 self?.updateLocalWallpapersMonitoring()210 }211 .store(in: &cancellables)212213 $sourceFolderPaths214 .dropFirst()215 .sink { [weak self] _ in216 self?.updateLocalWallpapersMonitoring()217 }218 .store(in: &cancellables)219 }220221 private func updateLocalWallpapersMonitoring() {222 if saveToFolderEnabled {223 LocalWallpapersManager.shared.startMonitoring(folderPath: saveFolderPath)224 } else {225 LocalWallpapersManager.shared.stopMonitoring()226 }227 }228229 func scheduleSearch() { Task { [weak self] in await self?.fetchWallpapers() } }230231 func fetchWallpapers(reset: Bool = true, refreshing: Bool = false) async {232 if reset {233 isFetchingMore = false234 searchGeneration &+= 1235 }236 let requestGeneration = searchGeneration237 let pageToRequest: Int238 if reset {239 if !refreshing {240 isLoading = true241 wallpapers = []242 }243 errorMessage = nil244 currentPage = 1245 canLoadMore = true246 pageToRequest = 1247 } else {248 guard !isFetchingMore && canLoadMore else { return }249 isFetchingMore = true250 pageToRequest = currentPage251 }252253 do {254 let resolutions =255 selectedResolution == "Any" || selectedResolution.isEmpty ? [] : [selectedResolution]256 let ratios = selectedRatio == "Any" || selectedRatio.isEmpty ? [] : [selectedRatio]257258 259 let catStr =260 "\(categories.general ? "1" : "0")\(categories.anime ? "1" : "0")\(categories.people ? "1" : "0")"261 262 let purityStr = "\(purity.sfw ? "1" : "0")\(purity.sketchy ? "1" : "0")0"263264 let (newWallpapers, meta) = try await api.searchWallpapers(265 query: searchText,266 categories: catStr,267 purity: purityStr,268 sorting: feedMode.sorting,269 resolutions: resolutions,270 ratios: ratios,271 limit: 24,272 page: pageToRequest273 )274275 if requestGeneration != searchGeneration { return }276 if reset {277 self.wallpapers = newWallpapers278 } else {279 self.wallpapers.append(contentsOf: newWallpapers)280 }281282 if let meta = meta {283 let nextPage = meta.current_page + 1284 self.canLoadMore = meta.current_page < meta.last_page285 286 287 288 if requestGeneration == searchGeneration {289 self.currentPage = nextPage290 }291 } else if !newWallpapers.isEmpty {292 self.canLoadMore = true293 if requestGeneration == searchGeneration {294 self.currentPage += 1295 }296 } else {297 self.canLoadMore = false298 }299300 } catch {301 if reset && requestGeneration == searchGeneration {302 self.errorMessage = error.localizedDescription303 }304 }305306 if reset {307 if requestGeneration == searchGeneration {308 isLoading = false309 }310 } else if requestGeneration == searchGeneration {311 isFetchingMore = false312 }313 }314315 func loadMore() async {316 await fetchWallpapers(reset: false)317 }318319 func setWallpaper(_ wallpaper: Wallpaper, on screen: NSScreen? = nil) async {320 guard let remoteURL = URL(string: wallpaper.path) else {321 self.errorMessage = "Failed to set wallpaper: invalid image URL"322 return323 }324 do {325 let localURL = try await wallpaperManager.download(from: remoteURL)326 defer { try? removeTempFile(localURL) }327328 329 330 try wallpaperManager.setDesktopImage(from: localURL, screen: screen)331332 333 historyManager.addToHistory(wallpaper)334335 if saveToFolderEnabled {336 let destFolder = URL(fileURLWithPath: saveFolderPath)337 try wallpaperManager.saveToFolder(from: localURL, to: destFolder)338 }339340 } catch {341 self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"342 }343 }344345 func setLocalWallpaper(_ wallpaper: LocalWallpaper, on screen: NSScreen? = nil) async {346 do {347 348 349 try wallpaperManager.setDesktopImage(from: wallpaper.url, screen: screen)350351 let historyWallpaper = Wallpaper(352 id: wallpaper.id,353 url: wallpaper.url.path,354 path: wallpaper.url.path,355 resolution: wallpaper.resolution,356 purity: "local",357 file_size: Int(wallpaper.fileSize),358 thumbs: Wallpaper.Thumbs(359 large: wallpaper.url.path,360 original: wallpaper.url.path,361 small: wallpaper.url.path362 )363 )364 historyManager.addToHistory(historyWallpaper)365366 } catch {367 self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"368 }369 }370371 func downloadWallpaper(_ wallpaper: Wallpaper) async {372 guard let remoteURL = URL(string: wallpaper.path) else {373 self.errorMessage = "Failed to download: invalid image URL"374 return375 }376 do {377 let localURL = try await wallpaperManager.download(from: remoteURL)378 defer { try? removeTempFile(localURL) }379380 381 let destFolder = URL(fileURLWithPath: saveFolderPath)382 try wallpaperManager.saveToFolder(from: localURL, to: destFolder)383384 } catch {385 self.errorMessage = "Failed to download: \(error.localizedDescription)"386 }387 }388389 func selectSaveFolder() {390 391 392 MenuController.shared?.beginModalPresentation()393 NSApp.activate(ignoringOtherApps: true)394395 chooseFolder(URL(fileURLWithPath: saveFolderPath)) { url in396 if let url { self.saveFolderPath = url.path }397 MenuController.shared?.endModalPresentation()398 }399 }400401 static func makeFolderPanel(at directory: URL) -> NSOpenPanel {402 let panel = NSOpenPanel()403 panel.canChooseFiles = false404 panel.canChooseDirectories = true405 panel.canCreateDirectories = true406 panel.allowsMultipleSelection = false407 panel.directoryURL = directory408 panel.prompt = "Choose"409 return panel410 }411412 static func presentFolderPicker(413 at directory: URL, completion: @escaping (URL?) -> Void414 ) {415 presentFolderPicker(panel: makeFolderPanel(at: directory), completion: completion)416 }417418 static func presentFolderPicker(419 panel: NSOpenPanel, completion: @escaping (URL?) -> Void420 ) {421 panel.begin { response in completion(response == .OK ? panel.url : nil) }422 }423424 425 426 func addSourceFolders() {427 MenuController.shared?.beginModalPresentation()428 NSApp.activate(ignoringOtherApps: true)429430 chooseSourceFolders([]) { urls in431 let merged = (self.sourceFolderPaths + urls.map(\.path))432 .reduce(into: [String]()) { acc, path in433 if !acc.contains(path) { acc.append(path) }434 }435 self.sourceFolderPaths = merged436 MenuController.shared?.endModalPresentation()437 }438 }439440 441 442 func removeSourceFolder(_ path: String) {443 sourceFolderPaths.removeAll { $0 == path }444 }445446 static func makeSourceFoldersPanel() -> NSOpenPanel {447 let panel = NSOpenPanel()448 panel.canChooseFiles = false449 panel.canChooseDirectories = true450 panel.canCreateDirectories = true451 panel.allowsMultipleSelection = true452 panel.prompt = "Add"453 return panel454 }455456 static func presentSourceFoldersPicker(457 panel: NSOpenPanel, completion: @escaping ([URL]) -> Void458 ) {459 panel.begin { response in460 completion(response == .OK ? panel.urls : [])461 }462 }463464 static func presentSourceFoldersPicker(465 _ directory: URL, completion: @escaping ([URL]) -> Void466 ) {467 let panel = makeSourceFoldersPanel()468 panel.directoryURL = directory469 presentSourceFoldersPicker(panel: panel, completion: completion)470 }471472 static let chooseDefaultSourceFolders: @MainActor ([URL], @escaping ([URL]) -> Void) -> Void =473 { directories, completion in474 let start = directories.last ?? FileManager.default.homeDirectoryForCurrentUser475 presentSourceFoldersPicker(start, completion: completion)476 }477478 func quit() {479 terminateApplication()480 }481}