AppState.swift

12.3 KB · raw

1import AppKit2import Combine3import Foundation45enum AppTab {6  case explore7  case local8  case favorites9  case history10  case settings11}1213/// Browse mode for the Explore tab. Each maps to a Wallhaven `sorting`14/// value; the free-text search box applies on top of the chosen mode.15enum 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  // Navigation50  @Published var activeTab: AppTab = .explore5152  // Data Sources53  @Published var wallpapers: [Wallpaper] = []54  @Published var favoritesManager = FavoritesManager.shared55  @Published var historyManager = HistoryManager.shared5657  // Search & Filter58  @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  // Pagination66  @Published var canLoadMore = true67  private var currentPage = 168  private var isFetchingMore = false6970  // Settings71  @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  // Filters82  @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>()94  // Monotonic token incremented on every reset; only the search started with95  // the latest token may commit results. This keeps a slow older request from96  // overwriting the wallpapers/canLoadMore/errorMessage after a newer search97  // (or pagination) has begun.98  private var searchGeneration: UInt64 = 099100  init(101    api: WallhavenSearching? = nil,102    wallpaperManager: WallpaperManaging? = nil,103    autostartManager: AutostartManaging? = nil,104    chooseFolder: (@MainActor (URL, @escaping (URL?) -> Void) -> Void)? = nil,105    terminateApplication: (@MainActor () -> Void)? = nil,106    observeChanges: Bool = true107  ) {108    let api = api ?? WallhavenAPI()109    let wallpaperManager = wallpaperManager ?? WallpaperManager.shared110    let autostartManager = autostartManager ?? AutostartManager.shared111    self.api = api112    self.wallpaperManager = wallpaperManager113    self.autostartManager = autostartManager114    self.chooseFolder = chooseFolder ?? AppState.presentFolderPicker115    self.terminateApplication = terminateApplication ?? { NSApp.terminate(nil) }116    self.launchAtLogin = autostartManager.isEnabled117    // Load settings118    self.saveToFolderEnabled = UserDefaults.standard.bool(forKey: "saveToFolderEnabled")119    if let path = UserDefaults.standard.string(forKey: "saveFolderPath") {120      self.saveFolderPath = path121    } else {122      let pictures = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first!123      self.saveFolderPath = pictures.appendingPathComponent("Haven").path124    }125126    guard observeChanges else { return }127128    // Debounce search129    $searchText130      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)131      .sink { [weak self] _ in self?.scheduleSearch() }132      .store(in: &cancellables)133134    // Observe filters135    $categories136      .map { _ in () }137      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)138      .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }139      .store(in: &cancellables)140141    $purity142      .map { _ in () }143      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)144      .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }145      .store(in: &cancellables)146147    // Resolution and ratio pickers fire one value per menu interaction.148    // Debouncing them keeps a single user gesture producing a single149    // network request, matching the categories/purity/searchText flow.150    $selectedResolution151      .dropFirst()152      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)153      .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }154      .store(in: &cancellables)155156    $selectedRatio157      .dropFirst()158      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)159      .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }160      .store(in: &cancellables)161162    $feedMode163      .dropFirst()164      .sink { [weak self] _ in165        Task { [weak self] in await self?.fetchWallpapers(reset: true) }166      }167      .store(in: &cancellables)168169    updateLocalWallpapersMonitoring()170171    $saveToFolderEnabled172      .dropFirst()173      .sink { [weak self] _ in174        self?.updateLocalWallpapersMonitoring()175      }176      .store(in: &cancellables)177178    $saveFolderPath179      .dropFirst()180      .sink { [weak self] _ in181        self?.updateLocalWallpapersMonitoring()182      }183      .store(in: &cancellables)184  }185186  private func updateLocalWallpapersMonitoring() {187    if saveToFolderEnabled {188      LocalWallpapersManager.shared.startMonitoring(folderPath: saveFolderPath)189    } else {190      LocalWallpapersManager.shared.stopMonitoring()191    }192  }193194  func scheduleSearch() { Task { [weak self] in await self?.fetchWallpapers() } }195196  func fetchWallpapers(reset: Bool = true, refreshing: Bool = false) async {197    if reset {198      isFetchingMore = false199      searchGeneration &+= 1200    }201    let requestGeneration = searchGeneration202    let pageToRequest: Int203    if reset {204      if !refreshing {205        isLoading = true206        wallpapers = []207      }208      errorMessage = nil209      currentPage = 1210      canLoadMore = true211      pageToRequest = 1212    } else {213      guard !isFetchingMore && canLoadMore else { return }214      isFetchingMore = true215      pageToRequest = currentPage216    }217218    do {219      let resolutions =220        selectedResolution == "Any" || selectedResolution.isEmpty ? [] : [selectedResolution]221      let ratios = selectedRatio == "Any" || selectedRatio.isEmpty ? [] : [selectedRatio]222223      // Construct bitstrings224      let catStr =225        "\(categories.general ? "1" : "0")\(categories.anime ? "1" : "0")\(categories.people ? "1" : "0")"226      // NSFW is intentionally unsupported; the third purity bit is always 0.227      let purityStr = "\(purity.sfw ? "1" : "0")\(purity.sketchy ? "1" : "0")0"228229      let (newWallpapers, meta) = try await api.searchWallpapers(230        query: searchText,231        categories: catStr,232        purity: purityStr,233        sorting: feedMode.sorting,234        resolutions: resolutions,235        ratios: ratios,236        limit: 24,237        page: pageToRequest238      )239240      if requestGeneration != searchGeneration { return }241      if reset {242        self.wallpapers = newWallpapers243      } else {244        self.wallpapers.append(contentsOf: newWallpapers)245      }246247      if let meta = meta {248        let nextPage = meta.current_page + 1249        self.canLoadMore = meta.current_page < meta.last_page250        // A page that's been superseded by a newer reset would leave the next251        // page index pointing into the stale query; only advance when this252        // response is still the current generation.253        if requestGeneration == searchGeneration {254          self.currentPage = nextPage255        }256      } else if !newWallpapers.isEmpty {257        self.canLoadMore = true258        if requestGeneration == searchGeneration {259          self.currentPage += 1260        }261      } else {262        self.canLoadMore = false263      }264265    } catch {266      if reset && requestGeneration == searchGeneration {267        self.errorMessage = error.localizedDescription268      }269    }270271    if reset {272      if requestGeneration == searchGeneration {273        isLoading = false274      }275    } else if requestGeneration == searchGeneration {276      isFetchingMore = false277    }278  }279280  func loadMore() async {281    await fetchWallpapers(reset: false)282  }283284  func setWallpaper(_ wallpaper: Wallpaper, on screen: NSScreen? = nil) async {285    guard let remoteURL = URL(string: wallpaper.path) else {286      self.errorMessage = "Failed to set wallpaper: invalid image URL"287      return288    }289    do {290      let localURL = try await wallpaperManager.download(from: remoteURL)291292      // AppState is @MainActor isolated; the prior MainActor.run hop was293      // redundant because the call site already runs on the main actor.294      try wallpaperManager.setDesktopImage(from: localURL, screen: screen)295296      // Add to history297      historyManager.addToHistory(wallpaper)298299      if saveToFolderEnabled {300        let destFolder = URL(fileURLWithPath: saveFolderPath)301        try wallpaperManager.saveToFolder(from: localURL, to: destFolder)302      }303304    } catch {305      self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"306    }307  }308309  func setLocalWallpaper(_ wallpaper: LocalWallpaper, on screen: NSScreen? = nil) async {310    do {311      // AppState is @MainActor isolated; the prior MainActor.run hop was312      // redundant because the call site already runs on the main actor.313      try wallpaperManager.setDesktopImage(from: wallpaper.url, screen: screen)314315      let historyWallpaper = Wallpaper(316        id: wallpaper.id,317        url: wallpaper.url.path,318        path: wallpaper.url.path,319        resolution: wallpaper.resolution,320        purity: "local",321        file_size: Int(wallpaper.fileSize),322        thumbs: Wallpaper.Thumbs(323          large: wallpaper.url.path,324          original: wallpaper.url.path,325          small: wallpaper.url.path326        )327      )328      historyManager.addToHistory(historyWallpaper)329330    } catch {331      self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"332    }333  }334335  func downloadWallpaper(_ wallpaper: Wallpaper) async {336    guard let remoteURL = URL(string: wallpaper.path) else {337      self.errorMessage = "Failed to download: invalid image URL"338      return339    }340    do {341      let localURL = try await wallpaperManager.download(from: remoteURL)342343      // Always save to folder if download requested, or ensure save folder is used344      let destFolder = URL(fileURLWithPath: saveFolderPath)345      try wallpaperManager.saveToFolder(from: localURL, to: destFolder)346347    } catch {348      self.errorMessage = "Failed to download: \(error.localizedDescription)"349    }350  }351352  func selectSaveFolder() {353    // Hold the transient popover open; without this the picker steals focus354    // and the popover closes before a folder can be chosen.355    MenuController.shared?.beginModalPresentation()356    NSApp.activate(ignoringOtherApps: true)357358    chooseFolder(URL(fileURLWithPath: saveFolderPath)) { url in359      if let url { self.saveFolderPath = url.path }360      MenuController.shared?.endModalPresentation()361    }362  }363364  static func makeFolderPanel(at directory: URL) -> NSOpenPanel {365    let panel = NSOpenPanel()366    panel.canChooseFiles = false367    panel.canChooseDirectories = true368    panel.canCreateDirectories = true369    panel.allowsMultipleSelection = false370    panel.directoryURL = directory371    panel.prompt = "Choose"372    return panel373  }374375  static func presentFolderPicker(376    at directory: URL, completion: @escaping (URL?) -> Void377  ) {378    presentFolderPicker(panel: makeFolderPanel(at: directory), completion: completion)379  }380381  static func presentFolderPicker(382    panel: NSOpenPanel, completion: @escaping (URL?) -> Void383  ) {384    panel.begin { response in completion(response == .OK ? panel.url : nil) }385  }386387  func quit() {388    terminateApplication()389  }390}