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}4647484950struct WallhavenStatus: Equatable {51 let title: String52 let detail: String5354 55 56 init?(error: Error) {57 guard let error = error as? WallhavenError,58 case .unavailable(let detail) = error59 else { return nil }60 self.title = "Wallhaven is offline"61 self.detail = detail62 }63}6465@MainActor66class AppState: ObservableObject {67 68 @Published var activeTab: AppTab = .explore6970 71 @Published var wallpapers: [Wallpaper] = []72 @Published var favoritesManager = FavoritesManager.shared73 @Published var historyManager = HistoryManager.shared7475 76 @Published var feedMode: FeedMode = .latest77 @Published var searchText: String = ""78 @Published var selectedResolution: String = ""79 @Published var selectedRatio: String = ""80 @Published var isLoading: Bool = false81 @Published var errorMessage: String?82 83 84 @Published var wallhavenStatus: WallhavenStatus?8586 87 @Published var canLoadMore = true88 private var currentPage = 189 private var isFetchingMore = false9091 92 @Published var saveToFolderEnabled: Bool {93 didSet { UserDefaults.standard.set(saveToFolderEnabled, forKey: "saveToFolderEnabled") }94 }95 @Published var saveFolderPath: String {96 didSet { UserDefaults.standard.set(saveFolderPath, forKey: "saveFolderPath") }97 }98 @Published var sourceFolderPaths: [String] {99 didSet { UserDefaults.standard.set(sourceFolderPaths, forKey: "sourceFolderPaths") }100 }101 @Published var launchAtLogin: Bool {102 didSet { autostartManager.isEnabled = launchAtLogin }103 }104105 106 @Published var categories: (general: Bool, anime: Bool, people: Bool) = (true, true, true)107 @Published var purity: (sfw: Bool, sketchy: Bool) = (true, false)108109 let availableResolutions = ["Any", "1920x1080", "2560x1440", "3840x2160", "5120x2880"]110 let availableRatios = ["Any", "16x9", "16x10", "21x9", "32x9", "4x3"]111112 private let api: WallhavenSearching113 private let wallpaperManager: WallpaperManaging114 private let autostartManager: AutostartManaging115 private let chooseFolder: @MainActor (URL, @escaping (URL?) -> Void) -> Void116 private let chooseSourceFolders: @MainActor ([URL], @escaping ([URL]) -> Void) -> Void117 private let terminateApplication: @MainActor () -> Void118 private let removeTempFile: @MainActor (URL) throws -> Void119 private var cancellables = Set<AnyCancellable>()120 121 122 123 124 private var searchGeneration: UInt64 = 0125126 init(127 api: WallhavenSearching? = nil,128 wallpaperManager: WallpaperManaging? = nil,129 autostartManager: AutostartManaging? = nil,130 chooseFolder: (@MainActor (URL, @escaping (URL?) -> Void) -> Void)? = nil,131 chooseSourceFolders: (@MainActor ([URL], @escaping ([URL]) -> Void) -> Void)? = nil,132 terminateApplication: (@MainActor () -> Void)? = nil,133 removeTempFile: @escaping @MainActor (URL) throws -> Void = {134 try FileManager.default.removeItem(at: $0)135 },136 picturesDirectoryProvider: @escaping () -> URL? = {137 FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first138 },139 observeChanges: Bool = true140 ) {141 let api = api ?? WallhavenAPI()142 let wallpaperManager = wallpaperManager ?? WallpaperManager.shared143 let autostartManager = autostartManager ?? AutostartManager.shared144 self.api = api145 self.wallpaperManager = wallpaperManager146 self.autostartManager = autostartManager147 self.chooseFolder = chooseFolder ?? AppState.presentFolderPicker148 self.chooseSourceFolders = chooseSourceFolders ?? AppState.chooseDefaultSourceFolders149 self.terminateApplication = terminateApplication ?? { NSApp.terminate(nil) }150 self.removeTempFile = removeTempFile151 self.launchAtLogin = autostartManager.isEnabled152 153 self.saveToFolderEnabled = UserDefaults.standard.bool(forKey: "saveToFolderEnabled")154 if let path = UserDefaults.standard.string(forKey: "saveFolderPath") {155 self.saveFolderPath = path156 } else {157 let pictures = picturesDirectoryProvider() ?? FileManager.default.temporaryDirectory158 self.saveFolderPath = pictures.appendingPathComponent("Haven").path159 }160 161 162 163 if let storedSources = UserDefaults.standard.array(forKey: "sourceFolderPaths")164 as? [String]165 {166 self.sourceFolderPaths = storedSources167 } else if let legacyPath = UserDefaults.standard.string(forKey: "saveFolderPath") {168 let seeded = [legacyPath]169 self.sourceFolderPaths = seeded170 UserDefaults.standard.set(seeded, forKey: "sourceFolderPaths")171 } else {172 self.sourceFolderPaths = []173 }174175 guard observeChanges else { return }176177 178 179 180 181 182 $searchText183 .dropFirst()184 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)185 .sink { [weak self] _ in self?.scheduleSearch() }186 .store(in: &cancellables)187188 189 $categories190 .dropFirst()191 .map { _ in () }192 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)193 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }194 .store(in: &cancellables)195196 $purity197 .dropFirst()198 .map { _ in () }199 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)200 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }201 .store(in: &cancellables)202203 204 205 206 $selectedResolution207 .dropFirst()208 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)209 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }210 .store(in: &cancellables)211212 $selectedRatio213 .dropFirst()214 .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)215 .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }216 .store(in: &cancellables)217218 $feedMode219 .dropFirst()220 .sink { [weak self] _ in221 Task { [weak self] in await self?.fetchWallpapers(reset: true) }222 }223 .store(in: &cancellables)224225 updateLocalWallpapersMonitoring()226227 $saveToFolderEnabled228 .dropFirst()229 .sink { [weak self] _ in230 self?.updateLocalWallpapersMonitoring()231 }232 .store(in: &cancellables)233234 $saveFolderPath235 .dropFirst()236 .sink { [weak self] _ in237 self?.updateLocalWallpapersMonitoring()238 }239 .store(in: &cancellables)240241 $sourceFolderPaths242 .dropFirst()243 .sink { [weak self] _ in244 self?.updateLocalWallpapersMonitoring()245 }246 .store(in: &cancellables)247 }248249 private func updateLocalWallpapersMonitoring() {250 if saveToFolderEnabled {251 LocalWallpapersManager.shared.startMonitoring(folderPath: saveFolderPath)252 } else {253 LocalWallpapersManager.shared.stopMonitoring()254 }255 }256257 func scheduleSearch() { Task { [weak self] in await self?.fetchWallpapers() } }258259 func fetchWallpapers(reset: Bool = true, refreshing: Bool = false) async {260 if reset {261 isFetchingMore = false262 searchGeneration &+= 1263 }264 let requestGeneration = searchGeneration265 let pageToRequest: Int266 if reset {267 if !refreshing {268 isLoading = true269 wallpapers = []270 }271 errorMessage = nil272 currentPage = 1273 canLoadMore = true274 pageToRequest = 1275 } else {276 guard !isFetchingMore && canLoadMore else { return }277 isFetchingMore = true278 pageToRequest = currentPage279 }280281 do {282 let resolutions =283 selectedResolution == "Any" || selectedResolution.isEmpty ? [] : [selectedResolution]284 let ratios = selectedRatio == "Any" || selectedRatio.isEmpty ? [] : [selectedRatio]285286 287 let catStr =288 "\(categories.general ? "1" : "0")\(categories.anime ? "1" : "0")\(categories.people ? "1" : "0")"289 290 let purityStr = "\(purity.sfw ? "1" : "0")\(purity.sketchy ? "1" : "0")0"291292 let (newWallpapers, meta) = try await api.searchWallpapers(293 query: searchText,294 categories: catStr,295 purity: purityStr,296 sorting: feedMode.sorting,297 resolutions: resolutions,298 ratios: ratios,299 limit: 24,300 page: pageToRequest301 )302303 if requestGeneration != searchGeneration { return }304 self.wallhavenStatus = nil305 if reset {306 self.wallpapers = newWallpapers307 } else {308 self.wallpapers.append(contentsOf: newWallpapers)309 }310311 if let meta = meta {312 let nextPage = meta.current_page + 1313 self.canLoadMore = meta.current_page < meta.last_page314 315 316 317 if requestGeneration == searchGeneration {318 self.currentPage = nextPage319 }320 } else if !newWallpapers.isEmpty {321 self.canLoadMore = true322 if requestGeneration == searchGeneration {323 self.currentPage += 1324 }325 } else {326 self.canLoadMore = false327 }328329 } catch {330 if reset && requestGeneration == searchGeneration {331 if let status = WallhavenStatus(error: error) {332 wallhavenStatus = status333 334 335 336 errorMessage = wallpapers.isEmpty ? nil : status.detail337 } else {338 errorMessage = error.localizedDescription339 }340 }341 }342343 if reset {344 if requestGeneration == searchGeneration {345 isLoading = false346 }347 } else if requestGeneration == searchGeneration {348 isFetchingMore = false349 }350 }351352 func loadMore() async {353 await fetchWallpapers(reset: false)354 }355356 func setWallpaper(_ wallpaper: Wallpaper, on screen: NSScreen? = nil) async {357 guard let remoteURL = URL(string: wallpaper.path) else {358 self.errorMessage = "Failed to set wallpaper: invalid image URL"359 return360 }361 do {362 363 364 365 let localURL = try await wallpaperManager.cachedLocalFile(for: remoteURL)366367 368 369 try wallpaperManager.setDesktopImage(from: localURL, screen: screen)370371 372 historyManager.addToHistory(wallpaper)373374 if saveToFolderEnabled {375 let destFolder = URL(fileURLWithPath: saveFolderPath)376 try wallpaperManager.saveToFolder(from: localURL, to: destFolder)377 }378379 } catch {380 self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"381 }382 }383384 func setLocalWallpaper(_ wallpaper: LocalWallpaper, on screen: NSScreen? = nil) async {385 do {386 387 388 try wallpaperManager.setDesktopImage(from: wallpaper.url, screen: screen)389390 let historyWallpaper = Wallpaper(391 id: wallpaper.id,392 url: wallpaper.url.path,393 path: wallpaper.url.path,394 resolution: wallpaper.resolution,395 purity: "local",396 file_size: Int(wallpaper.fileSize),397 thumbs: Wallpaper.Thumbs(398 large: wallpaper.url.path,399 original: wallpaper.url.path,400 small: wallpaper.url.path401 )402 )403 historyManager.addToHistory(historyWallpaper)404405 } catch {406 self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"407 }408 }409410 func downloadWallpaper(_ wallpaper: Wallpaper) async {411 guard let remoteURL = URL(string: wallpaper.path) else {412 self.errorMessage = "Failed to download: invalid image URL"413 return414 }415 do {416 let localURL = try await wallpaperManager.download(from: remoteURL)417 defer { try? removeTempFile(localURL) }418419 420 let destFolder = URL(fileURLWithPath: saveFolderPath)421 try wallpaperManager.saveToFolder(from: localURL, to: destFolder)422423 } catch {424 self.errorMessage = "Failed to download: \(error.localizedDescription)"425 }426 }427428 func selectSaveFolder() {429 430 431 MenuController.shared?.beginModalPresentation()432 NSApp.activate(ignoringOtherApps: true)433434 chooseFolder(URL(fileURLWithPath: saveFolderPath)) { url in435 if let url { self.saveFolderPath = url.path }436 MenuController.shared?.endModalPresentation()437 }438 }439440 static func makeFolderPanel(at directory: URL) -> NSOpenPanel {441 let panel = NSOpenPanel()442 panel.canChooseFiles = false443 panel.canChooseDirectories = true444 panel.canCreateDirectories = true445 panel.allowsMultipleSelection = false446 panel.directoryURL = directory447 panel.prompt = "Choose"448 return panel449 }450451 static func presentFolderPicker(452 at directory: URL, completion: @escaping (URL?) -> Void453 ) {454 presentFolderPicker(panel: makeFolderPanel(at: directory), completion: completion)455 }456457 static func presentFolderPicker(458 panel: NSOpenPanel, completion: @escaping (URL?) -> Void459 ) {460 panel.begin { response in completion(response == .OK ? panel.url : nil) }461 }462463 464 465 func addSourceFolders() {466 MenuController.shared?.beginModalPresentation()467 NSApp.activate(ignoringOtherApps: true)468469 chooseSourceFolders([]) { urls in470 let merged = (self.sourceFolderPaths + urls.map(\.path))471 .reduce(into: [String]()) { acc, path in472 if !acc.contains(path) { acc.append(path) }473 }474 self.sourceFolderPaths = merged475 MenuController.shared?.endModalPresentation()476 }477 }478479 480 481 func removeSourceFolder(_ path: String) {482 sourceFolderPaths.removeAll { $0 == path }483 }484485 static func makeSourceFoldersPanel() -> NSOpenPanel {486 let panel = NSOpenPanel()487 panel.canChooseFiles = false488 panel.canChooseDirectories = true489 panel.canCreateDirectories = true490 panel.allowsMultipleSelection = true491 panel.prompt = "Add"492 return panel493 }494495 static func presentSourceFoldersPicker(496 panel: NSOpenPanel, completion: @escaping ([URL]) -> Void497 ) {498 panel.begin { response in499 completion(response == .OK ? panel.urls : [])500 }501 }502503 static func presentSourceFoldersPicker(504 _ directory: URL, completion: @escaping ([URL]) -> Void505 ) {506 let panel = makeSourceFoldersPanel()507 panel.directoryURL = directory508 presentSourceFoldersPicker(panel: panel, completion: completion)509 }510511 static let chooseDefaultSourceFolders: @MainActor ([URL], @escaping ([URL]) -> Void) -> Void =512 { directories, completion in513 let start = directories.last ?? FileManager.default.homeDirectoryForCurrentUser514 presentSourceFoldersPicker(start, completion: completion)515 }516517 func quit() {518 terminateApplication()519 }520}