AppState.swift

12.8 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 let removeTempFile: @MainActor (URL) throws -> Void94  private var cancellables = Set<AnyCancellable>()95  // Monotonic token incremented on every reset; only the search started with96  // the latest token may commit results. This keeps a slow older request from97  // overwriting the wallpapers/canLoadMore/errorMessage after a newer search98  // (or pagination) has begun.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    // Load settings126    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    // Debounce search137    $searchText138      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)139      .sink { [weak self] _ in self?.scheduleSearch() }140      .store(in: &cancellables)141142    // Observe filters143    $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    // Resolution and ratio pickers fire one value per menu interaction.156    // Debouncing them keeps a single user gesture producing a single157    // network request, matching the categories/purity/searchText flow.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      // Construct bitstrings232      let catStr =233        "\(categories.general ? "1" : "0")\(categories.anime ? "1" : "0")\(categories.people ? "1" : "0")"234      // NSFW is intentionally unsupported; the third purity bit is always 0.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        // A page that's been superseded by a newer reset would leave the next259        // page index pointing into the stale query; only advance when this260        // response is still the current generation.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      // AppState is @MainActor isolated; the prior MainActor.run hop was302      // redundant because the call site already runs on the main actor.303      try wallpaperManager.setDesktopImage(from: localURL, screen: screen)304305      // Add to history306      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      // AppState is @MainActor isolated; the prior MainActor.run hop was321      // redundant because the call site already runs on the main actor.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      // Always save to folder if download requested, or ensure save folder is used354      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    // Hold the transient popover open; without this the picker steals focus364    // and the popover closes before a folder can be chosen.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}