AppState.swift

17.5 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/// Wallhaven being down, as opposed to Haven failing. Surfaced in the Explore48/// tab where the wallpapers would be, because an empty grid otherwise reads as49/// Haven being broken.50struct WallhavenStatus: Equatable {51  let title: String52  let detail: String5354  /// Only `WallhavenError.unavailable` is an outage. A rejected request, a55  /// transport failure, or a decoding change is Haven's problem to report.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  // Navigation68  @Published var activeTab: AppTab = .explore6970  // Data Sources71  @Published var wallpapers: [Wallpaper] = []72  @Published var favoritesManager = FavoritesManager.shared73  @Published var historyManager = HistoryManager.shared7475  // Search & Filter76  @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  /// Set while Wallhaven itself is down; the Explore tab explains it in place83  /// of the grid instead of leaving an unexplained blank area.84  @Published var wallhavenStatus: WallhavenStatus?8586  // Pagination87  @Published var canLoadMore = true88  private var currentPage = 189  private var isFetchingMore = false9091  // Settings92  @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  // Filters106  @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  // Monotonic token incremented on every reset; only the search started with121  // the latest token may commit results. This keeps a slow older request from122  // overwriting the wallpapers/canLoadMore/errorMessage after a newer search123  // (or pagination) has begun.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    // Load settings153    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    // Migrate the legacy single-folder setting into the new source list161    // exactly once, so existing users keep seeing their gallery. Idempotent162    // because we only seed when the new key is absent.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    // `@Published` replays its current value to every new subscriber, so178    // without `dropFirst` these three pipelines each launch a search as soon179    // as an AppState exists — three identical requests at startup, before the180    // popover has ever been opened. The initial load comes from the views'181    // `onAppear` instead.182    $searchText183      .dropFirst()184      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)185      .sink { [weak self] _ in self?.scheduleSearch() }186      .store(in: &cancellables)187188    // Observe filters189    $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    // Resolution and ratio pickers fire one value per menu interaction.204    // Debouncing them keeps a single user gesture producing a single205    // network request, matching the categories/purity/searchText flow.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      // Construct bitstrings287      let catStr =288        "\(categories.general ? "1" : "0")\(categories.anime ? "1" : "0")\(categories.people ? "1" : "0")"289      // NSFW is intentionally unsupported; the third purity bit is always 0.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        // A page that's been superseded by a newer reset would leave the next315        // page index pointing into the stale query; only advance when this316        // response is still the current generation.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          // The Explore tab shows the outage in place of the empty grid, so334          // the banner would only repeat it. With results still on screen335          // there is no panel, so the banner stays the only notice.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      // Cached rather than a throwaway temp download: NSWorkspace re-reads363      // this file from disk on Space switches/wake, and repeated clicks on364      // the same wallpaper should reuse it instead of re-downloading.365      let localURL = try await wallpaperManager.cachedLocalFile(for: remoteURL)366367      // AppState is @MainActor isolated; the prior MainActor.run hop was368      // redundant because the call site already runs on the main actor.369      try wallpaperManager.setDesktopImage(from: localURL, screen: screen)370371      // Add to history372      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      // AppState is @MainActor isolated; the prior MainActor.run hop was387      // redundant because the call site already runs on the main actor.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      // Always save to folder if download requested, or ensure save folder is used420      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    // Hold the transient popover open; without this the picker steals focus430    // and the popover closes before a folder can be chosen.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  /// Show the multi-select source folder picker and append the chosen464  /// directories to `sourceFolderPaths`. Cancelling the picker is a no-op.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  /// Remove a previously added source folder. Idempotent: removing an480  /// unknown path is a no-op.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}