AppState.swift

16.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 sourceFolderPaths: [String] {78    didSet { UserDefaults.standard.set(sourceFolderPaths, forKey: "sourceFolderPaths") }79  }80  @Published var launchAtLogin: Bool {81    didSet { autostartManager.isEnabled = launchAtLogin }82  }8384  // Filters85  @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  // Monotonic token incremented on every reset; only the search started with100  // the latest token may commit results. This keeps a slow older request from101  // overwriting the wallpapers/canLoadMore/errorMessage after a newer search102  // (or pagination) has begun.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    // Load settings132    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    // Migrate the legacy single-folder setting into the new source list140    // exactly once, so existing users keep seeing their gallery. Idempotent141    // because we only seed when the new key is absent.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    // `@Published` replays its current value to every new subscriber, so157    // without `dropFirst` these three pipelines each launch a search as soon158    // as an AppState exists — three identical requests at startup, before the159    // popover has ever been opened. The initial load comes from the views'160    // `onAppear` instead.161    $searchText162      .dropFirst()163      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)164      .sink { [weak self] _ in self?.scheduleSearch() }165      .store(in: &cancellables)166167    // Observe filters168    $categories169      .dropFirst()170      .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    $purity176      .dropFirst()177      .map { _ in () }178      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)179      .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }180      .store(in: &cancellables)181182    // Resolution and ratio pickers fire one value per menu interaction.183    // Debouncing them keeps a single user gesture producing a single184    // network request, matching the categories/purity/searchText flow.185    $selectedResolution186      .dropFirst()187      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)188      .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }189      .store(in: &cancellables)190191    $selectedRatio192      .dropFirst()193      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)194      .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }195      .store(in: &cancellables)196197    $feedMode198      .dropFirst()199      .sink { [weak self] _ in200        Task { [weak self] in await self?.fetchWallpapers(reset: true) }201      }202      .store(in: &cancellables)203204    updateLocalWallpapersMonitoring()205206    $saveToFolderEnabled207      .dropFirst()208      .sink { [weak self] _ in209        self?.updateLocalWallpapersMonitoring()210      }211      .store(in: &cancellables)212213    $saveFolderPath214      .dropFirst()215      .sink { [weak self] _ in216        self?.updateLocalWallpapersMonitoring()217      }218      .store(in: &cancellables)219220    $sourceFolderPaths221      .dropFirst()222      .sink { [weak self] _ in223        self?.updateLocalWallpapersMonitoring()224      }225      .store(in: &cancellables)226  }227228  private func updateLocalWallpapersMonitoring() {229    if saveToFolderEnabled {230      LocalWallpapersManager.shared.startMonitoring(folderPath: saveFolderPath)231    } else {232      LocalWallpapersManager.shared.stopMonitoring()233    }234  }235236  func scheduleSearch() { Task { [weak self] in await self?.fetchWallpapers() } }237238  func fetchWallpapers(reset: Bool = true, refreshing: Bool = false) async {239    if reset {240      isFetchingMore = false241      searchGeneration &+= 1242    }243    let requestGeneration = searchGeneration244    let pageToRequest: Int245    if reset {246      if !refreshing {247        isLoading = true248        wallpapers = []249      }250      errorMessage = nil251      currentPage = 1252      canLoadMore = true253      pageToRequest = 1254    } else {255      guard !isFetchingMore && canLoadMore else { return }256      isFetchingMore = true257      pageToRequest = currentPage258    }259260    do {261      let resolutions =262        selectedResolution == "Any" || selectedResolution.isEmpty ? [] : [selectedResolution]263      let ratios = selectedRatio == "Any" || selectedRatio.isEmpty ? [] : [selectedRatio]264265      // Construct bitstrings266      let catStr =267        "\(categories.general ? "1" : "0")\(categories.anime ? "1" : "0")\(categories.people ? "1" : "0")"268      // NSFW is intentionally unsupported; the third purity bit is always 0.269      let purityStr = "\(purity.sfw ? "1" : "0")\(purity.sketchy ? "1" : "0")0"270271      let (newWallpapers, meta) = try await api.searchWallpapers(272        query: searchText,273        categories: catStr,274        purity: purityStr,275        sorting: feedMode.sorting,276        resolutions: resolutions,277        ratios: ratios,278        limit: 24,279        page: pageToRequest280      )281282      if requestGeneration != searchGeneration { return }283      if reset {284        self.wallpapers = newWallpapers285      } else {286        self.wallpapers.append(contentsOf: newWallpapers)287      }288289      if let meta = meta {290        let nextPage = meta.current_page + 1291        self.canLoadMore = meta.current_page < meta.last_page292        // A page that's been superseded by a newer reset would leave the next293        // page index pointing into the stale query; only advance when this294        // response is still the current generation.295        if requestGeneration == searchGeneration {296          self.currentPage = nextPage297        }298      } else if !newWallpapers.isEmpty {299        self.canLoadMore = true300        if requestGeneration == searchGeneration {301          self.currentPage += 1302        }303      } else {304        self.canLoadMore = false305      }306307    } catch {308      if reset && requestGeneration == searchGeneration {309        self.errorMessage = error.localizedDescription310      }311    }312313    if reset {314      if requestGeneration == searchGeneration {315        isLoading = false316      }317    } else if requestGeneration == searchGeneration {318      isFetchingMore = false319    }320  }321322  func loadMore() async {323    await fetchWallpapers(reset: false)324  }325326  func setWallpaper(_ wallpaper: Wallpaper, on screen: NSScreen? = nil) async {327    guard let remoteURL = URL(string: wallpaper.path) else {328      self.errorMessage = "Failed to set wallpaper: invalid image URL"329      return330    }331    do {332      // Cached rather than a throwaway temp download: NSWorkspace re-reads333      // this file from disk on Space switches/wake, and repeated clicks on334      // the same wallpaper should reuse it instead of re-downloading.335      let localURL = try await wallpaperManager.cachedLocalFile(for: remoteURL)336337      // AppState is @MainActor isolated; the prior MainActor.run hop was338      // redundant because the call site already runs on the main actor.339      try wallpaperManager.setDesktopImage(from: localURL, screen: screen)340341      // Add to history342      historyManager.addToHistory(wallpaper)343344      if saveToFolderEnabled {345        let destFolder = URL(fileURLWithPath: saveFolderPath)346        try wallpaperManager.saveToFolder(from: localURL, to: destFolder)347      }348349    } catch {350      self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"351    }352  }353354  func setLocalWallpaper(_ wallpaper: LocalWallpaper, on screen: NSScreen? = nil) async {355    do {356      // AppState is @MainActor isolated; the prior MainActor.run hop was357      // redundant because the call site already runs on the main actor.358      try wallpaperManager.setDesktopImage(from: wallpaper.url, screen: screen)359360      let historyWallpaper = Wallpaper(361        id: wallpaper.id,362        url: wallpaper.url.path,363        path: wallpaper.url.path,364        resolution: wallpaper.resolution,365        purity: "local",366        file_size: Int(wallpaper.fileSize),367        thumbs: Wallpaper.Thumbs(368          large: wallpaper.url.path,369          original: wallpaper.url.path,370          small: wallpaper.url.path371        )372      )373      historyManager.addToHistory(historyWallpaper)374375    } catch {376      self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"377    }378  }379380  func downloadWallpaper(_ wallpaper: Wallpaper) async {381    guard let remoteURL = URL(string: wallpaper.path) else {382      self.errorMessage = "Failed to download: invalid image URL"383      return384    }385    do {386      let localURL = try await wallpaperManager.download(from: remoteURL)387      defer { try? removeTempFile(localURL) }388389      // Always save to folder if download requested, or ensure save folder is used390      let destFolder = URL(fileURLWithPath: saveFolderPath)391      try wallpaperManager.saveToFolder(from: localURL, to: destFolder)392393    } catch {394      self.errorMessage = "Failed to download: \(error.localizedDescription)"395    }396  }397398  func selectSaveFolder() {399    // Hold the transient popover open; without this the picker steals focus400    // and the popover closes before a folder can be chosen.401    MenuController.shared?.beginModalPresentation()402    NSApp.activate(ignoringOtherApps: true)403404    chooseFolder(URL(fileURLWithPath: saveFolderPath)) { url in405      if let url { self.saveFolderPath = url.path }406      MenuController.shared?.endModalPresentation()407    }408  }409410  static func makeFolderPanel(at directory: URL) -> NSOpenPanel {411    let panel = NSOpenPanel()412    panel.canChooseFiles = false413    panel.canChooseDirectories = true414    panel.canCreateDirectories = true415    panel.allowsMultipleSelection = false416    panel.directoryURL = directory417    panel.prompt = "Choose"418    return panel419  }420421  static func presentFolderPicker(422    at directory: URL, completion: @escaping (URL?) -> Void423  ) {424    presentFolderPicker(panel: makeFolderPanel(at: directory), completion: completion)425  }426427  static func presentFolderPicker(428    panel: NSOpenPanel, completion: @escaping (URL?) -> Void429  ) {430    panel.begin { response in completion(response == .OK ? panel.url : nil) }431  }432433  /// Show the multi-select source folder picker and append the chosen434  /// directories to `sourceFolderPaths`. Cancelling the picker is a no-op.435  func addSourceFolders() {436    MenuController.shared?.beginModalPresentation()437    NSApp.activate(ignoringOtherApps: true)438439    chooseSourceFolders([]) { urls in440      let merged = (self.sourceFolderPaths + urls.map(\.path))441        .reduce(into: [String]()) { acc, path in442          if !acc.contains(path) { acc.append(path) }443        }444      self.sourceFolderPaths = merged445      MenuController.shared?.endModalPresentation()446    }447  }448449  /// Remove a previously added source folder. Idempotent: removing an450  /// unknown path is a no-op.451  func removeSourceFolder(_ path: String) {452    sourceFolderPaths.removeAll { $0 == path }453  }454455  static func makeSourceFoldersPanel() -> NSOpenPanel {456    let panel = NSOpenPanel()457    panel.canChooseFiles = false458    panel.canChooseDirectories = true459    panel.canCreateDirectories = true460    panel.allowsMultipleSelection = true461    panel.prompt = "Add"462    return panel463  }464465  static func presentSourceFoldersPicker(466    panel: NSOpenPanel, completion: @escaping ([URL]) -> Void467  ) {468    panel.begin { response in469      completion(response == .OK ? panel.urls : [])470    }471  }472473  static func presentSourceFoldersPicker(474    _ directory: URL, completion: @escaping ([URL]) -> Void475  ) {476    let panel = makeSourceFoldersPanel()477    panel.directoryURL = directory478    presentSourceFoldersPicker(panel: panel, completion: completion)479  }480481  static let chooseDefaultSourceFolders: @MainActor ([URL], @escaping ([URL]) -> Void) -> Void =482    { directories, completion in483      let start = directories.last ?? FileManager.default.homeDirectoryForCurrentUser484      presentSourceFoldersPicker(start, completion: completion)485    }486487  func quit() {488    terminateApplication()489  }490}