ed611667ffbb

cover folder picker completion without presenting UI (v0.6.0)

time
2026-07-16 19:22 UTC · 3 months ago
machine
koh2
agent
minimax-m3
session
complete source coverage gate
parent
da67964f1b5e
32 files Info.plist+2 −2Package.resolved+23 −0Package.swift+30 −21README.md+1 −1Sources/Haven/API/Models.swift+25 −25Sources/Haven/API/WallhavenAPI.swift+65 −56Sources/Haven/AppBootstrap.swift+11 −0Sources/Haven/AppDelegate.swift+13 −12Sources/Haven/Managers/AutostartManager.swift+74 −55Sources/Haven/Managers/FavoritesManager.swift+32 −31Sources/Haven/Managers/HistoryManager.swift+39 −38Sources/Haven/Managers/ImageCache.swift+31 −23Sources/Haven/Managers/LocalWallpapersManager.swift+168 −176Sources/Haven/Managers/ScreenManager.swift+9 −9Sources/Haven/Managers/WallpaperManager.swift+75 −51Sources/Haven/UI/AppState.swift+307 −272Sources/Haven/UI/MenuController.swift+76 −75Sources/Haven/UI/Views/Components/GlossyButton.swift+30 −30Sources/Haven/UI/Views/Components/WallpaperItem.swift+84 −79Sources/Haven/UI/Views/LocalView.swift+210 −194Sources/Haven/UI/Views/MainView.swift+263 −258Sources/Haven/UI/Views/WallpaperGrid.swift+25 −28Sources/Haven/Utils/Configuration.swift+2 −2Sources/Haven/main.swift+5 −2Tests/HavenTests/AppStateTests.swift+276 −0Tests/HavenTests/HavenTests.swift+0 −30Tests/HavenTests/ManagerTests.swift+430 −0Tests/HavenTests/ModelsTests.swift+71 −0Tests/HavenTests/PlatformCoverageTests.swift+142 −0Tests/HavenTests/TestFixtures.swift+34 −0Tests/HavenTests/ViewCoverageTests.swift+238 −0Tests/HavenTests/WallhavenAPITests.swift+164 −0
Info.plist+2 −2
@@ -9,9 +9,9 @@99     <key>CFBundleName</key>1010     <string>Haven</string>1111     <key>CFBundleShortVersionString</key>12-    <string>0.5</string>12+    <string>0.6.0</string>1313     <key>CFBundleVersion</key>14-    <string>5</string>14+    <string>6</string>1515     <key>LSMinimumSystemVersion</key>1616     <string>13.0</string>1717     <key>LSUIElement</key>

view file

Package.resolved+23 −0
@@ -0,0 +1,23 @@1+{2+  "originHash" : "31fe69d1f56a4ae0a461e228de40f376e75c8a00239450985a2ce47eb8dfe8fd",3+  "pins" : [4+    {5+      "identity" : "swift-syntax",6+      "kind" : "remoteSourceControl",7+      "location" : "https://github.com/swiftlang/swift-syntax.git",8+      "state" : {9+        "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2",10+        "version" : "601.0.1"11+      }12+    },13+    {14+      "identity" : "swift-testing",15+      "kind" : "remoteSourceControl",16+      "location" : "https://github.com/swiftlang/swift-testing.git",17+      "state" : {18+        "revision" : "d6b70f9ef9eb207729fb50e0ff32edc6f72ea474"19+      }20+    }21+  ],22+  "version" : 323+}

view file

Package.swift+30 −21
@@ -1,25 +1,34 @@1-// swift-tools-version: 5.91+// swift-tools-version: 6.022 import PackageDescription33 44 let package = Package(5-    name: "Haven",6-    platforms: [7-        .macOS(.v13) // Targeting macOS Ventura and later for refreshable ScrollView8-    ],9-    products: [10-        .executable(name: "Haven", targets: ["Haven"])11-    ],12-    dependencies: [],13-    targets: [14-        .executableTarget(15-            name: "Haven",16-            dependencies: [],17-            path: "Sources/Haven"18-        ),19-        .testTarget(20-            name: "HavenTests",21-            dependencies: ["Haven"],22-            path: "Tests/HavenTests"23-        )24-    ]5+  name: "Haven",6+  platforms: [7+    .macOS(.v13)  // Targeting macOS Ventura and later for refreshable ScrollView8+  ],9+  products: [10+    .executable(name: "Haven", targets: ["Haven"])11+  ],12+  dependencies: [13+    .package(14+      url: "https://github.com/swiftlang/swift-testing.git",15+      revision: "d6b70f9ef9eb207729fb50e0ff32edc6f72ea474"16+    )17+  ],18+  targets: [19+    .executableTarget(20+      name: "Haven",21+      dependencies: [],22+      path: "Sources/Haven"23+    ),24+    .testTarget(25+      name: "HavenTests",26+      dependencies: [27+        "Haven",28+        .product(name: "Testing", package: "swift-testing"),29+      ],30+      path: "Tests/HavenTests"31+    ),32+  ],33+  swiftLanguageModes: [.v5]2534 )

view file

README.md+1 −1
@@ -21,7 +21,7 @@2121 ## Requirements2222 2323 - macOS 13 (Ventura) or later24-- Swift 5.9+ toolchain (Xcode or the Swift command-line tools)24+- Swift 6.1+ toolchain (Xcode or the Swift command-line tools)2525 2626 No Wallhaven API key is required — Haven uses the public search API and browses2727 SFW and Sketchy content only.

view file

Sources/Haven/API/Models.swift+25 −25
@@ -1,34 +1,34 @@11 import Foundation22 33 struct WallhavenResponse: Codable {4-    let data: [Wallpaper]5-    let meta: Meta?4+  let data: [Wallpaper]5+  let meta: Meta?66 }77 88 struct Meta: Codable {9-    let current_page: Int10-    let last_page: Int11-    let per_page: Int12-    let total: Int13-    // query, seed, etc can be added if needed9+  let current_page: Int10+  let last_page: Int11+  let per_page: Int12+  let total: Int13+  // query, seed, etc can be added if needed1414 }1515 1616 struct Wallpaper: Codable, Identifiable, Equatable {17-    let id: String18-    let url: String // Page URL19-    let path: String // Direct Image URL20-    let resolution: String21-    let purity: String22-    let file_size: Int?23-    let thumbs: Thumbs24-    25-    struct Thumbs: Codable, Equatable {26-        let large: String27-        let original: String28-        let small: String29-    }30-    31-    static func == (lhs: Wallpaper, rhs: Wallpaper) -> Bool {32-        return lhs.id == rhs.id33-    }34-}\ No newline at end of file17+  let id: String18+  let url: String  // Page URL19+  let path: String  // Direct Image URL20+  let resolution: String21+  let purity: String22+  let file_size: Int?23+  let thumbs: Thumbs24+25+  struct Thumbs: Codable, Equatable {26+    let large: String27+    let original: String28+    let small: String29+  }30+31+  static func == (lhs: Wallpaper, rhs: Wallpaper) -> Bool {32+    return lhs.id == rhs.id33+  }34+}

view file

Sources/Haven/API/WallhavenAPI.swift+65 −56
@@ -1,78 +1,87 @@11 import Foundation22 33 enum WallhavenError: Error {4-    case invalidURL5-    case networkError(Error)6-    case decodingError(Error)7-    case noData8-    case apiError(String)4+  case invalidURL5+  case networkError(Error)6+  case decodingError(Error)7+  case noData8+  case apiError(String)99 }1010 1111 /// Sort order for Wallhaven search results. Maps to the `sorting` query1212 /// parameter on the public API. `random` returns a fresh batch per page.1313 enum WallhavenSorting: String {14-    case dateAdded = "date_added"15-    case toplist16-    case random14+  case dateAdded = "date_added"15+  case toplist16+  case random1717 }1818 19-class WallhavenAPI {20-    private let session: URLSession19+@MainActor20+protocol WallhavenSearching {21+  func searchWallpapers(22+    query: String, categories: String, purity: String, sorting: WallhavenSorting,23+    resolutions: [String], ratios: [String], limit: Int, page: Int24+  ) async throws -> ([Wallpaper], Meta?)25+}2126 22-    init(session: URLSession = .shared) {23-        self.session = session24-    }27+class WallhavenAPI: WallhavenSearching {28+  private let session: URLSession2529 26-    func searchWallpapers(27-        query: String,28-        categories: String = "111",29-        purity: String = "100",30-        sorting: WallhavenSorting = .dateAdded,31-        resolutions: [String] = [],32-        ratios: [String] = [],33-        limit: Int = 24,34-        page: Int = 135-    ) async throws -> ([Wallpaper], Meta?) {36-        var queryItems = [37-            URLQueryItem(name: "q", value: query),38-            URLQueryItem(name: "categories", value: categories),39-            URLQueryItem(name: "purity", value: purity),40-            URLQueryItem(name: "sorting", value: sorting.rawValue),41-            URLQueryItem(name: "limit", value: String(limit)),42-            URLQueryItem(name: "page", value: String(page))43-        ]30+  init(session: URLSession = .shared) {31+    self.session = session32+  }4433 45-        if !resolutions.isEmpty {46-            queryItems.append(URLQueryItem(name: "resolutions", value: resolutions.joined(separator: ",")))47-        }34+  func searchWallpapers(35+    query: String,36+    categories: String = "111",37+    purity: String = "100",38+    sorting: WallhavenSorting = .dateAdded,39+    resolutions: [String] = [],40+    ratios: [String] = [],41+    limit: Int = 24,42+    page: Int = 143+  ) async throws -> ([Wallpaper], Meta?) {44+    var queryItems = [45+      URLQueryItem(name: "q", value: query),46+      URLQueryItem(name: "categories", value: categories),47+      URLQueryItem(name: "purity", value: purity),48+      URLQueryItem(name: "sorting", value: sorting.rawValue),49+      URLQueryItem(name: "limit", value: String(limit)),50+      URLQueryItem(name: "page", value: String(page)),51+    ]4852 49-        if !ratios.isEmpty {50-            queryItems.append(URLQueryItem(name: "ratios", value: ratios.joined(separator: ",")))51-        }53+    if !resolutions.isEmpty {54+      queryItems.append(55+        URLQueryItem(name: "resolutions", value: resolutions.joined(separator: ",")))56+    }5257 53-        return try await performSearch(queryItems: queryItems)58+    if !ratios.isEmpty {59+      queryItems.append(URLQueryItem(name: "ratios", value: ratios.joined(separator: ",")))5460     }5561 56-    private func performSearch(queryItems: [URLQueryItem]) async throws -> ([Wallpaper], Meta?) {57-        var urlComponents = URLComponents(url: Configuration.baseURL.appendingPathComponent("search"), resolvingAgainstBaseURL: true)!58-        urlComponents.queryItems = queryItems62+    return try await performSearch(queryItems: queryItems)63+  }5964 60-        guard let url = urlComponents.url else {61-            throw WallhavenError.invalidURL62-        }65+  private func performSearch(queryItems: [URLQueryItem]) async throws -> ([Wallpaper], Meta?) {66+    var urlComponents = URLComponents(67+      url: Configuration.baseURL.appendingPathComponent("search"), resolvingAgainstBaseURL: true)!68+    urlComponents.queryItems = queryItems6369 64-        let (data, response) = try await session.data(from: url)70+    let url = urlComponents.url!6571 66-        guard let httpResponse = response as? HTTPURLResponse,67-              (200...299).contains(httpResponse.statusCode) else {68-            throw WallhavenError.apiError("Server returned an error")69-        }72+    let (data, response) = try await session.data(from: url)7073 71-        do {72-            let decodedResponse = try JSONDecoder().decode(WallhavenResponse.self, from: data)73-            return (decodedResponse.data, decodedResponse.meta)74-        } catch {75-            throw WallhavenError.decodingError(error)76-        }74+    guard let httpResponse = response as? HTTPURLResponse,75+      (200...299).contains(httpResponse.statusCode)76+    else {77+      throw WallhavenError.apiError("Server returned an error")7778     }78-}\ No newline at end of file79+80+    do {81+      let decodedResponse = try JSONDecoder().decode(WallhavenResponse.self, from: data)82+      return (decodedResponse.data, decodedResponse.meta)83+    } catch {84+      throw WallhavenError.decodingError(error)85+    }86+  }87+}

view file

Sources/Haven/AppBootstrap.swift+11 −0
@@ -0,0 +1,11 @@1+import AppKit2+3+enum HavenApplication {4+  static func start(5+    delegate: AppDelegate, shouldRun: Bool,6+    setDelegate: (AppDelegate) -> Void, run: () -> Void7+  ) {8+    setDelegate(delegate)9+    if shouldRun { run() }10+  }11+}

view file

Sources/Haven/AppDelegate.swift+13 −12
@@ -1,19 +1,20 @@11 import AppKit22 33 class AppDelegate: NSObject, NSApplicationDelegate {4-    var statusItem: NSStatusItem!5-    var menuController: MenuController!4+  var statusItem: NSStatusItem!5+  var menuController: MenuController!66 7-    func applicationDidFinishLaunching(_ notification: Notification) {8-        // Create the status item in the menu bar9-        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)10-        11-        if let button = statusItem.button {12-            // Use a system symbol for the icon13-            button.image = NSImage(systemSymbolName: "photo.on.rectangle.angled", accessibilityDescription: "Haven")14-        }7+  func applicationDidFinishLaunching(_ notification: Notification) {8+    // Create the status item in the menu bar9+    statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)1510 16-        // Initialize the menu controller17-        menuController = MenuController(statusItem: statusItem)11+    if let button = statusItem.button {12+      // Use a system symbol for the icon13+      button.image = NSImage(14+        systemSymbolName: "photo.on.rectangle.angled", accessibilityDescription: "Haven")1815     }16+17+    // Initialize the menu controller18+    menuController = MenuController(statusItem: statusItem)19+  }1920 }

view file

Sources/Haven/Managers/AutostartManager.swift+74 −55
@@ -1,63 +1,82 @@11 import Foundation22 import ServiceManagement33 4-class AutostartManager {5-    static let shared = AutostartManager()6-    7-    private let launchAgentURL: URL8-    private let bundleIdentifier = "com.june.Haven"9-    10-    init() {11-        let libraryDir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first!12-        let launchAgentsDir = libraryDir.appendingPathComponent("LaunchAgents")13-        14-        // Ensure directory exists15-        try? FileManager.default.createDirectory(at: launchAgentsDir, withIntermediateDirectories: true)16-        17-        self.launchAgentURL = launchAgentsDir.appendingPathComponent("\(bundleIdentifier).plist")4+@MainActor5+protocol AutostartManaging: AnyObject {6+  var isEnabled: Bool { get set }7+}8+9+class AutostartManager: AutostartManaging {10+  static let shared = AutostartManager()11+12+  private let launchAgentURL: URL13+  private let bundleIdentifier = "com.june.Haven"14+  private let executablePath: () -> String?15+  private let writePlist: (Data, URL) throws -> Void16+  private let removePlist: (URL) throws -> Void17+18+  init(19+    libraryDirectory: URL? = nil,20+    executablePath: @escaping () -> String? = { Bundle.main.executablePath },21+    writePlist: @escaping (Data, URL) throws -> Void = { data, url in try data.write(to: url) },22+    removePlist: @escaping (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) }23+  ) {24+    let libraryDir =25+      libraryDirectory26+      ?? FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first!27+    let launchAgentsDir = libraryDir.appendingPathComponent("LaunchAgents")28+29+    // Ensure directory exists30+    try? FileManager.default.createDirectory(at: launchAgentsDir, withIntermediateDirectories: true)31+32+    self.launchAgentURL = launchAgentsDir.appendingPathComponent("\(bundleIdentifier).plist")33+    self.executablePath = executablePath34+    self.writePlist = writePlist35+    self.removePlist = removePlist36+  }37+38+  var isEnabled: Bool {39+    get {40+      return FileManager.default.fileExists(atPath: launchAgentURL.path)1841     }19-    20-    var isEnabled: Bool {21-        get {22-            return FileManager.default.fileExists(atPath: launchAgentURL.path)23-        }24-        set {25-            if newValue {26-                enable()27-            } else {28-                disable()29-            }30-        }42+    set {43+      if newValue {44+        enable()45+      } else {46+        disable()47+      }3148     }32-    33-    private func enable() {34-        guard !isEnabled else { return }35-        36-        // Point to the actual executable inside the bundle37-        guard let appPath = Bundle.main.executablePath else { return }38-        39-        let plistContent: [String: Any] = [40-            "Label": bundleIdentifier,41-            "ProgramArguments": [appPath],42-            "RunAtLoad": true,43-            "ProcessType": "Interactive"44-        ]45-        46-        do {47-            let data = try PropertyListSerialization.data(fromPropertyList: plistContent, format: .xml, options: 0)48-            try data.write(to: launchAgentURL)49-        } catch {50-            print("Failed to enable autostart: \(error)")51-        }49+  }50+51+  private func enable() {52+    guard !isEnabled else { return }53+54+    // Point to the actual executable inside the bundle55+    guard let appPath = executablePath() else { return }56+57+    let plistContent: [String: Any] = [58+      "Label": bundleIdentifier,59+      "ProgramArguments": [appPath],60+      "RunAtLoad": true,61+      "ProcessType": "Interactive",62+    ]63+64+    do {65+      let data = try PropertyListSerialization.data(66+        fromPropertyList: plistContent, format: .xml, options: 0)67+      try writePlist(data, launchAgentURL)68+    } catch {69+      print("Failed to enable autostart: \(error)")5270     }53-    54-    private func disable() {55-        guard isEnabled else { return }56-        57-        do {58-            try FileManager.default.removeItem(at: launchAgentURL)59-        } catch {60-            print("Failed to disable autostart: \(error)")61-        }71+  }72+73+  private func disable() {74+    guard isEnabled else { return }75+76+    do {77+      try removePlist(launchAgentURL)78+    } catch {79+      print("Failed to disable autostart: \(error)")6280     }81+  }6382 }

view file

Sources/Haven/Managers/FavoritesManager.swift+32 −31
@@ -1,38 +1,39 @@11 import Foundation22 33 class FavoritesManager: ObservableObject {4-    static let shared = FavoritesManager()5-    6-    @Published var favorites: [Wallpaper] = []7-    private let key = "favoriteWallpapers"8-    9-    init() {10-        loadFavorites()4+  static let shared = FavoritesManager()5+6+  @Published var favorites: [Wallpaper] = []7+  private let key = "favoriteWallpapers"8+9+  init() {10+    loadFavorites()11+  }12+13+  func toggleFavorite(_ wallpaper: Wallpaper) {14+    if let index = favorites.firstIndex(where: { $0.id == wallpaper.id }) {15+      favorites.remove(at: index)16+    } else {17+      favorites.insert(wallpaper, at: 0)1118     }12-    13-    func toggleFavorite(_ wallpaper: Wallpaper) {14-        if let index = favorites.firstIndex(where: { $0.id == wallpaper.id }) {15-            favorites.remove(at: index)16-        } else {17-            favorites.insert(wallpaper, at: 0)18-        }19-        saveFavorites()19+    saveFavorites()20+  }21+22+  func isFavorite(_ wallpaper: Wallpaper) -> Bool {23+    return favorites.contains(where: { $0.id == wallpaper.id })24+  }25+26+  private func saveFavorites() {27+    if let data = try? JSONEncoder().encode(favorites) {28+      UserDefaults.standard.set(data, forKey: key)2029     }21-    22-    func isFavorite(_ wallpaper: Wallpaper) -> Bool {23-        return favorites.contains(where: { $0.id == wallpaper.id })30+  }31+32+  private func loadFavorites() {33+    if let data = UserDefaults.standard.data(forKey: key),34+      let decoded = try? JSONDecoder().decode([Wallpaper].self, from: data)35+    {36+      favorites = decoded2437     }25-    26-    private func saveFavorites() {27-        if let data = try? JSONEncoder().encode(favorites) {28-            UserDefaults.standard.set(data, forKey: key)29-        }30-    }31-    32-    private func loadFavorites() {33-        if let data = UserDefaults.standard.data(forKey: key),34-           let decoded = try? JSONDecoder().decode([Wallpaper].self, from: data) {35-            favorites = decoded36-        }37-    }38+  }3839 }

view file

Sources/Haven/Managers/HistoryManager.swift+39 −38
@@ -1,46 +1,47 @@11 import Foundation22 33 class HistoryManager: ObservableObject {4-    static let shared = HistoryManager()5-    6-    @Published var history: [Wallpaper] = []7-    private let key = "wallpaperHistory"8-    private let maxHistoryItems = 509-    10-    init() {11-        loadHistory()4+  static let shared = HistoryManager()5+6+  @Published var history: [Wallpaper] = []7+  private let key = "wallpaperHistory"8+  private let maxHistoryItems = 509+10+  init() {11+    loadHistory()12+  }13+14+  func addToHistory(_ wallpaper: Wallpaper) {15+    // Remove if existing to promote to top16+    if let index = history.firstIndex(where: { $0.id == wallpaper.id }) {17+      history.remove(at: index)1218     }13-    14-    func addToHistory(_ wallpaper: Wallpaper) {15-        // Remove if existing to promote to top16-        if let index = history.firstIndex(where: { $0.id == wallpaper.id }) {17-            history.remove(at: index)18-        }19-        20-        history.insert(wallpaper, at: 0)21-        22-        if history.count > maxHistoryItems {23-            history = Array(history.prefix(maxHistoryItems))24-        }25-        26-        saveHistory()19+20+    history.insert(wallpaper, at: 0)21+22+    if history.count > maxHistoryItems {23+      history = Array(history.prefix(maxHistoryItems))2724     }28-    29-    func clearHistory() {30-        history = []31-        saveHistory()25+26+    saveHistory()27+  }28+29+  func clearHistory() {30+    history = []31+    saveHistory()32+  }33+34+  private func saveHistory() {35+    if let data = try? JSONEncoder().encode(history) {36+      UserDefaults.standard.set(data, forKey: key)3237     }33-    34-    private func saveHistory() {35-        if let data = try? JSONEncoder().encode(history) {36-            UserDefaults.standard.set(data, forKey: key)37-        }38+  }39+40+  private func loadHistory() {41+    if let data = UserDefaults.standard.data(forKey: key),42+      let decoded = try? JSONDecoder().decode([Wallpaper].self, from: data)43+    {44+      history = decoded3845     }39-    40-    private func loadHistory() {41-        if let data = UserDefaults.standard.data(forKey: key),42-           let decoded = try? JSONDecoder().decode([Wallpaper].self, from: data) {43-            history = decoded44-        }45-    }46+  }4647 }

view file

Sources/Haven/Managers/ImageCache.swift+31 −23
@@ -1,30 +1,38 @@11 import AppKit22 33 class ImageCache {4-    static let shared = ImageCache()5-    private let cache = NSCache<NSString, NSImage>()6-    7-    private init() {}8-    9-    func image(for url: URL) -> NSImage? {10-        return cache.object(forKey: url.absoluteString as NSString)4+  static let shared = ImageCache()5+  private let cache = NSCache<NSString, NSImage>()6+  private let session: URLSession7+8+  init(session: URLSession = .shared) {9+    self.session = session10+  }11+12+  func image(for url: URL) -> NSImage? {13+    return cache.object(forKey: url.absoluteString as NSString)14+  }15+16+  func insertImage(_ image: NSImage, for url: URL) {17+    cache.setObject(image, forKey: url.absoluteString as NSString)18+  }19+20+  func fetchImage(url: URL) async throws -> NSImage {21+    if let cached = image(for: url) {22+      return cached1123     }12-    13-    func insertImage(_ image: NSImage, for url: URL) {14-        cache.setObject(image, forKey: url.absoluteString as NSString)24+25+    let (data, response) = try await session.data(from: url)26+    guard let response = response as? HTTPURLResponse,27+      (200...299).contains(response.statusCode)28+    else {29+      throw WallhavenError.apiError("Image download failed")1530     }16-    17-    func fetchImage(url: URL) async throws -> NSImage {18-        if let cached = image(for: url) {19-            return cached20-        }21-        22-        let (data, _) = try await URLSession.shared.data(from: url)23-        guard let image = NSImage(data: data) else {24-            throw WallhavenError.decodingError(NSError(domain: "ImageCache", code: -1, userInfo: nil))25-        }26-        27-        insertImage(image, for: url)28-        return image31+    guard let image = NSImage(data: data) else {32+      throw WallhavenError.decodingError(NSError(domain: "ImageCache", code: -1, userInfo: nil))2933     }34+35+    insertImage(image, for: url)36+    return image37+  }3038 }

view file

Sources/Haven/Managers/LocalWallpapersManager.swift+168 −176
@@ -1,202 +1,194 @@1-import Foundation21 import Combine32 import CoreGraphics3+import Foundation44 import ImageIO55 66 struct LocalWallpaper: Identifiable, Equatable {7-    let id: String8-    let url: URL9-    let filename: String10-    let resolution: String11-    let fileSize: Int6412-    let createdAt: Date13-    let modifiedAt: Date14-    15-    var thumbnailURL: URL? {16-        return url17-    }18-    19-    static func == (lhs: LocalWallpaper, rhs: LocalWallpaper) -> Bool {20-        return lhs.id == rhs.id21-    }7+  let id: String8+  let url: URL9+  let filename: String10+  let resolution: String11+  let fileSize: Int6412+  let createdAt: Date13+  let modifiedAt: Date14+15+  var thumbnailURL: URL? {16+    return url17+  }18+19+  static func == (lhs: LocalWallpaper, rhs: LocalWallpaper) -> Bool {20+    return lhs.id == rhs.id21+  }2222 }2323 2424 @MainActor2525 class LocalWallpapersManager: ObservableObject {26-    static let shared = LocalWallpapersManager()27-    28-    @Published var wallpapers: [LocalWallpaper] = []29-    @Published var isLoading: Bool = false30-    @Published var errorMessage: String?31-    32-    private var folderMonitor: FolderMonitor?33-    private let imageExtensions: Set<String> = ["jpg", "jpeg", "png", "webp", "bmp", "gif"]34-    35-    private init() {36-        // Initial scan if a folder is configured37-        if let folderPath = UserDefaults.standard.string(forKey: "saveFolderPath") {38-            startMonitoring(folderPath: folderPath)39-        }26+  static let shared = LocalWallpapersManager()27+28+  @Published var wallpapers: [LocalWallpaper] = []29+  @Published var isLoading: Bool = false30+  @Published var errorMessage: String?31+32+  private var folderMonitor: FolderMonitor?33+  private let imageExtensions: Set<String> = ["jpg", "jpeg", "png", "webp", "bmp", "gif"]34+35+  init() {36+    // Initial scan if a folder is configured37+    if let folderPath = UserDefaults.standard.string(forKey: "saveFolderPath") {38+      startMonitoring(folderPath: folderPath)4039     }41-    42-    func startMonitoring(folderPath: String) {43-        folderMonitor?.stop()44-        folderMonitor = FolderMonitor(folderPath: folderPath) { [weak self] in45-            Task { @MainActor in46-                self?.scanFolder(path: folderPath)47-            }48-        }49-        folderMonitor?.start()50-        scanFolder(path: folderPath)40+  }41+42+  func startMonitoring(folderPath: String) {43+    folderMonitor?.stop()44+    folderMonitor = FolderMonitor(folderPath: folderPath) { [weak self] in45+      Task { @MainActor in46+        self?.scanFolder(path: folderPath)47+      }5148     }52-    53-    func stopMonitoring() {54-        folderMonitor?.stop()55-        folderMonitor = nil56-    }57-    58-    func scanFolder(path: String) {59-        guard !isLoading else { return }60-        isLoading = true61-        errorMessage = nil62-        63-        Task.detached(priority: .userInitiated) { [weak self] in64-            guard let self = self else { return }65-            66-            let folderURL = URL(fileURLWithPath: path)67-            let fileManager = FileManager.default49+    folderMonitor?.start()50+    scanFolder(path: folderPath)51+  }6852 69-            // Create the save folder on first use so a fresh install shows an70-            // empty gallery instead of a scan error.71-            try? fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true)53+  func stopMonitoring() {54+    folderMonitor?.stop()55+    folderMonitor = nil56+  }7257 73-            do {74-                let contents = try fileManager.contentsOfDirectory(75-                    at: folderURL,76-                    includingPropertiesForKeys: [.contentTypeKey, .fileSizeKey, .creationDateKey, .contentModificationDateKey],77-                    options: [.skipsHiddenFiles, .skipsPackageDescendants]78-                )79-                80-                var wallpapers: [LocalWallpaper] = []81-                for url in contents {82-                    let ext = url.pathExtension.lowercased()83-                    guard self.imageExtensions.contains(ext) else { continue }84-                    85-                    do {86-                        let resourceValues = try url.resourceValues(forKeys: [87-                            .fileSizeKey,88-                            .creationDateKey,89-                            .contentModificationDateKey90-                        ])58+  func scanFolder(path: String) {59+    guard !isLoading else { return }60+    isLoading = true61+    errorMessage = nil9162 92-                        // Skip anything we can't actually decode — a broken or93-                        // unsupported file has no readable pixel dimensions, and94-                        // the user would rather it never appear in the gallery.95-                        guard let resolution = Self.imageResolution(url: url) else { continue }63+    Task.detached(priority: .userInitiated) { [weak self] in64+      guard let self = self else { return }9665 97-                        let filename = url.lastPathComponent98-                        let fileSize = Int64(resourceValues.fileSize ?? 0)99-                        let createdAt = resourceValues.creationDate ?? Date()100-                        let modifiedAt = resourceValues.contentModificationDate ?? Date()66+      let folderURL = URL(fileURLWithPath: path)67+      let fileManager = FileManager.default10168 102-                        wallpapers.append(LocalWallpaper(103-                            id: url.path,104-                            url: url,105-                            filename: filename,106-                            resolution: resolution,107-                            fileSize: fileSize,108-                            createdAt: createdAt,109-                            modifiedAt: modifiedAt110-                        ))111-                    } catch {112-                        continue113-                    }114-                }115-                116-                wallpapers.sort { $0.modifiedAt > $1.modifiedAt }117-                118-                let finalWallpapers = wallpapers119-                await MainActor.run {120-                    self.wallpapers = finalWallpapers121-                    self.isLoading = false122-                }123-            } catch {124-                await MainActor.run {125-                    self.errorMessage = "Failed to scan folder: \(error.localizedDescription)"126-                    self.isLoading = false127-                }128-            }69+      // Create the save folder on first use so a fresh install shows an70+      // empty gallery instead of a scan error.71+      try? fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true)72+73+      do {74+        let contents = try fileManager.contentsOfDirectory(75+          at: folderURL,76+          includingPropertiesForKeys: [77+            .contentTypeKey, .fileSizeKey, .creationDateKey, .contentModificationDateKey,78+          ],79+          options: [.skipsHiddenFiles, .skipsPackageDescendants]80+        )81+82+        var wallpapers: [LocalWallpaper] = []83+        for url in contents {84+          let ext = url.pathExtension.lowercased()85+          guard self.imageExtensions.contains(ext) else { continue }86+          if let wallpaper = Self.wallpaper(at: url) { wallpapers.append(wallpaper) }12987         }130-    }131-    132-    /// Reads pixel dimensions without decoding the full image. Returns nil when133-    /// the file can't be read as an image, which is the signal to skip it.134-    /// Nonisolated so scans run entirely off the main actor.135-    nonisolated private static func imageResolution(url: URL) -> String? {136-        guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),137-              let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any],138-              let width = properties[kCGImagePropertyPixelWidth as String] as? NSNumber,139-              let height = properties[kCGImagePropertyPixelHeight as String] as? NSNumber,140-              width.intValue > 0, height.intValue > 0 else {141-            return nil88+89+        wallpapers.sort { $0.modifiedAt > $1.modifiedAt }90+91+        let finalWallpapers = wallpapers92+        await MainActor.run {93+          self.wallpapers = finalWallpapers94+          self.isLoading = false14295         }143-        return "\(width.intValue)x\(height.intValue)"144-    }145-    146-    func deleteWallpaper(_ wallpaper: LocalWallpaper) {147-        do {148-            try FileManager.default.removeItem(at: wallpaper.url)149-            wallpapers.removeAll { $0.id == wallpaper.id }150-        } catch {151-            errorMessage = "Failed to delete: \(error.localizedDescription)"96+      } catch {97+        await MainActor.run {98+          self.errorMessage = "Failed to scan folder: \(error.localizedDescription)"99+          self.isLoading = false152100         }101+      }153102     }103+  }104+105+  /// Reads pixel dimensions without decoding the full image. Returns nil when106+  /// the file can't be read as an image, which is the signal to skip it.107+  /// Nonisolated so scans run entirely off the main actor.108+  nonisolated private static func imageResolution(url: URL) -> String? {109+    guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),110+      let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any],111+      let width = properties[kCGImagePropertyPixelWidth as String] as? NSNumber,112+      let height = properties[kCGImagePropertyPixelHeight as String] as? NSNumber,113+      width.intValue > 0, height.intValue > 0114+    else {115+      return nil116+    }117+    return "\(width.intValue)x\(height.intValue)"118+  }119+120+  nonisolated static func wallpaper(at url: URL) -> LocalWallpaper? {121+    guard122+      let resourceValues = try? url.resourceValues(forKeys: [123+        .fileSizeKey, .creationDateKey, .contentModificationDateKey,124+      ]),125+      let resolution = imageResolution(url: url)126+    else { return nil }127+    return LocalWallpaper(128+      id: url.path, url: url, filename: url.lastPathComponent, resolution: resolution,129+      fileSize: Int64(resourceValues.fileSize ?? 0),130+      createdAt: resourceValues.creationDate ?? Date(),131+      modifiedAt: resourceValues.contentModificationDate ?? Date())132+  }133+134+  func deleteWallpaper(_ wallpaper: LocalWallpaper) {135+    do {136+      try FileManager.default.removeItem(at: wallpaper.url)137+      wallpapers.removeAll { $0.id == wallpaper.id }138+    } catch {139+      errorMessage = "Failed to delete: \(error.localizedDescription)"140+    }141+  }154142 }155143 156144 // MARK: - Folder Monitor157145 158-private class FolderMonitor {159-    private let folderPath: String160-    private let callback: () -> Void161-    private var fileDescriptor: Int32 = -1162-    private var source: DispatchSourceFileSystemObject?163-    private let queue = DispatchQueue(label: "com.haven.folderMonitor", qos: .utility)164-    165-    init(folderPath: String, callback: @escaping () -> Void) {166-        self.folderPath = folderPath167-        self.callback = callback146+class FolderMonitor {147+  private let folderPath: String148+  private let callback: () -> Void149+  private var fileDescriptor: Int32 = -1150+  private var source: DispatchSourceFileSystemObject?151+  private let queue = DispatchQueue(label: "com.haven.folderMonitor", qos: .utility)152+153+  init(folderPath: String, fileDescriptor: Int32 = -1, callback: @escaping () -> Void) {154+    self.folderPath = folderPath155+    self.fileDescriptor = fileDescriptor156+    self.callback = callback157+  }158+159+  func start() {160+    fileDescriptor = open(folderPath, O_EVTONLY)161+    guard fileDescriptor >= 0 else { return }162+163+    source = DispatchSource.makeFileSystemObjectSource(164+      fileDescriptor: fileDescriptor,165+      eventMask: [.write, .rename, .delete, .link],166+      queue: queue167+    )168+169+    source?.setEventHandler { [weak self] in170+      DispatchQueue.main.async {171+        self?.callback()172+      }168173     }169-    170-    func start() {171-        fileDescriptor = open(folderPath, O_EVTONLY)172-        guard fileDescriptor >= 0 else { return }173-        174-        source = DispatchSource.makeFileSystemObjectSource(175-            fileDescriptor: fileDescriptor,176-            eventMask: [.write, .rename, .delete, .link],177-            queue: queue178-        )179-        180-        source?.setEventHandler { [weak self] in181-            DispatchQueue.main.async {182-                self?.callback()183-            }184-        }185-        186-        source?.setCancelHandler { [weak self] in187-            guard let self = self else { return }188-            close(self.fileDescriptor)189-        }190-        191-        source?.resume()192-    }193-    194-    func stop() {195-        source?.cancel()196-        source = nil197-    }198-    199-    deinit {200-        stop()201-    }174+175+    source?.setCancelHandler { [weak self] in self?.closeFileDescriptor() }176+177+    source?.resume()178+  }179+180+  func stop() {181+    source?.cancel()182+    source = nil183+  }184+185+  func closeFileDescriptor() {186+    guard fileDescriptor >= 0 else { return }187+    close(fileDescriptor)188+    fileDescriptor = -1189+  }190+191+  deinit {192+    stop()193+  }202194 }

view file

Sources/Haven/Managers/ScreenManager.swift+9 −9
@@ -1,13 +1,13 @@11 import AppKit22 33 class ScreenManager {4-    static let shared = ScreenManager()5-    6-    var screens: [NSScreen] {7-        return NSScreen.screens8-    }9-    10-    func screenName(for screen: NSScreen) -> String {11-        return screen.localizedName12-    }4+  static let shared = ScreenManager()5+6+  var screens: [NSScreen] {7+    return NSScreen.screens8+  }9+10+  func screenName(for screen: NSScreen) -> String {11+    return screen.localizedName12+  }1313 }

view file

Sources/Haven/Managers/WallpaperManager.swift+75 −51
@@ -1,58 +1,82 @@11 import AppKit22 3-import AppKit3+@MainActor4+protocol WallpaperManaging {5+  func download(from url: URL) async throws -> URL6+  func setDesktopImage(from fileURL: URL, screen: NSScreen?) throws7+  func saveToFolder(from sourceURL: URL, to folderURL: URL) throws8+}49 5-class WallpaperManager {6-    static let shared = WallpaperManager()7-    8-    private init() {}9-    10-    /// Downloads the file to a temporary location and returns the local URL.11-    func download(from url: URL) async throws -> URL {12-        let (data, _) = try await URLSession.shared.data(from: url)13-        14-        let filename = url.lastPathComponent15-        let tempDir = FileManager.default.temporaryDirectory16-        let fileURL = tempDir.appendingPathComponent(filename)17-        18-        try data.write(to: fileURL)19-        return fileURL10+class WallpaperManager: WallpaperManaging {11+  static let shared = WallpaperManager()12+13+  private let session: URLSession14+  private let desktopSetter: @MainActor (URL, NSScreen) throws -> Void15+16+  init(17+    session: URLSession = .shared,18+    desktopSetter: @escaping @MainActor (URL, NSScreen) throws -> Void =19+      WallpaperManager.applyDesktopImage20+  ) {21+    self.session = session22+    self.desktopSetter = desktopSetter23+  }24+25+  private static func applyDesktopImage(_ url: URL, _ screen: NSScreen) throws {26+    try NSWorkspace.shared.setDesktopImageURL(url, for: screen, options: [:])27+  }28+29+  /// Downloads the file to a temporary location and returns the local URL.30+  func download(from url: URL) async throws -> URL {31+    let (data, response) = try await session.data(from: url)32+    guard let response = response as? HTTPURLResponse,33+      (200...299).contains(response.statusCode)34+    else {35+      throw WallhavenError.apiError("Wallpaper download failed")2036     }21-    22-    /// Sets the desktop image from a local file URL.23-    @MainActor24-    func setDesktopImage(from fileURL: URL, screen: NSScreen? = nil) throws {25-        if let screen = screen {26-            try NSWorkspace.shared.setDesktopImageURL(fileURL, for: screen, options: [:])27-        } else {28-            // Set for all screens or just main? Let's default to main for safety, 29-            // but the UI will likely drive this.30-            // If no screen specified, maybe set for the 'active' one.31-            let targetScreen = NSScreen.main ?? NSScreen.screens.first!32-            try NSWorkspace.shared.setDesktopImageURL(fileURL, for: targetScreen, options: [:])33-        }37+38+    let filename = url.lastPathComponent39+    let tempDir = FileManager.default.temporaryDirectory40+    let fileURL = tempDir.appendingPathComponent(filename)41+42+    try data.write(to: fileURL)43+    return fileURL44+  }45+46+  /// Sets the desktop image from a local file URL.47+  @MainActor48+  func setDesktopImage(from fileURL: URL, screen: NSScreen? = nil) throws {49+    if let screen = screen {50+      try desktopSetter(fileURL, screen)51+    } else {52+      // Set for all screens or just main? Let's default to main for safety,53+      // but the UI will likely drive this.54+      // If no screen specified, maybe set for the 'active' one.55+      let targetScreen = NSScreen.main ?? NSScreen.screens.first!56+      try desktopSetter(fileURL, targetScreen)3457     }35-    36-    /// Copies the file to the specified destination directory.37-    func saveToFolder(from sourceURL: URL, to folderURL: URL) throws {38-        // Ensure folder exists39-        try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true)40-        41-        let destinationURL = folderURL.appendingPathComponent(sourceURL.lastPathComponent)42-        43-        // If file exists, maybe don't overwrite or handle gracefully? 44-        // For now, we'll remove it if it exists to ensure we have the fresh one, or just skip.45-        if FileManager.default.fileExists(atPath: destinationURL.path) {46-            try FileManager.default.removeItem(at: destinationURL)47-        }48-        49-        try FileManager.default.copyItem(at: sourceURL, to: destinationURL)58+  }59+60+  /// Copies the file to the specified destination directory.61+  func saveToFolder(from sourceURL: URL, to folderURL: URL) throws {62+    // Ensure folder exists63+    try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true)64+65+    let destinationURL = folderURL.appendingPathComponent(sourceURL.lastPathComponent)66+67+    // If file exists, maybe don't overwrite or handle gracefully?68+    // For now, we'll remove it if it exists to ensure we have the fresh one, or just skip.69+    if FileManager.default.fileExists(atPath: destinationURL.path) {70+      try FileManager.default.removeItem(at: destinationURL)5071     }51-    52-    // Legacy support wrapper (optional, but cleaner to update call sites)53-    @MainActor54-    func setWallpaper(from url: URL, screen: NSScreen? = nil) async throws {55-        let localURL = try await download(from: url)56-        try setDesktopImage(from: localURL, screen: screen)57-    }72+73+    try FileManager.default.copyItem(at: sourceURL, to: destinationURL)74+  }75+76+  // Legacy support wrapper (optional, but cleaner to update call sites)77+  @MainActor78+  func setWallpaper(from url: URL, screen: NSScreen? = nil) async throws {79+    let localURL = try await download(from: url)80+    try setDesktopImage(from: localURL, screen: screen)81+  }5882 }

view file

Sources/Haven/UI/AppState.swift+307 −272
@@ -1,306 +1,341 @@1-import Foundation2-import Combine31 import AppKit2+import Combine3+import Foundation44 55 enum AppTab {6-    case explore7-    case local8-    case favorites9-    case history10-    case settings6+  case explore7+  case local8+  case favorites9+  case history10+  case settings1111 }1212 1313 /// Browse mode for the Explore tab. Each maps to a Wallhaven `sorting`1414 /// value; the free-text search box applies on top of the chosen mode.1515 enum FeedMode: String, CaseIterable, Identifiable {16-    case latest17-    case toplist18-    case random16+  case latest17+  case toplist18+  case random1919 20-    var id: String { rawValue }20+  var id: String { rawValue }2121 22-    var sorting: WallhavenSorting {23-        switch self {24-        case .latest: return .dateAdded25-        case .toplist: return .toplist26-        case .random: return .random27-        }22+  var sorting: WallhavenSorting {23+    switch self {24+    case .latest: return .dateAdded25+    case .toplist: return .toplist26+    case .random: return .random2827     }28+  }2929 30-    var displayName: String {31-        switch self {32-        case .latest: return "Latest"33-        case .toplist: return "Toplist"34-        case .random: return "Random"35-        }30+  var displayName: String {31+    switch self {32+    case .latest: return "Latest"33+    case .toplist: return "Toplist"34+    case .random: return "Random"3635     }36+  }3737 38-    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-        }38+  var systemImage: String {39+    switch self {40+    case .latest: return "clock"41+    case .toplist: return "diamond"42+    case .random: return "arrow.triangle.2.circlepath"4443     }44+  }4545 }4646 4747 @MainActor4848 class AppState: ObservableObject {49-    // Navigation50-    @Published var activeTab: AppTab = .explore49+  // Navigation50+  @Published var activeTab: AppTab = .explore5151 52-    // Data Sources53-    @Published var wallpapers: [Wallpaper] = []54-    @Published var favoritesManager = FavoritesManager.shared55-    @Published var historyManager = HistoryManager.shared52+  // Data Sources53+  @Published var wallpapers: [Wallpaper] = []54+  @Published var favoritesManager = FavoritesManager.shared55+  @Published var historyManager = HistoryManager.shared5656 57-    // 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?64-    65-    // Pagination66-    @Published var canLoadMore = true67-    private var currentPage = 168-    private var isFetchingMore = false69-    70-    // Settings71-    @Published var saveToFolderEnabled: Bool {72-        didSet { UserDefaults.standard.set(saveToFolderEnabled, forKey: "saveToFolderEnabled") }57+  // 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?64+65+  // Pagination66+  @Published var canLoadMore = true67+  private var currentPage = 168+  private var isFetchingMore = false69+70+  // 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+  }80+81+  // Filters82+  @Published var categories: (general: Bool, anime: Bool, people: Bool) = (true, true, true)83+  @Published var purity: (sfw: Bool, sketchy: Bool) = (true, false)84+85+  let availableResolutions = ["Any", "1920x1080", "2560x1440", "3840x2160", "5120x2880"]86+  let availableRatios = ["Any", "16x9", "16x10", "21x9", "32x9", "4x3"]87+88+  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+95+  init(96+    api: WallhavenSearching? = nil,97+    wallpaperManager: WallpaperManaging? = nil,98+    autostartManager: AutostartManaging? = nil,99+    chooseFolder: (@MainActor (URL, @escaping (URL?) -> Void) -> Void)? = nil,100+    terminateApplication: (@MainActor () -> Void)? = nil,101+    observeChanges: Bool = true102+  ) {103+    let api = api ?? WallhavenAPI()104+    let wallpaperManager = wallpaperManager ?? WallpaperManager.shared105+    let autostartManager = autostartManager ?? AutostartManager.shared106+    self.api = api107+    self.wallpaperManager = wallpaperManager108+    self.autostartManager = autostartManager109+    self.chooseFolder = chooseFolder ?? AppState.presentFolderPicker110+    self.terminateApplication = terminateApplication ?? { NSApp.terminate(nil) }111+    self.launchAtLogin = autostartManager.isEnabled112+    // Load settings113+    self.saveToFolderEnabled = UserDefaults.standard.bool(forKey: "saveToFolderEnabled")114+    if let path = UserDefaults.standard.string(forKey: "saveFolderPath") {115+      self.saveFolderPath = path116+    } else {117+      let pictures = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first!118+      self.saveFolderPath = pictures.appendingPathComponent("Haven").path73119     }74-    @Published var saveFolderPath: String {75-        didSet { UserDefaults.standard.set(saveFolderPath, forKey: "saveFolderPath") }120+121+    guard observeChanges else { return }122+123+    // Debounce search124+    $searchText125+      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)126+      .sink { [weak self] _ in self?.scheduleSearch() }127+      .store(in: &cancellables)128+129+    // Observe filters130+    $categories131+      .map { _ in () }132+      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)133+      .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }134+      .store(in: &cancellables)135+136+    $purity137+      .map { _ in () }138+      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)139+      .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }140+      .store(in: &cancellables)141+142+    $feedMode143+      .dropFirst()144+      .sink { [weak self] _ in145+        Task { [weak self] in await self?.fetchWallpapers(reset: true) }146+      }147+      .store(in: &cancellables)148+149+    updateLocalWallpapersMonitoring()150+151+    $saveToFolderEnabled152+      .dropFirst()153+      .sink { [weak self] _ in154+        self?.updateLocalWallpapersMonitoring()155+      }156+      .store(in: &cancellables)157+158+    $saveFolderPath159+      .dropFirst()160+      .sink { [weak self] _ in161+        self?.updateLocalWallpapersMonitoring()162+      }163+      .store(in: &cancellables)164+  }165+166+  private func updateLocalWallpapersMonitoring() {167+    if saveToFolderEnabled {168+      LocalWallpapersManager.shared.startMonitoring(folderPath: saveFolderPath)169+    } else {170+      LocalWallpapersManager.shared.stopMonitoring()76171     }77-    @Published var launchAtLogin: Bool = AutostartManager.shared.isEnabled {78-        didSet { AutostartManager.shared.isEnabled = launchAtLogin }172+  }173+174+  func scheduleSearch() { Task { [weak self] in await self?.fetchWallpapers() } }175+176+  func fetchWallpapers(reset: Bool = true, refreshing: Bool = false) async {177+    if reset {178+      if !refreshing {179+        isLoading = true180+        wallpapers = []181+      }182+      errorMessage = nil183+      currentPage = 1184+      canLoadMore = true185+    } else {186+      guard !isFetchingMore && canLoadMore else { return }187+      isFetchingMore = true79188     }80-    81-    // Filters82-    @Published var categories: (general: Bool, anime: Bool, people: Bool) = (true, true, true)83-    @Published var purity: (sfw: Bool, sketchy: Bool) = (true, false)84-    85-    let availableResolutions = ["Any", "1920x1080", "2560x1440", "3840x2160", "5120x2880"]86-    let availableRatios = ["Any", "16x9", "16x10", "21x9", "32x9", "4x3"]87-    88-    private let api = WallhavenAPI()89-    private var cancellables = Set<AnyCancellable>()90-    91-    init() {92-        // Load settings93-        self.saveToFolderEnabled = UserDefaults.standard.bool(forKey: "saveToFolderEnabled")94-        if let path = UserDefaults.standard.string(forKey: "saveFolderPath") {95-            self.saveFolderPath = path96-        } else {97-            let pictures = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first!98-            self.saveFolderPath = pictures.appendingPathComponent("Haven").path99-        }100-        101-        // Debounce search102-        $searchText103-            .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)104-            .sink { [weak self] _ in105-                Task { [weak self] in106-                    await self?.fetchWallpapers()107-                }108-            }109-            .store(in: &cancellables)110-            111-        // Observe filters112-        $categories113-            .map { _ in () }114-            .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)115-            .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }116-            .store(in: &cancellables)117-            118-        $purity119-            .map { _ in () }120-            .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)121-            .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }122-            .store(in: &cancellables)123189 124-        $feedMode125-            .dropFirst()126-            .sink { [weak self] _ in127-                Task { [weak self] in await self?.fetchWallpapers(reset: true) }128-            }129-            .store(in: &cancellables)190+    do {191+      let resolutions =192+        selectedResolution == "Any" || selectedResolution.isEmpty ? [] : [selectedResolution]193+      let ratios = selectedRatio == "Any" || selectedRatio.isEmpty ? [] : [selectedRatio]130194 131-        updateLocalWallpapersMonitoring()132-        133-        $saveToFolderEnabled134-            .dropFirst()135-            .sink { [weak self] _ in136-                self?.updateLocalWallpapersMonitoring()137-            }138-            .store(in: &cancellables)139-            140-        $saveFolderPath141-            .dropFirst()142-            .sink { [weak self] _ in143-                self?.updateLocalWallpapersMonitoring()144-            }145-            .store(in: &cancellables)195+      // Construct bitstrings196+      let catStr =197+        "\(categories.general ? "1" : "0")\(categories.anime ? "1" : "0")\(categories.people ? "1" : "0")"198+      // NSFW is intentionally unsupported; the third purity bit is always 0.199+      let purityStr = "\(purity.sfw ? "1" : "0")\(purity.sketchy ? "1" : "0")0"200+201+      let (newWallpapers, meta) = try await api.searchWallpapers(202+        query: searchText,203+        categories: catStr,204+        purity: purityStr,205+        sorting: feedMode.sorting,206+        resolutions: resolutions,207+        ratios: ratios,208+        limit: 24,209+        page: currentPage210+      )211+212+      if reset {213+        self.wallpapers = newWallpapers214+      } else {215+        self.wallpapers.append(contentsOf: newWallpapers)216+      }217+218+      if let meta = meta {219+        self.canLoadMore = meta.current_page < meta.last_page220+        self.currentPage = meta.current_page + 1221+      } else {222+        self.canLoadMore = !newWallpapers.isEmpty223+        if !newWallpapers.isEmpty { self.currentPage += 1 }224+      }225+226+    } catch {227+      if reset {228+        self.errorMessage = error.localizedDescription229+      }146230     }147-    148-    private func updateLocalWallpapersMonitoring() {149-        if saveToFolderEnabled {150-            LocalWallpapersManager.shared.startMonitoring(folderPath: saveFolderPath)151-        } else {152-            LocalWallpapersManager.shared.stopMonitoring()153-        }231+232+    if reset {233+      isLoading = false234+    } else {235+      isFetchingMore = false154236     }155-    156-    func fetchWallpapers(reset: Bool = true, refreshing: Bool = false) async {157-        if reset {158-            if !refreshing {159-                isLoading = true160-                wallpapers = []161-            }162-            errorMessage = nil163-            currentPage = 1164-            canLoadMore = true165-        } else {166-            guard !isFetchingMore && canLoadMore else { return }167-            isFetchingMore = true168-        }169-        170-        do {171-            let resolutions = selectedResolution == "Any" || selectedResolution.isEmpty ? [] : [selectedResolution]172-            let ratios = selectedRatio == "Any" || selectedRatio.isEmpty ? [] : [selectedRatio]173-            174-            // Construct bitstrings175-            let catStr = "\(categories.general ? "1" : "0")\(categories.anime ? "1" : "0")\(categories.people ? "1" : "0")"176-            // NSFW is intentionally unsupported; the third purity bit is always 0.177-            let purityStr = "\(purity.sfw ? "1" : "0")\(purity.sketchy ? "1" : "0")0"178-            179-            let (newWallpapers, meta) = try await api.searchWallpapers(180-                query: searchText,181-                categories: catStr,182-                purity: purityStr,183-                sorting: feedMode.sorting,184-                resolutions: resolutions,185-                ratios: ratios,186-                limit: 24,187-                page: currentPage188-            )189-            190-            if reset {191-                self.wallpapers = newWallpapers192-            } else {193-                self.wallpapers.append(contentsOf: newWallpapers)194-            }195-            196-            if let meta = meta {197-                self.canLoadMore = meta.current_page < meta.last_page198-                self.currentPage = meta.current_page + 1199-            } else {200-                self.canLoadMore = !newWallpapers.isEmpty201-                if !newWallpapers.isEmpty { self.currentPage += 1 }202-            }203-            204-        } catch {205-            if reset {206-                self.errorMessage = error.localizedDescription207-            }208-        }209-        210-        if reset {211-            isLoading = false212-        } else {213-            isFetchingMore = false214-        }237+  }238+239+  func loadMore() async {240+    await fetchWallpapers(reset: false)241+  }242+243+  func setWallpaper(_ wallpaper: Wallpaper, on screen: NSScreen? = nil) async {244+    do {245+      let localURL = try await wallpaperManager.download(from: URL(string: wallpaper.path)!)246+247+      try await MainActor.run {248+        try wallpaperManager.setDesktopImage(from: localURL, screen: screen)249+      }250+251+      // Add to history252+      historyManager.addToHistory(wallpaper)253+254+      if saveToFolderEnabled {255+        let destFolder = URL(fileURLWithPath: saveFolderPath)256+        try wallpaperManager.saveToFolder(from: localURL, to: destFolder)257+      }258+259+    } catch {260+      self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"215261     }216-    217-    func loadMore() async {218-        await fetchWallpapers(reset: false)262+  }263+264+  func setLocalWallpaper(_ wallpaper: LocalWallpaper, on screen: NSScreen? = nil) async {265+    do {266+      try await MainActor.run {267+        try wallpaperManager.setDesktopImage(from: wallpaper.url, screen: screen)268+      }269+270+      let historyWallpaper = Wallpaper(271+        id: wallpaper.id,272+        url: wallpaper.url.path,273+        path: wallpaper.url.path,274+        resolution: wallpaper.resolution,275+        purity: "local",276+        file_size: Int(wallpaper.fileSize),277+        thumbs: Wallpaper.Thumbs(278+          large: wallpaper.url.path,279+          original: wallpaper.url.path,280+          small: wallpaper.url.path281+        )282+      )283+      historyManager.addToHistory(historyWallpaper)284+285+    } catch {286+      self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"219287     }220-    221-    func setWallpaper(_ wallpaper: Wallpaper, on screen: NSScreen? = nil) async {222-        do {223-            let localURL = try await WallpaperManager.shared.download(from: URL(string: wallpaper.path)!)224-            225-            try await MainActor.run {226-                try WallpaperManager.shared.setDesktopImage(from: localURL, screen: screen)227-            }228-            229-            // Add to history230-            historyManager.addToHistory(wallpaper)231-            232-            if saveToFolderEnabled {233-                let destFolder = URL(fileURLWithPath: saveFolderPath)234-                try WallpaperManager.shared.saveToFolder(from: localURL, to: destFolder)235-            }236-            237-        } catch {238-            self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"239-        }288+  }289+290+  func downloadWallpaper(_ wallpaper: Wallpaper) async {291+    do {292+      let localURL = try await wallpaperManager.download(from: URL(string: wallpaper.path)!)293+294+      // Always save to folder if download requested, or ensure save folder is used295+      let destFolder = URL(fileURLWithPath: saveFolderPath)296+      try wallpaperManager.saveToFolder(from: localURL, to: destFolder)297+298+    } catch {299+      self.errorMessage = "Failed to download: \(error.localizedDescription)"240300     }241-    242-    func setLocalWallpaper(_ wallpaper: LocalWallpaper, on screen: NSScreen? = nil) async {243-        do {244-            try await MainActor.run {245-                try WallpaperManager.shared.setDesktopImage(from: wallpaper.url, screen: screen)246-            }247-            248-            let historyWallpaper = Wallpaper(249-                id: wallpaper.id,250-                url: wallpaper.url.path,251-                path: wallpaper.url.path,252-                resolution: wallpaper.resolution,253-                purity: "local",254-                file_size: Int(wallpaper.fileSize),255-                thumbs: Wallpaper.Thumbs(256-                    large: wallpaper.url.path,257-                    original: wallpaper.url.path,258-                    small: wallpaper.url.path259-                )260-            )261-            historyManager.addToHistory(historyWallpaper)262-            263-        } catch {264-            self.errorMessage = "Failed to set wallpaper: \(error.localizedDescription)"265-        }266-    }267-    268-    func downloadWallpaper(_ wallpaper: Wallpaper) async {269-         do {270-            let localURL = try await WallpaperManager.shared.download(from: URL(string: wallpaper.path)!)271-            272-            // Always save to folder if download requested, or ensure save folder is used273-            let destFolder = URL(fileURLWithPath: saveFolderPath)274-            try WallpaperManager.shared.saveToFolder(from: localURL, to: destFolder)275-            276-        } catch {277-            self.errorMessage = "Failed to download: \(error.localizedDescription)"278-        }279-    }280-    281-    func selectSaveFolder() {282-        // Hold the transient popover open; without this the picker steals focus283-        // and the popover closes before a folder can be chosen.284-        MenuController.shared?.beginModalPresentation()285-        NSApp.activate(ignoringOtherApps: true)301+  }286302 287-        let panel = NSOpenPanel()288-        panel.canChooseFiles = false289-        panel.canChooseDirectories = true290-        panel.canCreateDirectories = true291-        panel.allowsMultipleSelection = false292-        panel.directoryURL = URL(fileURLWithPath: saveFolderPath)293-        panel.prompt = "Choose"303+  func selectSaveFolder() {304+    // Hold the transient popover open; without this the picker steals focus305+    // and the popover closes before a folder can be chosen.306+    MenuController.shared?.beginModalPresentation()307+    NSApp.activate(ignoringOtherApps: true)294308 295-        panel.begin { response in296-            if response == .OK, let url = panel.url {297-                self.saveFolderPath = url.path298-            }299-            MenuController.shared?.endModalPresentation()300-        }309+    chooseFolder(URL(fileURLWithPath: saveFolderPath)) { url in310+      if let url { self.saveFolderPath = url.path }311+      MenuController.shared?.endModalPresentation()301312     }302-    303-    func quit() {304-        NSApp.terminate(nil)305-    }306-}\ No newline at end of file313+  }314+315+  static func makeFolderPanel(at directory: URL) -> NSOpenPanel {316+    let panel = NSOpenPanel()317+    panel.canChooseFiles = false318+    panel.canChooseDirectories = true319+    panel.canCreateDirectories = true320+    panel.allowsMultipleSelection = false321+    panel.directoryURL = directory322+    panel.prompt = "Choose"323+    return panel324+  }325+326+  static func presentFolderPicker(327+    at directory: URL, completion: @escaping (URL?) -> Void328+  ) {329+    presentFolderPicker(panel: makeFolderPanel(at: directory), completion: completion)330+  }331+332+  static func presentFolderPicker(333+    panel: NSOpenPanel, completion: @escaping (URL?) -> Void334+  ) {335+    panel.begin { response in completion(response == .OK ? panel.url : nil) }336+  }337+338+  func quit() {339+    terminateApplication()340+  }341+}

view file

Sources/Haven/UI/MenuController.swift+76 −75
@@ -3,93 +3,94 @@33 44 @MainActor55 class MenuController: NSObject {6-    static private(set) weak var shared: MenuController?6+  static private(set) weak var shared: MenuController?77 8-    let statusItem: NSStatusItem9-    let popover = NSPopover()10-    var eventMonitor: EventMonitor?8+  let statusItem: NSStatusItem9+  let popover: NSPopover10+  var eventMonitor: EventMonitor?1111 12-    init(statusItem: NSStatusItem) {13-        self.statusItem = statusItem14-        super.init()12+  init(statusItem: NSStatusItem, popover: NSPopover? = nil) {13+    self.statusItem = statusItem14+    self.popover = popover ?? NSPopover()15+    super.init()1516 16-        if let button = statusItem.button {17-            button.action = #selector(togglePopover(_:))18-            button.target = self19-        }17+    if let button = statusItem.button {18+      button.action = #selector(togglePopover(_:))19+      button.target = self20+    }2021 21-        // Explicitly set content size to match MainView's frame22-        popover.contentSize = NSSize(width: 450, height: 640)23-        popover.behavior = .transient24-        popover.contentViewController = NSHostingController(rootView: MainView())22+    // Explicitly set content size to match MainView's frame23+    self.popover.contentSize = NSSize(width: 450, height: 640)24+    self.popover.behavior = .transient25+    self.popover.contentViewController = NSHostingController(rootView: MainView())2526 26-        eventMonitor = EventMonitor(mask: [.leftMouseDown, .rightMouseDown]) { [weak self] event in27-            if let self = self, self.popover.isShown {28-                self.closePopover(sender: event)29-            }30-        }27+    eventMonitor = EventMonitor(mask: [.leftMouseDown, .rightMouseDown]) { [weak self] event in28+      if let self = self, self.popover.isShown {29+        self.closePopover(sender: event)30+      }31+    }3132 32-        MenuController.shared = self33+    MenuController.shared = self34+  }35+36+  /// Keep the popover open while a modal window (e.g. an NSOpenPanel) is up.37+  /// A transient popover otherwise dismisses itself the moment another window38+  /// takes focus, which closed the folder picker before it could be used.39+  func beginModalPresentation() {40+    eventMonitor?.stop()41+    popover.behavior = .applicationDefined42+  }43+44+  func endModalPresentation() {45+    popover.behavior = .transient46+    if popover.isShown {47+      eventMonitor?.start()3348     }49+  }3450 35-    /// Keep the popover open while a modal window (e.g. an NSOpenPanel) is up.36-    /// A transient popover otherwise dismisses itself the moment another window37-    /// takes focus, which closed the folder picker before it could be used.38-    func beginModalPresentation() {39-        eventMonitor?.stop()40-        popover.behavior = .applicationDefined51+  @objc func togglePopover(_ sender: AnyObject?) {52+    if popover.isShown {53+      closePopover(sender: sender)54+    } else {55+      showPopover(sender: sender)4156     }57+  }4258 43-    func endModalPresentation() {44-        popover.behavior = .transient45-        if popover.isShown {46-            eventMonitor?.start()47-        }59+  func showPopover(sender: AnyObject?) {60+    if let button = statusItem.button {61+      popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)62+      eventMonitor?.start()4863     }49-    50-    @objc func togglePopover(_ sender: AnyObject?) {51-        if popover.isShown {52-            closePopover(sender: sender)53-        } else {54-            showPopover(sender: sender)55-        }56-    }57-    58-    func showPopover(sender: AnyObject?) {59-        if let button = statusItem.button {60-            popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)61-            eventMonitor?.start()62-        }63-    }64-    65-    func closePopover(sender: AnyObject?) {66-        popover.performClose(sender)67-        eventMonitor?.stop()68-    }64+  }65+66+  func closePopover(sender: AnyObject?) {67+    popover.performClose(sender)68+    eventMonitor?.stop()69+  }6970 }7071 7172 class EventMonitor {72-    private var monitor: Any?73-    private let mask: NSEvent.EventTypeMask74-    private let handler: (NSEvent?) -> Void75-    76-    init(mask: NSEvent.EventTypeMask, handler: @escaping (NSEvent?) -> Void) {77-        self.mask = mask78-        self.handler = handler73+  private var monitor: Any?74+  private let mask: NSEvent.EventTypeMask75+  let handler: (NSEvent?) -> Void76+77+  init(mask: NSEvent.EventTypeMask, handler: @escaping (NSEvent?) -> Void) {78+    self.mask = mask79+    self.handler = handler80+  }81+82+  deinit {83+    stop()84+  }85+86+  func start() {87+    monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: handler)88+  }89+90+  func stop() {91+    if let monitor = monitor {92+      NSEvent.removeMonitor(monitor)93+      self.monitor = nil7994     }80-    81-    deinit {82-        stop()83-    }84-    85-    func start() {86-        monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: handler)87-    }88-    89-    func stop() {90-        if let monitor = monitor {91-            NSEvent.removeMonitor(monitor)92-            self.monitor = nil93-        }94-    }95-}\ No newline at end of file95+  }96+}

view file

Sources/Haven/UI/Views/Components/GlossyButton.swift+30 −30
@@ -1,36 +1,36 @@11 import SwiftUI22 33 struct GlossyButton: View {4-    let title: String5-    let icon: String?6-    let action: () -> Void7-    var color: Color = .blue8-    9-    var body: some View {10-        Button(action: action) {11-            HStack(spacing: 6) {12-                if let icon = icon {13-                    Image(systemName: icon)14-                        .font(.system(size: 14, weight: .semibold))15-                }16-                Text(title)17-                    .font(.system(size: 13, weight: .medium))18-            }19-            .padding(.horizontal, 12)20-            .padding(.vertical, 8)21-            .background(22-                ZStack {23-                    RoundedRectangle(cornerRadius: 8)24-                        .fill(color.opacity(0.2))25-                    26-                    RoundedRectangle(cornerRadius: 8)27-                        .stroke(color.opacity(0.4), lineWidth: 1)28-                        .shadow(color: color.opacity(0.3), radius: 2, x: 0, y: 1)29-                }30-            )31-            .foregroundColor(.white)32-            .shadow(radius: 2)4+  let title: String5+  let icon: String?6+  let action: () -> Void7+  var color: Color = .blue8+9+  var body: some View {10+    Button(action: action) {11+      HStack(spacing: 6) {12+        if let icon = icon {13+          Image(systemName: icon)14+            .font(.system(size: 14, weight: .semibold))3315         }34-        .buttonStyle(.plain)16+        Text(title)17+          .font(.system(size: 13, weight: .medium))18+      }19+      .padding(.horizontal, 12)20+      .padding(.vertical, 8)21+      .background(22+        ZStack {23+          RoundedRectangle(cornerRadius: 8)24+            .fill(color.opacity(0.2))25+26+          RoundedRectangle(cornerRadius: 8)27+            .stroke(color.opacity(0.4), lineWidth: 1)28+            .shadow(color: color.opacity(0.3), radius: 2, x: 0, y: 1)29+        }30+      )31+      .foregroundColor(.white)32+      .shadow(radius: 2)3533     }34+    .buttonStyle(.plain)35+  }3636 }

view file

Sources/Haven/UI/Views/Components/WallpaperItem.swift+84 −79
@@ -1,87 +1,92 @@11 import SwiftUI22 33 struct WallpaperItem: View {4-    let wallpaper: Wallpaper5-    let action: () -> Void6-    @ObservedObject var favoritesManager = FavoritesManager.shared7-    @State private var retryID = UUID()8-    @State private var attempt = 09-    @State private var isHovering = false4+  let wallpaper: Wallpaper5+  let action: () -> Void6+  @ObservedObject var favoritesManager = FavoritesManager.shared7+  @State private var retryID = UUID()8+  @State private var attempt = 09+  @State private var isHovering = false1010 11-    // Transient thumbnail failures (flaky network) are common; retry a few12-    // times with growing backoff before giving up quietly.13-    private let maxAttempts = 411+  // Transient thumbnail failures (flaky network) are common; retry a few12+  // times with growing backoff before giving up quietly.13+  let maxAttempts: Int14+  let retryDelayNanoseconds: UInt641415 15-    var body: some View {16-        Button(action: action) {17-            AsyncImage(url: URL(string: wallpaper.thumbs.small)) { phase in18-                switch phase {19-                case .empty:20-                    Rectangle()21-                        .fill(Color.gray.opacity(0.2))22-                        .overlay(ProgressView())23-                case .success(let image):24-                    image25-                        .resizable()26-                        .aspectRatio(contentMode: .fill)27-                case .failure:28-                    Rectangle()29-                        .fill(Color.gray.opacity(0.2))30-                        .overlay(31-                            attempt < maxAttempts32-                                ? AnyView(ProgressView())33-                                : AnyView(Image(systemName: "photo")34-                                    .foregroundColor(.gray.opacity(0.6)))35-                        )36-                        .task {37-                            guard attempt < maxAttempts else { return }38-                            // 0.8s, 1.6s, 2.4s, 3.2s39-                            let delay = UInt64(attempt + 1) * 800_000_00040-                            try? await Task.sleep(nanoseconds: delay)41-                            guard !Task.isCancelled else { return }42-                            attempt += 143-                            retryID = UUID()44-                        }45-                @unknown default:46-                    EmptyView()47-                }16+  init(17+    wallpaper: Wallpaper, action: @escaping () -> Void, attempt: Int = 0,18+    maxAttempts: Int = 4, retryDelayNanoseconds: UInt64 = 800_000_00019+  ) {20+    self.wallpaper = wallpaper21+    self.action = action22+    self.maxAttempts = maxAttempts23+    self.retryDelayNanoseconds = retryDelayNanoseconds24+    _attempt = State(initialValue: attempt)25+  }26+27+  var body: some View {28+    Button(action: action) {29+      AsyncImage(url: URL(string: wallpaper.thumbs.small)) { thumbnail(for: $0) }30+        .id(retryID)31+        .frame(height: 100)32+        .clipped()33+        .cornerRadius(8)34+        .overlay(35+          RoundedRectangle(cornerRadius: 8)36+            .stroke(isHovering ? Color.accentColor : Color.clear, lineWidth: 2)37+        )38+        .shadow(radius: isHovering ? 4 : 0)39+        .overlay(40+          // Favorite Button41+          Group {42+            if isHovering || favoritesManager.isFavorite(wallpaper) {43+              Button(action: toggleFavorite) {44+                Image(systemName: favoritesManager.isFavorite(wallpaper) ? "heart.fill" : "heart")45+                  .foregroundColor(favoritesManager.isFavorite(wallpaper) ? .red : .white)46+                  .shadow(radius: 2)47+                  .font(.system(size: 16, weight: .bold))48+                  .padding(6)49+                  .background(Color.black.opacity(0.3))50+                  .clipShape(Circle())51+              }52+              .buttonStyle(.plain)53+              .padding(6)54+              .transition(.opacity)4855             }49-            .id(retryID)50-            .frame(height: 100)51-            .clipped()52-            .cornerRadius(8)53-            .overlay(54-                RoundedRectangle(cornerRadius: 8)55-                    .stroke(isHovering ? Color.accentColor : Color.clear, lineWidth: 2)56-            )57-            .shadow(radius: isHovering ? 4 : 0)58-            .overlay(59-                // Favorite Button60-                Group {61-                    if isHovering || favoritesManager.isFavorite(wallpaper) {62-                        Button {63-                            favoritesManager.toggleFavorite(wallpaper)64-                        } label: {65-                            Image(systemName: favoritesManager.isFavorite(wallpaper) ? "heart.fill" : "heart")66-                                .foregroundColor(favoritesManager.isFavorite(wallpaper) ? .red : .white)67-                                .shadow(radius: 2)68-                                .font(.system(size: 16, weight: .bold))69-                                .padding(6)70-                                .background(Color.black.opacity(0.3))71-                                .clipShape(Circle())72-                        }73-                        .buttonStyle(.plain)74-                        .padding(6)75-                        .transition(.opacity)76-                    }77-                },78-                alignment: .topTrailing79-            )80-            .scaleEffect(isHovering ? 1.02 : 1.0)81-            .animation(.spring(response: 0.3), value: isHovering)82-        }83-        .buttonStyle(.plain)84-        .onHover { isHovering = $0 }85-        .help("\(wallpaper.resolution)\n\(wallpaper.purity)")56+          },57+          alignment: .topTrailing58+        )59+        .scaleEffect(isHovering ? 1.02 : 1.0)60+        .animation(.spring(response: 0.3), value: isHovering)8661     }62+    .buttonStyle(.plain)63+    .onHover { isHovering = $0 }64+    .help("\(wallpaper.resolution)\n\(wallpaper.purity)")65+  }66+67+  func thumbnail(for phase: AsyncImagePhase) -> AnyView {68+    if case .success(let image) = phase {69+      return AnyView(image.resizable().aspectRatio(contentMode: .fill))70+    }71+    if case .failure = phase {72+      return AnyView(73+        Rectangle().fill(Color.gray.opacity(0.2)).overlay(74+          attempt < maxAttempts75+            ? AnyView(ProgressView())76+            : AnyView(Image(systemName: "photo").foregroundColor(.gray.opacity(0.6)))77+        ).task { await retryAfterFailure() })78+    }79+    return AnyView(Rectangle().fill(Color.gray.opacity(0.2)).overlay(ProgressView()))80+  }81+82+  func retryAfterFailure() async {83+    guard attempt < maxAttempts else { return }84+    let delay = UInt64(attempt + 1) * retryDelayNanoseconds85+    try? await Task.sleep(nanoseconds: delay)86+    guard !Task.isCancelled else { return }87+    attempt += 188+    retryID = UUID()89+  }90+91+  func toggleFavorite() { favoritesManager.toggleFavorite(wallpaper) }8792 }

view file

Sources/Haven/UI/Views/LocalView.swift+210 −194
@@ -1,226 +1,242 @@11 import SwiftUI22 33 struct LocalView: View {4-    @ObservedObject var appState: AppState5-    @ObservedObject private var localManager = LocalWallpapersManager.shared6-    @State private var selectedWallpaper: LocalWallpaper?7-    @State private var showingDeleteConfirmation = false8-    @State private var wallpaperToDelete: LocalWallpaper?9-    10-    var body: some View {11-        VStack(spacing: 0) {12-            // Header13-            HStack {14-                Text("Local Wallpapers")15-                    .font(.headline)16-                17-                Spacer()18-                19-                if !localManager.wallpapers.isEmpty {20-                    Text("\(localManager.wallpapers.count) items")21-                        .font(.caption)22-                        .foregroundColor(.secondary)23-                }24-                25-                Button {26-                    localManager.scanFolder(path: appState.saveFolderPath)27-                } label: {28-                    Image(systemName: "arrow.clockwise")29-                }30-                .buttonStyle(.plain)31-                .disabled(localManager.isLoading)32-            }33-            .padding()34-            35-            Divider()36-            37-            // Content38-            if localManager.isLoading && localManager.wallpapers.isEmpty {39-                LoadingState()40-            } else if !appState.saveToFolderEnabled {41-                EmptyState(42-                    icon: "folder.badge.questionmark",43-                    text: "Enable 'Save wallpapers locally' in Settings to view local wallpapers"44-                )45-            } else if localManager.wallpapers.isEmpty {46-                EmptyState(47-                    icon: "photo.on.rectangle.angled",48-                    text: "No wallpapers found in your save folder"49-                )50-            } else {51-                LocalWallpaperGrid(52-                    wallpapers: localManager.wallpapers,53-                    onSetWallpaper: { wallpaper in54-                        Task {55-                            await appState.setLocalWallpaper(wallpaper)56-                        }57-                    },58-                    onDeleteWallpaper: { wallpaper in59-                        wallpaperToDelete = wallpaper60-                        showingDeleteConfirmation = true61-                    }62-                )63-            }4+  @ObservedObject var appState: AppState5+  @ObservedObject var localManager: LocalWallpapersManager6+  @State private var selectedWallpaper: LocalWallpaper?7+  @State private var showingDeleteConfirmation = false8+  @State private var wallpaperToDelete: LocalWallpaper?9+10+  init(appState: AppState, localManager: LocalWallpapersManager? = nil) {11+    self.appState = appState12+    self.localManager = localManager ?? .shared13+  }14+15+  var body: some View {16+    VStack(spacing: 0) {17+      // Header18+      HStack {19+        Text("Local Wallpapers")20+          .font(.headline)21+22+        Spacer()23+24+        if !localManager.wallpapers.isEmpty {25+          Text("\(localManager.wallpapers.count) items")26+            .font(.caption)27+            .foregroundColor(.secondary)6428         }65-        .alert("Delete Wallpaper?", isPresented: $showingDeleteConfirmation, presenting: wallpaperToDelete) { wallpaper in66-            Button("Cancel", role: .cancel) {}67-            Button("Delete", role: .destructive) {68-                localManager.deleteWallpaper(wallpaper)69-            }70-        } message: { wallpaper in71-            Text("This will permanently delete '\(wallpaper.filename)' from your save folder.")29+30+        Button(action: scan) {31+          Image(systemName: "arrow.clockwise")7232         }73-        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)74-        .onAppear {75-            if appState.saveToFolderEnabled {76-                localManager.scanFolder(path: appState.saveFolderPath)77-            }78-        }33+        .buttonStyle(.plain)34+        .disabled(localManager.isLoading)35+      }36+      .padding()37+38+      Divider()39+40+      // Content41+      if localManager.isLoading && localManager.wallpapers.isEmpty {42+        LoadingState()43+      } else if !appState.saveToFolderEnabled {44+        EmptyState(45+          icon: "folder.badge.questionmark",46+          text: "Enable 'Save wallpapers locally' in Settings to view local wallpapers"47+        )48+      } else if localManager.wallpapers.isEmpty {49+        EmptyState(50+          icon: "photo.on.rectangle.angled",51+          text: "No wallpapers found in your save folder"52+        )53+      } else {54+        LocalWallpaperGrid(55+          wallpapers: localManager.wallpapers,56+          onSetWallpaper: { wallpaper in Task { await set(wallpaper) } },57+          onDeleteWallpaper: requestDelete58+        )59+      }7960     }61+    .alert(62+      "Delete Wallpaper?", isPresented: $showingDeleteConfirmation, presenting: wallpaperToDelete,63+      actions: deleteActions, message: deleteMessage)64+    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)65+    .onAppear {66+      if appState.saveToFolderEnabled {67+        localManager.scanFolder(path: appState.saveFolderPath)68+      }69+    }70+  }71+72+  func scan() { localManager.scanFolder(path: appState.saveFolderPath) }73+  func set(_ wallpaper: LocalWallpaper) async { await appState.setLocalWallpaper(wallpaper) }74+  func requestDelete(_ wallpaper: LocalWallpaper) {75+    wallpaperToDelete = wallpaper76+    showingDeleteConfirmation = true77+  }78+  func delete(_ wallpaper: LocalWallpaper) { localManager.deleteWallpaper(wallpaper) }79+  func cancelDelete() { wallpaperToDelete = nil }80+  func deleteMessage(_ wallpaper: LocalWallpaper) -> Text {81+    Text("This will permanently delete '\(wallpaper.filename)' from your save folder.")82+  }83+  @ViewBuilder func deleteActions(_ wallpaper: LocalWallpaper) -> some View {84+    Button("Cancel", role: .cancel, action: cancelDelete)85+    Button("Delete", role: .destructive) { delete(wallpaper) }86+  }8087 }8188 8289 // MARK: - Local Wallpaper Grid8390 8491 struct LocalWallpaperGrid: View {85-    let wallpapers: [LocalWallpaper]86-    let onSetWallpaper: (LocalWallpaper) -> Void87-    let onDeleteWallpaper: (LocalWallpaper) -> Void88-    89-    var body: some View {90-        ScrollView {91-            LazyVGrid(columns: [GridItem(.adaptive(minimum: 140), spacing: 12)], spacing: 12) {92-                ForEach(wallpapers) { wallpaper in93-                    LocalWallpaperItem(94-                        wallpaper: wallpaper,95-                        onSet: { onSetWallpaper(wallpaper) },96-                        onDelete: { onDeleteWallpaper(wallpaper) }97-                    )98-                }99-            }100-            .padding(12)92+  let wallpapers: [LocalWallpaper]93+  let onSetWallpaper: (LocalWallpaper) -> Void94+  let onDeleteWallpaper: (LocalWallpaper) -> Void95+96+  var body: some View {97+    ScrollView {98+      LazyVGrid(columns: [GridItem(.adaptive(minimum: 140), spacing: 12)], spacing: 12) {99+        ForEach(wallpapers) { wallpaper in100+          LocalWallpaperItem(101+            wallpaper: wallpaper,102+            onSet: { onSetWallpaper(wallpaper) },103+            onDelete: { onDeleteWallpaper(wallpaper) }104+          )101105         }106+      }107+      .padding(12)102108     }109+  }103110 }104111 105112 // MARK: - Local Wallpaper Item106113 107114 struct LocalWallpaperItem: View {108-    let wallpaper: LocalWallpaper109-    let onSet: () -> Void110-    let onDelete: () -> Void111-    112-    @State private var isHovering = false113-    @State private var image: NSImage?114-    @State private var isLoadingImage = true115-    116-    var body: some View {117-        Button(action: onSet) {118-            ZStack {119-                if let image = image {120-                    Image(nsImage: image)121-                        .resizable()122-                        .aspectRatio(contentMode: .fill)123-                        .clipped()124-                } else if isLoadingImage {125-                    Rectangle()126-                        .fill(Color.gray.opacity(0.2))127-                        .overlay(ProgressView())128-                } else {129-                    Rectangle()130-                        .fill(Color.gray.opacity(0.1))131-                        .overlay(132-                            VStack {133-                                Image(systemName: "photo")134-                                    .foregroundColor(.gray)135-                                Text(wallpaper.filename)136-                                    .font(.caption2)137-                                    .foregroundColor(.secondary)138-                                    .lineLimit(1)139-                            }140-                        )141-                }142-            }143-            .frame(maxWidth: .infinity, minHeight: 100, maxHeight: 100)144-            .cornerRadius(8)115+  let wallpaper: LocalWallpaper116+  let onSet: () -> Void117+  let onDelete: () -> Void118+119+  @State private var isHovering = false120+  @State private var image: NSImage?121+  @State private var isLoadingImage = true122+123+  init(124+    wallpaper: LocalWallpaper, onSet: @escaping () -> Void, onDelete: @escaping () -> Void,125+    initialImage: NSImage? = nil, isLoadingImage: Bool = true126+  ) {127+    self.wallpaper = wallpaper128+    self.onSet = onSet129+    self.onDelete = onDelete130+    _image = State(initialValue: initialImage)131+    _isLoadingImage = State(initialValue: isLoadingImage)132+  }133+134+  var body: some View {135+    Button(action: onSet) {136+      ZStack {137+        if let image = image {138+          Image(nsImage: image)139+            .resizable()140+            .aspectRatio(contentMode: .fill)141+            .clipped()142+        } else if isLoadingImage {143+          Rectangle()144+            .fill(Color.gray.opacity(0.2))145+            .overlay(ProgressView())146+        } else {147+          Rectangle()148+            .fill(Color.gray.opacity(0.1))145149             .overlay(146-                RoundedRectangle(cornerRadius: 8)147-                    .stroke(isHovering ? Color.accentColor : Color.clear, lineWidth: 2)150+              VStack {151+                Image(systemName: "photo")152+                  .foregroundColor(.gray)153+                Text(wallpaper.filename)154+                  .font(.caption2)155+                  .foregroundColor(.secondary)156+                  .lineLimit(1)157+              }148158             )149-            .shadow(radius: isHovering ? 4 : 0)150-            .overlay(151-                // Action Buttons152-                HStack {153-                    // Delete button154-                    Button {155-                        onDelete()156-                    } label: {157-                        Image(systemName: "trash")158-                            .foregroundColor(.white)159-                            .shadow(radius: 2)160-                            .font(.system(size: 14, weight: .bold))161-                            .padding(6)162-                            .background(Color.red.opacity(0.7))163-                            .clipShape(Circle())164-                    }165-                    .buttonStyle(.plain)166-                    .padding(6)167-                    168-                    Spacer()169-                    170-                    // Resolution badge171-                    Text(wallpaper.resolution)172-                        .font(.caption2)173-                        .padding(.horizontal, 6)174-                        .padding(.vertical, 2)175-                        .background(Color.black.opacity(0.6))176-                        .foregroundColor(.white)177-                        .cornerRadius(4)178-                        .padding(6)179-                }180-                .opacity(isHovering ? 1 : 0)181-                .animation(.easeInOut(duration: 0.15), value: isHovering),182-                alignment: .top183-            )184-            .scaleEffect(isHovering ? 1.02 : 1.0)185-            .animation(.spring(response: 0.3), value: isHovering)186159         }187-        .buttonStyle(.plain)188-        .onHover { isHovering = $0 }189-        .help("\(wallpaper.resolution) • \(formatFileSize(wallpaper.fileSize))")190-        .onAppear {191-            loadImage()160+      }161+      .frame(maxWidth: .infinity, minHeight: 100, maxHeight: 100)162+      .cornerRadius(8)163+      .overlay(164+        RoundedRectangle(cornerRadius: 8)165+          .stroke(isHovering ? Color.accentColor : Color.clear, lineWidth: 2)166+      )167+      .shadow(radius: isHovering ? 4 : 0)168+      .overlay(169+        // Action Buttons170+        HStack {171+          // Delete button172+          Button(action: onDelete) {173+            Image(systemName: "trash")174+              .foregroundColor(.white)175+              .shadow(radius: 2)176+              .font(.system(size: 14, weight: .bold))177+              .padding(6)178+              .background(Color.red.opacity(0.7))179+              .clipShape(Circle())180+          }181+          .buttonStyle(.plain)182+          .padding(6)183+184+          Spacer()185+186+          // Resolution badge187+          Text(wallpaper.resolution)188+            .font(.caption2)189+            .padding(.horizontal, 6)190+            .padding(.vertical, 2)191+            .background(Color.black.opacity(0.6))192+            .foregroundColor(.white)193+            .cornerRadius(4)194+            .padding(6)192195         }196+        .opacity(isHovering ? 1 : 0)197+        .animation(.easeInOut(duration: 0.15), value: isHovering),198+        alignment: .top199+      )200+      .scaleEffect(isHovering ? 1.02 : 1.0)201+      .animation(.spring(response: 0.3), value: isHovering)193202     }194-    195-    private func loadImage() {196-        isLoadingImage = true197-        198-        DispatchQueue.global(qos: .userInitiated).async {199-            let loadedImage = NSImage(contentsOf: self.wallpaper.url)200-            DispatchQueue.main.async {201-                self.image = loadedImage202-                self.isLoadingImage = false203-            }204-        }203+    .buttonStyle(.plain)204+    .onHover { isHovering = $0 }205+    .help("\(wallpaper.resolution) • \(formatFileSize(wallpaper.fileSize))")206+    .onAppear {207+      loadImage()205208     }206-    207-    private func formatFileSize(_ bytes: Int64) -> String {208-        let formatter = ByteCountFormatter()209-        formatter.allowedUnits = [.useKB, .useMB, .useGB]210-        formatter.countStyle = .file211-        return formatter.string(fromByteCount: bytes)209+  }210+211+  private func loadImage() {212+    isLoadingImage = true213+214+    DispatchQueue.global(qos: .userInitiated).async {215+      let loadedImage = NSImage(contentsOf: self.wallpaper.url)216+      DispatchQueue.main.async {217+        self.image = loadedImage218+        self.isLoadingImage = false219+      }212220     }221+  }222+223+  private func formatFileSize(_ bytes: Int64) -> String {224+    let formatter = ByteCountFormatter()225+    formatter.allowedUnits = [.useKB, .useMB, .useGB]226+    formatter.countStyle = .file227+    return formatter.string(fromByteCount: bytes)228+  }213229 }214230 215231 // MARK: - Loading State216232 217233 struct LoadingState: View {218-    var body: some View {219-        VStack(spacing: 16) {220-            ProgressView()221-            Text("Loading wallpapers...")222-                .foregroundColor(.secondary)223-        }224-        .frame(maxWidth: .infinity, maxHeight: .infinity)234+  var body: some View {235+    VStack(spacing: 16) {236+      ProgressView()237+      Text("Loading wallpapers...")238+        .foregroundColor(.secondary)225239     }240+    .frame(maxWidth: .infinity, maxHeight: .infinity)241+  }226242 }

view file

Sources/Haven/UI/Views/MainView.swift+263 −258
@@ -1,312 +1,317 @@11 import SwiftUI22 33 struct MainView: View {4-    @StateObject private var appState = AppState()5-    6-    var body: some View {4+  @StateObject private var appState: AppState5+6+  init(appState: AppState? = nil) {7+    let appState = appState ?? AppState()8+    _appState = StateObject(wrappedValue: appState)9+  }10+11+  var body: some View {12+    ZStack {13+      VStack(spacing: 0) {14+        // Header (Universal)15+        HStack {16+          Text("Haven")17+            .font(.custom("Avenir Next Medium", size: 18))18+19+          Spacer()20+21+          // Tab Bar22+          HStack(spacing: 16) {23+            TabButton(icon: "safari", tab: .explore, activeTab: $appState.activeTab)24+            TabButton(icon: "folder", tab: .local, activeTab: $appState.activeTab)25+            TabButton(icon: "heart", tab: .favorites, activeTab: $appState.activeTab)26+            TabButton(icon: "clock", tab: .history, activeTab: $appState.activeTab)27+            TabButton(icon: "gearshape", tab: .settings, activeTab: $appState.activeTab)28+          }29+        }30+        .padding()31+        .background(VisualEffectView(material: .headerView, blendingMode: .behindWindow))32+33+        Divider()34+35+        // Content736         ZStack {8-            VStack(spacing: 0) {9-                // Header (Universal)10-                HStack {11-                    Text("Haven")12-                        .font(.custom("Avenir Next Medium", size: 18))13-                    14-                    Spacer()15-                    16-                    // Tab Bar17-                    HStack(spacing: 16) {18-                        TabButton(icon: "safari", tab: .explore, activeTab: $appState.activeTab)19-                        TabButton(icon: "folder", tab: .local, activeTab: $appState.activeTab)20-                        TabButton(icon: "heart", tab: .favorites, activeTab: $appState.activeTab)21-                        TabButton(icon: "clock", tab: .history, activeTab: $appState.activeTab)22-                        TabButton(icon: "gearshape", tab: .settings, activeTab: $appState.activeTab)23-                    }24-                }25-                .padding()26-                .background(VisualEffectView(material: .headerView, blendingMode: .behindWindow))27-                28-                Divider()29-                30-                // Content31-                ZStack {32-                    switch appState.activeTab {33-                    case .explore:34-                        ExploreView(appState: appState)35-                    case .local:36-                        LocalView(appState: appState)37-                    case .favorites:38-                        FavoritesView(appState: appState)39-                    case .history:40-                        HistoryView(appState: appState)41-                    case .settings:42-                        SettingsView(appState: appState)43-                    }44-                }45-                .frame(maxWidth: .infinity, maxHeight: .infinity)46-            }37+          switch appState.activeTab {38+          case .explore:39+            ExploreView(appState: appState)40+          case .local:41+            LocalView(appState: appState)42+          case .favorites:43+            FavoritesView(appState: appState)44+          case .history:45+            HistoryView(appState: appState)46+          case .settings:47+            SettingsView(appState: appState)48+          }4749         }48-        .frame(width: 450, height: 640)49-        .background(Color(nsColor: .windowBackgroundColor))50-        .onAppear {51-            Task { await appState.fetchWallpapers() }52-        }50+        .frame(maxWidth: .infinity, maxHeight: .infinity)51+      }5352     }53+    .frame(width: 450, height: 640)54+    .background(Color(nsColor: .windowBackgroundColor))55+    .onAppear {56+      Task { await appState.fetchWallpapers() }57+    }58+  }5459 }5560 5661 // MARK: - Subviews5762 5863 struct ExploreView: View {59-    @ObservedObject var appState: AppState60-    61-    var body: some View {62-        VStack(spacing: 0) {63-            // Controls64-            HStack {65-                HStack {66-                    Image(systemName: "magnifyingglass")67-                        .foregroundColor(.secondary)68-                    TextField("Search...", text: $appState.searchText)69-                }70-                .padding(8)71-                .background(Color(nsColor: .controlBackgroundColor))72-                .cornerRadius(8)73-                74-                Menu {75-                    Section("Categories") {76-                        Toggle("General", isOn: $appState.categories.general)77-                        Toggle("Anime", isOn: $appState.categories.anime)78-                        Toggle("People", isOn: $appState.categories.people)79-                    }80-                    81-                    Section("Purity") {82-                        Toggle("SFW", isOn: $appState.purity.sfw)83-                        Toggle("Sketchy", isOn: $appState.purity.sketchy)84-                    }85-                    86-                    Divider()87-                    88-                    Picker("Resolution", selection: $appState.selectedResolution) {89-                        ForEach(appState.availableResolutions, id: \.self) { res in90-                            Text(res).tag(res)91-                        }92-                    }93-                    Picker("Ratio", selection: $appState.selectedRatio) {94-                        ForEach(appState.availableRatios, id: \.self) { ratio in95-                            Text(ratio).tag(ratio)96-                        }97-                    }98-                } label: {99-                    Image(systemName: "slider.horizontal.3")100-                }64+  @ObservedObject var appState: AppState10165 102-                .menuStyle(.borderlessButton)103-                .frame(width: 30)104-                105-                Button {106-                    Task { await appState.fetchWallpapers(reset: true) }107-                } label: {108-                    Image(systemName: "arrow.clockwise")109-                }110-                .buttonStyle(.plain)111-                .frame(width: 30)112-            }113-            .padding()114-            .onChange(of: appState.selectedResolution) { _ in Task { await appState.fetchWallpapers() } }115-            .onChange(of: appState.selectedRatio) { _ in Task { await appState.fetchWallpapers() } }116-            117-            Divider()66+  var body: some View {67+    VStack(spacing: 0) {68+      // Controls69+      HStack {70+        HStack {71+          Image(systemName: "magnifyingglass")72+            .foregroundColor(.secondary)73+          TextField("Search...", text: $appState.searchText)74+        }75+        .padding(8)76+        .background(Color(nsColor: .controlBackgroundColor))77+        .cornerRadius(8)11878 119-            // Feed-mode selector: Latest, Toplist, Random. The active mode is120-            // visually emphasised; taps update appState.feedMode and trigger121-            // a reset fetch via the Combine observer in AppState.122-            HStack(spacing: 10) {123-                ForEach(FeedMode.allCases) { mode in124-                    FeedModePill(mode: mode, isActive: appState.feedMode == mode) {125-                        if appState.feedMode != mode {126-                            appState.feedMode = mode127-                        }128-                    }129-                }130-            }131-            .padding(.horizontal)132-            .padding(.vertical, 10)79+        Menu {80+          Section("Categories") {81+            Toggle("General", isOn: $appState.categories.general)82+            Toggle("Anime", isOn: $appState.categories.anime)83+            Toggle("People", isOn: $appState.categories.people)84+          }13385 134-            Divider()86+          Section("Purity") {87+            Toggle("SFW", isOn: $appState.purity.sfw)88+            Toggle("Sketchy", isOn: $appState.purity.sketchy)89+          }13590 136-            WallpaperGrid(wallpapers: appState.wallpapers, appState: appState) {137-                Task { await appState.loadMore() }138-            }91+          Divider()92+93+          Picker("Resolution", selection: $appState.selectedResolution) {94+            ForEach(appState.availableResolutions, id: \.self) { resolutionOption($0) }95+          }96+          Picker("Ratio", selection: $appState.selectedRatio) {97+            ForEach(appState.availableRatios, id: \.self) { ratioOption($0) }98+          }99+        } label: {100+          Image(systemName: "slider.horizontal.3")139101         }102+103+        .menuStyle(.borderlessButton)104+        .frame(width: 30)105+106+        Button(action: refreshAction) {107+          Image(systemName: "arrow.clockwise")108+        }109+        .buttonStyle(.plain)110+        .frame(width: 30)111+      }112+      .padding()113+      .onChange(of: appState.selectedResolution) { _ in Task { await appState.fetchWallpapers() } }114+      .onChange(of: appState.selectedRatio) { _ in Task { await appState.fetchWallpapers() } }115+116+      Divider()117+118+      // Feed-mode selector: Latest, Toplist, Random. The active mode is119+      // visually emphasised; taps update appState.feedMode and trigger120+      // a reset fetch via the Combine observer in AppState.121+      HStack(spacing: 10) {122+        ForEach(FeedMode.allCases) { mode in123+          FeedModePill(mode: mode, isActive: appState.feedMode == mode) { select(mode) }124+        }125+      }126+      .padding(.horizontal)127+      .padding(.vertical, 10)128+129+      Divider()130+131+      WallpaperGrid(132+        wallpapers: appState.wallpapers, appState: appState, onEndReached: loadMoreAction)140133     }134+  }135+136+  func resolutionOption(_ resolution: String) -> some View { Text(resolution).tag(resolution) }137+  func ratioOption(_ ratio: String) -> some View { Text(ratio).tag(ratio) }138+  func refresh() async { await appState.fetchWallpapers(reset: true) }139+  func refreshAction() { Task { await refresh() } }140+  func select(_ mode: FeedMode) {141+    if appState.feedMode != mode { appState.feedMode = mode }142+  }143+  func loadMore() async { await appState.loadMore() }144+  func loadMoreAction() { Task { await loadMore() } }141145 }142146 143147 struct FeedModePill: View {144-    let mode: FeedMode145-    let isActive: Bool146-    let action: () -> Void148+  let mode: FeedMode149+  let isActive: Bool150+  let action: () -> Void147151 148-    private var tint: Color {149-        switch mode {150-        case .latest: return Color(red: 0.45, green: 0.85, blue: 0.30)   // green151-        case .toplist: return Color(red: 0.85, green: 0.40, blue: 0.85)  // magenta152-        case .random: return Color(red: 0.98, green: 0.55, blue: 0.20)   // orange153-        }152+  private var tint: Color {153+    switch mode {154+    case .latest: return Color(red: 0.45, green: 0.85, blue: 0.30)  // green155+    case .toplist: return Color(red: 0.85, green: 0.40, blue: 0.85)  // magenta156+    case .random: return Color(red: 0.98, green: 0.55, blue: 0.20)  // orange154157     }158+  }155159 156-    var body: some View {157-        Button(action: action) {158-            HStack(spacing: 6) {159-                Image(systemName: mode.systemImage)160-                Text(mode.displayName)161-                    .font(.system(size: 13, weight: .medium))162-            }163-            .padding(.horizontal, 14)164-            .padding(.vertical, 8)165-            .frame(maxWidth: .infinity)166-            .foregroundColor(isActive ? .white : tint)167-            .background(168-                RoundedRectangle(cornerRadius: 10)169-                    .fill(isActive ? tint : Color.clear)170-            )171-            .overlay(172-                RoundedRectangle(cornerRadius: 10)173-                    .stroke(tint.opacity(isActive ? 0 : 0.6), lineWidth: 1.5)174-            )175-        }176-        .buttonStyle(.plain)160+  var body: some View {161+    Button(action: action) {162+      HStack(spacing: 6) {163+        Image(systemName: mode.systemImage)164+        Text(mode.displayName)165+          .font(.system(size: 13, weight: .medium))166+      }167+      .padding(.horizontal, 14)168+      .padding(.vertical, 8)169+      .frame(maxWidth: .infinity)170+      .foregroundColor(isActive ? .white : tint)171+      .background(172+        RoundedRectangle(cornerRadius: 10)173+          .fill(isActive ? tint : Color.clear)174+      )175+      .overlay(176+        RoundedRectangle(cornerRadius: 10)177+          .stroke(tint.opacity(isActive ? 0 : 0.6), lineWidth: 1.5)178+      )177179     }180+    .buttonStyle(.plain)181+  }178182 }179183 180184 struct FavoritesView: View {181-    @ObservedObject var appState: AppState182-    183-    var body: some View {184-        if appState.favoritesManager.favorites.isEmpty {185-            EmptyState(icon: "heart.slash", text: "No favorites yet")186-        } else {187-            WallpaperGrid(wallpapers: appState.favoritesManager.favorites, appState: appState, onEndReached: nil)188-        }185+  @ObservedObject var appState: AppState186+187+  var body: some View {188+    if appState.favoritesManager.favorites.isEmpty {189+      EmptyState(icon: "heart.slash", text: "No favorites yet")190+    } else {191+      WallpaperGrid(192+        wallpapers: appState.favoritesManager.favorites, appState: appState, onEndReached: nil)189193     }194+  }190195 }191196 192197 struct HistoryView: View {193-    @ObservedObject var appState: AppState194-    195-    var body: some View {196-        VStack {197-            if !appState.historyManager.history.isEmpty {198-                HStack {199-                    Spacer()200-                    Button("Clear History") {201-                        appState.historyManager.clearHistory()202-                    }203-                    .font(.caption)204-                    .buttonStyle(.link)205-                }206-                .padding(.horizontal)207-                .padding(.top, 8)208-            }209-            210-            if appState.historyManager.history.isEmpty {211-                EmptyState(icon: "clock.arrow.circlepath", text: "No history yet")212-            } else {213-                WallpaperGrid(wallpapers: appState.historyManager.history, appState: appState, onEndReached: nil)214-            }198+  @ObservedObject var appState: AppState199+200+  var body: some View {201+    VStack {202+      if !appState.historyManager.history.isEmpty {203+        HStack {204+          Spacer()205+          Button("Clear History") { clearHistory() }206+            .font(.caption)207+            .buttonStyle(.link)215208         }209+        .padding(.horizontal)210+        .padding(.top, 8)211+      }212+213+      if appState.historyManager.history.isEmpty {214+        EmptyState(icon: "clock.arrow.circlepath", text: "No history yet")215+      } else {216+        WallpaperGrid(217+          wallpapers: appState.historyManager.history, appState: appState, onEndReached: nil)218+      }216219     }220+  }221+222+  func clearHistory() { appState.historyManager.clearHistory() }217223 }218224 219225 struct SettingsView: View {220-    @ObservedObject var appState: AppState226+  @ObservedObject var appState: AppState221227 222-    var body: some View {223-        VStack(alignment: .leading, spacing: 24) {224-            VStack(alignment: .leading, spacing: 12) {225-                Text("Storage")226-                    .font(.headline)228+  var body: some View {229+    VStack(alignment: .leading, spacing: 24) {230+      VStack(alignment: .leading, spacing: 12) {231+        Text("Storage")232+          .font(.headline)227233 228-                Toggle("Launch at Login", isOn: $appState.launchAtLogin)229-                Toggle("Save wallpapers locally", isOn: $appState.saveToFolderEnabled)234+        Toggle("Launch at Login", isOn: $appState.launchAtLogin)235+        Toggle("Save wallpapers locally", isOn: $appState.saveToFolderEnabled)230236 231-                if appState.saveToFolderEnabled {232-                    HStack {233-                        Text("Location")234-                        Spacer()235-                        Text(URL(fileURLWithPath: appState.saveFolderPath).lastPathComponent)236-                            .foregroundColor(.secondary)237-                            .lineLimit(1)238-                            .truncationMode(.middle)239-                        Button("Change…") {240-                            appState.selectSaveFolder()241-                        }242-                    }243-                }244-            }245-237+        if appState.saveToFolderEnabled {238+          HStack {239+            Text("Location")246240             Spacer()247-248-            Button(role: .destructive) {249-                appState.quit()250-            } label: {251-                Text("Quit Haven")252-                    .frame(maxWidth: .infinity)253-            }254-            .controlSize(.large)241+            Text(URL(fileURLWithPath: appState.saveFolderPath).lastPathComponent)242+              .foregroundColor(.secondary)243+              .lineLimit(1)244+              .truncationMode(.middle)245+            Button("Change…") { selectFolder() }246+          }255247         }256-        .padding()257-        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)248+      }249+250+      Spacer()251+252+      Button(role: .destructive, action: quit) {253+        Text("Quit Haven")254+          .frame(maxWidth: .infinity)255+      }256+      .controlSize(.large)258257     }258+    .padding()259+    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)260+  }261+262+  func selectFolder() { appState.selectSaveFolder() }263+  func quit() { appState.quit() }259264 }260265 261266 struct EmptyState: View {262-    let icon: String263-    let text: String264-    265-    var body: some View {266-        VStack(spacing: 16) {267-            Image(systemName: icon)268-                .font(.system(size: 48))269-                .foregroundColor(.gray.opacity(0.5))270-            Text(text)271-                .foregroundColor(.secondary)272-        }273-        .frame(maxWidth: .infinity, maxHeight: .infinity)267+  let icon: String268+  let text: String269+270+  var body: some View {271+    VStack(spacing: 16) {272+      Image(systemName: icon)273+        .font(.system(size: 48))274+        .foregroundColor(.gray.opacity(0.5))275+      Text(text)276+        .foregroundColor(.secondary)274277     }278+    .frame(maxWidth: .infinity, maxHeight: .infinity)279+  }275280 }276281 277282 struct TabButton: View {278-    let icon: String279-    let tab: AppTab280-    @Binding var activeTab: AppTab281-    282-    var body: some View {283-        Button {284-            activeTab = tab285-        } label: {286-            Image(systemName: icon)287-                .symbolVariant(activeTab == tab ? .fill : .none)288-                .foregroundColor(activeTab == tab ? .accentColor : .secondary)289-                .font(.system(size: 18))290-        }291-        .buttonStyle(.plain)283+  let icon: String284+  let tab: AppTab285+  @Binding var activeTab: AppTab286+287+  var body: some View {288+    Button(action: select) {289+      Image(systemName: icon)290+        .symbolVariant(activeTab == tab ? .fill : .none)291+        .foregroundColor(activeTab == tab ? .accentColor : .secondary)292+        .font(.system(size: 18))292293     }294+    .buttonStyle(.plain)295+  }296+297+  func select() { activeTab = tab }293298 }294299 295300 // Redeclaring this as it was in the original file, or we can move to a separate file296301 struct VisualEffectView: NSViewRepresentable {297-    let material: NSVisualEffectView.Material298-    let blendingMode: NSVisualEffectView.BlendingMode299-    300-    func makeNSView(context: Context) -> NSVisualEffectView {301-        let visualEffectView = NSVisualEffectView()302-        visualEffectView.material = material303-        visualEffectView.blendingMode = blendingMode304-        visualEffectView.state = .active305-        return visualEffectView306-    }307-    308-    func updateNSView(_ nsView: NSVisualEffectView, context: Context) {309-        nsView.material = material310-        nsView.blendingMode = blendingMode311-    }312-}\ No newline at end of file302+  let material: NSVisualEffectView.Material303+  let blendingMode: NSVisualEffectView.BlendingMode304+305+  func makeNSView(context: Context) -> NSVisualEffectView {306+    let visualEffectView = NSVisualEffectView()307+    visualEffectView.material = material308+    visualEffectView.blendingMode = blendingMode309+    visualEffectView.state = .active310+    return visualEffectView311+  }312+313+  func updateNSView(_ nsView: NSVisualEffectView, context: Context) {314+    nsView.material = material315+    nsView.blendingMode = blendingMode316+  }317+}

view file

Sources/Haven/UI/Views/WallpaperGrid.swift+25 −28
@@ -1,35 +1,32 @@11 import SwiftUI22 33 struct WallpaperGrid: View {4-    let wallpapers: [Wallpaper]5-    @ObservedObject var appState: AppState6-    let onEndReached: (() -> Void)?7-    8-    var body: some View {9-        ScrollView {10-            LazyVGrid(columns: [GridItem(.adaptive(minimum: 140), spacing: 12)], spacing: 12) {11-                ForEach(wallpapers) { wallpaper in12-                    WallpaperItem(wallpaper: wallpaper) {13-                        Task {14-                            await appState.setWallpaper(wallpaper)15-                        }16-                    }17-                    .onAppear {18-                        if wallpaper == wallpapers.last {19-                            onEndReached?()20-                        }21-                    }22-                }4+  let wallpapers: [Wallpaper]5+  @ObservedObject var appState: AppState6+  let onEndReached: (() -> Void)?7+8+  var body: some View {9+    ScrollView {10+      LazyVGrid(columns: [GridItem(.adaptive(minimum: 140), spacing: 12)], spacing: 12) {11+        ForEach(wallpapers) { wallpaper in12+          WallpaperItem(wallpaper: wallpaper) { Task { await set(wallpaper) } }13+            .onAppear {14+              if wallpaper == wallpapers.last {15+                onEndReached?()16+              }2317             }24-            .padding(12)25-            26-            if appState.isLoading && !appState.wallpapers.isEmpty {27-                ProgressView()28-                    .padding()29-            }3018         }31-        .refreshable {32-            await appState.fetchWallpapers(reset: true, refreshing: true)33-        }19+      }20+      .padding(12)21+22+      if appState.isLoading && !appState.wallpapers.isEmpty {23+        ProgressView()24+          .padding()25+      }3426     }27+    .refreshable { await refresh() }28+  }29+30+  func set(_ wallpaper: Wallpaper) async { await appState.setWallpaper(wallpaper) }31+  func refresh() async { await appState.fetchWallpapers(reset: true, refreshing: true) }3532 }

view file

Sources/Haven/Utils/Configuration.swift+2 −2
@@ -1,6 +1,6 @@11 import Foundation22 33 struct Configuration {4-    static let baseURL = URL(string: "https://wallhaven.cc/api/v1")!5-    static let defaultResolution = "1920x1080"4+  static let baseURL = URL(string: "https://wallhaven.cc/api/v1")!5+  static let defaultResolution = "1920x1080"66 }

view file

Sources/Haven/main.swift+5 −2
@@ -2,5 +2,8 @@22 33 let app = NSApplication.shared44 let delegate = AppDelegate()5-app.delegate = delegate6-app.run()5+HavenApplication.start(6+  delegate: delegate,7+  shouldRun: ProcessInfo.processInfo.environment["HAVEN_COVERAGE_SMOKE"] != "1",8+  setDelegate: { app.delegate = $0 },9+  run: { app.run() })

view file

Tests/HavenTests/AppStateTests.swift+276 −0
@@ -0,0 +1,276 @@1+import AppKit2+import Foundation3+import Testing4+5+@testable import Haven6+7+@MainActor8+final class SearchStub: WallhavenSearching {9+  struct Request {10+    let query: String11+    let categories: String12+    let purity: String13+    let resolutions: [String]14+    let ratios: [String]15+    let page: Int16+  }17+18+  var results: [Result<([Wallpaper], Meta?), Error>] = []19+  var requests: [Request] = []20+21+  func searchWallpapers(22+    query: String, categories: String, purity: String, sorting: WallhavenSorting,23+    resolutions: [String], ratios: [String], limit: Int, page: Int24+  ) async throws -> ([Wallpaper], Meta?) {25+    requests.append(26+      Request(27+        query: query, categories: categories, purity: purity, resolutions: resolutions,28+        ratios: ratios, page: page))29+    return try results.removeFirst().get()30+  }31+}32+33+@MainActor34+final class WallpaperStub: WallpaperManaging {35+  var error: Error?36+  var downloadedURL = URL(fileURLWithPath: "/tmp/downloaded.jpg")37+  var downloads: [URL] = []38+  var desktopURLs: [URL] = []39+  var saves: [(URL, URL)] = []40+41+  func download(from url: URL) async throws -> URL {42+    if let error { throw error }43+    downloads.append(url)44+    return downloadedURL45+  }46+47+  func setDesktopImage(from fileURL: URL, screen: NSScreen?) throws {48+    if let error { throw error }49+    desktopURLs.append(fileURL)50+  }51+52+  func saveToFolder(from sourceURL: URL, to folderURL: URL) throws {53+    if let error { throw error }54+    saves.append((sourceURL, folderURL))55+  }56+}57+58+@MainActor59+final class AutostartStub: AutostartManaging {60+  var isEnabled = false61+}62+63+@Suite("Explore feed configuration")64+struct FeedModeTests {65+  @Test(66+    "Feed modes map to API sorting",67+    arguments: [68+      (FeedMode.latest, WallhavenSorting.dateAdded),69+      (FeedMode.toplist, WallhavenSorting.toplist),70+      (FeedMode.random, WallhavenSorting.random),71+    ]72+  )73+  func sortingMapping(mode: FeedMode, sorting: WallhavenSorting) {74+    #expect(mode.sorting.rawValue == sorting.rawValue)75+  }76+77+  @Test("Every feed mode has non-empty display metadata", arguments: FeedMode.allCases)78+  func displayMetadata(mode: FeedMode) {79+    #expect(!mode.displayName.isEmpty)80+    #expect(!mode.systemImage.isEmpty)81+    #expect(mode.id == mode.rawValue)82+  }83+84+  @Test("Resolution and ratio defaults retain the Any option")85+  @MainActor86+  func filterOptions() {87+    let state = AppState()88+89+    #expect(state.availableResolutions.first == "Any")90+    #expect(state.availableRatios.first == "Any")91+    #expect(state.availableResolutions.contains("3840x2160"))92+    #expect(state.availableRatios.contains("21x9"))93+  }94+}95+96+@Suite("Application state behavior", .serialized)97+@MainActor98+struct ApplicationStateTests {99+  private func makeState(100+    search: SearchStub? = nil, wallpaper: WallpaperStub? = nil,101+    autostart: AutostartStub? = nil,102+    chooseFolder: @escaping @MainActor (URL, @escaping (URL?) -> Void) -> Void = { _, completion in103+      completion(nil)104+    },105+    terminate: @escaping @MainActor () -> Void = {}106+  ) -> AppState {107+    let search = search ?? SearchStub()108+    let wallpaper = wallpaper ?? WallpaperStub()109+    let autostart = autostart ?? AutostartStub()110+    return AppState(111+      api: search, wallpaperManager: wallpaper, autostartManager: autostart,112+      chooseFolder: chooseFolder, terminateApplication: terminate, observeChanges: false)113+  }114+115+  @Test("Reset and pagination apply filters, metadata, and append behavior")116+  func fetchAndPaginate() async {117+    let search = SearchStub()118+    let first = makeWallpaper(id: "first")119+    let second = makeWallpaper(id: "second")120+    search.results = [121+      .success(([first], Meta(current_page: 1, last_page: 2, per_page: 24, total: 2))),122+      .success(([second], Meta(current_page: 2, last_page: 2, per_page: 24, total: 2))),123+    ]124+    let state = makeState(search: search)125+    state.searchText = "forest"126+    state.selectedResolution = "3840x2160"127+    state.selectedRatio = "21x9"128+    state.categories = (true, false, true)129+    state.purity = (false, true)130+131+    await state.fetchWallpapers()132+    #expect(state.wallpapers == [first])133+    #expect(state.canLoadMore)134+    #expect(!state.isLoading)135+    #expect(search.requests[0].query == "forest")136+    #expect(search.requests[0].categories == "101")137+    #expect(search.requests[0].purity == "010")138+    #expect(search.requests[0].resolutions == ["3840x2160"])139+    #expect(search.requests[0].ratios == ["21x9"])140+141+    await state.loadMore()142+    #expect(state.wallpapers == [first, second])143+    #expect(!state.canLoadMore)144+    #expect(search.requests[1].page == 2)145+146+    await state.loadMore()147+    #expect(search.requests.count == 2)148+  }149+150+  @Test("Metadata-free responses and failures update fallback pagination state")151+  func fallbackPaginationAndFailures() async {152+    let search = SearchStub()153+    search.results = [154+      .success(([makeWallpaper(id: "one")], nil)),155+      .success(([], nil)),156+      .failure(URLError(.notConnectedToInternet)),157+      .failure(URLError(.timedOut)),158+    ]159+    let state = makeState(search: search)160+    state.selectedResolution = "Any"161+    state.selectedRatio = "Any"162+163+    await state.fetchWallpapers()164+    #expect(state.canLoadMore)165+    await state.loadMore()166+    #expect(!state.canLoadMore)167+    #expect(search.requests[1].page == 2)168+169+    await state.fetchWallpapers()170+    #expect(state.errorMessage != nil)171+    state.canLoadMore = true172+    await state.loadMore()173+    #expect(!state.isLoading)174+  }175+176+  @Test("Remote wallpaper operations cover saving and failures")177+  func remoteWallpaperOperations() async {178+    let wallpaperManager = WallpaperStub()179+    let state = makeState(wallpaper: wallpaperManager)180+    let wallpaper = makeWallpaper()181+    state.saveFolderPath = "/tmp/haven-tests"182+183+    state.saveToFolderEnabled = false184+    await state.setWallpaper(wallpaper)185+    #expect(wallpaperManager.desktopURLs == [wallpaperManager.downloadedURL])186+    #expect(wallpaperManager.saves.isEmpty)187+188+    state.saveToFolderEnabled = true189+    await state.setWallpaper(wallpaper)190+    #expect(wallpaperManager.saves.count == 1)191+192+    await state.downloadWallpaper(wallpaper)193+    #expect(wallpaperManager.saves.count == 2)194+195+    wallpaperManager.error = URLError(.cannotWriteToFile)196+    await state.setWallpaper(wallpaper)197+    #expect(state.errorMessage?.hasPrefix("Failed to set wallpaper") == true)198+    await state.downloadWallpaper(wallpaper)199+    #expect(state.errorMessage?.hasPrefix("Failed to download") == true)200+  }201+202+  @Test("Local wallpaper, autostart, folder picker, and quit are injectable")203+  func localAndPlatformActions() async {204+    let wallpaperManager = WallpaperStub()205+    let autostart = AutostartStub()206+    var terminated = false207+    let selected = URL(fileURLWithPath: "/tmp/selected")208+    let state = makeState(209+      wallpaper: wallpaperManager, autostart: autostart,210+      chooseFolder: { _, completion in completion(selected) },211+      terminate: { terminated = true })212+    let local = LocalWallpaper(213+      id: "local", url: URL(fileURLWithPath: "/tmp/local.jpg"), filename: "local.jpg",214+      resolution: "1x1", fileSize: 10, createdAt: Date(), modifiedAt: Date())215+216+    await state.setLocalWallpaper(local)217+    #expect(wallpaperManager.desktopURLs == [local.url])218+    #expect(state.historyManager.history.first?.purity == "local")219+220+    wallpaperManager.error = URLError(.cannotOpenFile)221+    await state.setLocalWallpaper(local)222+    #expect(state.errorMessage?.hasPrefix("Failed to set wallpaper") == true)223+224+    state.launchAtLogin = true225+    #expect(autostart.isEnabled)226+    state.selectSaveFolder()227+    #expect(state.saveFolderPath == selected.path)228+    state.quit()229+    #expect(terminated)230+231+    var offeredDirectory: URL?232+    let cancelState = makeState(chooseFolder: { directory, completion in233+      offeredDirectory = directory234+      completion(nil)235+    })236+    let oldPath = cancelState.saveFolderPath237+    cancelState.selectSaveFolder()238+    #expect(offeredDirectory?.path == oldPath)239+    #expect(cancelState.saveFolderPath == oldPath)240+  }241+242+  @Test("Observed search and settings changes trigger their pipelines")243+  func observedChanges() async {244+    let search = SearchStub()245+    search.results = Array(repeating: .success(([], nil)), count: 50)246+    let state = AppState(247+      api: search, chooseFolder: { _, completion in completion(nil) },248+      terminateApplication: {}, observeChanges: true)249+250+    func waitForRequests(_ expected: Int) async {251+      for _ in 0..<100 where search.requests.count < expected {252+        try? await Task.sleep(nanoseconds: 20_000_000)253+      }254+    }255+256+    state.scheduleSearch()257+    await waitForRequests(1)258+259+    state.feedMode = .random260+    await waitForRequests(2)261+262+    state.searchText = "updated"263+    await waitForRequests(3)264+265+    state.categories = (false, true, true)266+    await waitForRequests(4)267+268+    state.purity = (true, true)269+    await waitForRequests(5)270+271+    state.saveToFolderEnabled.toggle()272+    state.saveFolderPath = FileManager.default.temporaryDirectory.path273+274+    #expect(search.requests.count >= 5)275+  }276+}

view file

Tests/HavenTests/HavenTests.swift+0 −30
@@ -1,30 +0,0 @@1-import XCTest2-@testable import Haven3-4-class HavenTests: XCTestCase {5-    6-    // MARK: - ImageCache Tests7-    8-    func testImageCache() {9-        let cache = ImageCache.shared10-        let url = URL(string: "https://example.com/test.png")!11-        let image = NSImage(size: NSSize(width: 10, height: 10))12-        13-        cache.insertImage(image, for: url)14-        15-        let retrieved = cache.image(for: url)16-        XCTAssertNotNil(retrieved)17-        XCTAssertEqual(retrieved?.size, image.size)18-    }19-    20-    // MARK: - API Tests21-    // Note: In a real production environment, we would use a Mock URLProtocol to intercept requests.22-    // Here we will test the URL construction logic which is accessible if we make helper methods internal/public.23-    // Since performSearch is private, we'll test public methods by mocking the session if possible, 24-    // or just checking if they throw immediately on bad input.25-    26-    func testAPIInitialization() {27-        let api = WallhavenAPI()28-        XCTAssertNotNil(api)29-    }30-}

view file at the parent

Tests/HavenTests/ManagerTests.swift+430 −0
@@ -0,0 +1,430 @@1+import AppKit2+import Foundation3+import Testing4+5+@testable import Haven6+7+private final class ManagerURLProtocol: URLProtocol, @unchecked Sendable {8+  override class func canInit(with request: URLRequest) -> Bool { true }9+  override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }10+  override func startLoading() {11+    let path = request.url!.path12+    let statusCode = path.contains("fail-http") ? 500 : 20013+    let data: Data14+    if path.contains("image.png") {15+      data = onePixelPNG16+    } else if path.contains("invalid.png") {17+      data = Data("not an image".utf8)18+    } else {19+      data = Data("wallpaper".utf8)20+    }21+    let response = HTTPURLResponse(22+      url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)!23+    client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)24+    client?.urlProtocol(self, didLoad: data)25+    client?.urlProtocolDidFinishLoading(self)26+  }27+  override func stopLoading() {}28+}29+30+private func managerSession() -> URLSession {31+  let configuration = URLSessionConfiguration.ephemeral32+  configuration.protocolClasses = [ManagerURLProtocol.self]33+  return URLSession(configuration: configuration)34+}35+36+private let onePixelPNG = Data(37+  base64Encoded:38+    "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="39+)!40+41+@Suite("Favorites persistence", .serialized)42+struct FavoritesManagerTests {43+  private let defaultsKey = "favoriteWallpapers"44+45+  @Test("Toggling adds and removes a favorite")46+  func toggleFavorite() {47+    UserDefaults.standard.removeObject(forKey: defaultsKey)48+    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }49+    let manager = FavoritesManager()50+    let wallpaper = makeWallpaper()51+52+    manager.toggleFavorite(wallpaper)53+    #expect(manager.favorites == [wallpaper])54+    #expect(manager.isFavorite(wallpaper))55+56+    manager.toggleFavorite(wallpaper)57+    #expect(manager.favorites.isEmpty)58+    #expect(!manager.isFavorite(wallpaper))59+  }60+61+  @Test("Favorites survive manager recreation")62+  func favoritesPersist() {63+    UserDefaults.standard.removeObject(forKey: defaultsKey)64+    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }65+    let wallpaper = makeWallpaper()66+    FavoritesManager().toggleFavorite(wallpaper)67+68+    let reloaded = FavoritesManager()69+70+    #expect(reloaded.favorites == [wallpaper])71+  }72+}73+74+@Suite("History persistence", .serialized)75+struct HistoryManagerTests {76+  private let defaultsKey = "wallpaperHistory"77+78+  @Test("History keeps newest items first and promotes duplicates")79+  func orderingAndPromotion() {80+    UserDefaults.standard.removeObject(forKey: defaultsKey)81+    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }82+    let manager = HistoryManager()83+    let first = makeWallpaper(id: "first")84+    let second = makeWallpaper(id: "second")85+86+    manager.addToHistory(first)87+    manager.addToHistory(second)88+    manager.addToHistory(first)89+90+    #expect(manager.history.map(\.id) == ["first", "second"])91+  }92+93+  @Test("History is capped at fifty entries")94+  func historyLimit() {95+    UserDefaults.standard.removeObject(forKey: defaultsKey)96+    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }97+    let manager = HistoryManager()98+99+    for index in 0..<55 {100+      manager.addToHistory(makeWallpaper(id: "item-\(index)"))101+    }102+103+    #expect(manager.history.count == 50)104+    #expect(manager.history.first?.id == "item-54")105+    #expect(manager.history.last?.id == "item-5")106+  }107+108+  @Test("History survives recreation and clear removes persisted data")109+  func persistenceAndClear() {110+    UserDefaults.standard.removeObject(forKey: defaultsKey)111+    defer { UserDefaults.standard.removeObject(forKey: defaultsKey) }112+    HistoryManager().addToHistory(makeWallpaper(id: "persisted"))113+114+    let reloaded = HistoryManager()115+    #expect(reloaded.history.map(\.id) == ["persisted"])116+117+    reloaded.clearHistory()118+    #expect(HistoryManager().history.isEmpty)119+  }120+}121+122+@Suite("Image cache")123+struct ImageCacheTests {124+  @Test("Inserted images are returned for the same URL")125+  func insertAndRetrieve() {126+    let url = URL(string: "https://example.com/\(UUID().uuidString).png")!127+    let image = NSImage(size: NSSize(width: 10, height: 20))128+129+    ImageCache.shared.insertImage(image, for: url)130+131+    #expect(ImageCache.shared.image(for: url) === image)132+  }133+134+  @Test("Different URLs do not collide")135+  func keysDoNotCollide() {136+    let base = UUID().uuidString137+    let firstURL = URL(string: "https://example.com/\(base)-first.png")!138+    let secondURL = URL(string: "https://example.com/\(base)-second.png")!139+140+    ImageCache.shared.insertImage(NSImage(size: NSSize(width: 1, height: 1)), for: firstURL)141+142+    #expect(ImageCache.shared.image(for: secondURL) == nil)143+  }144+145+  @Test("Fetch decodes and caches successful image responses")146+  func fetchAndCache() async throws {147+    let cache = ImageCache(session: managerSession())148+    let url = URL(string: "https://example.com/image.png")!149+150+    let first = try await cache.fetchImage(url: url)151+    let second = try await cache.fetchImage(url: url)152+153+    #expect(first === second)154+  }155+156+  @Test("Fetch rejects HTTP errors and invalid image bytes")157+  func fetchFailures() async {158+    let cache = ImageCache(session: managerSession())159+    let httpError = URL(string: "https://example.com/fail-http.png")!160+    let invalid = URL(string: "https://example.com/invalid.png")!161+    await #expect(throws: WallhavenError.self) { try await cache.fetchImage(url: httpError) }162+    await #expect(throws: WallhavenError.self) { try await cache.fetchImage(url: invalid) }163+  }164+}165+166+@Suite("Wallpaper file management", .serialized)167+@MainActor168+struct WallpaperManagerTests {169+  @Test("Download validates HTTP status and writes response data")170+  func download() async throws {171+    let manager = WallpaperManager(session: managerSession())172+    let url = URL(string: "https://example.com/\(UUID().uuidString).jpg")!173+    let downloaded = try await manager.download(from: url)174+    defer { try? FileManager.default.removeItem(at: downloaded) }175+176+    #expect(try Data(contentsOf: downloaded) == Data("wallpaper".utf8))177+178+    let errorURL = URL(string: "https://example.com/fail-http.jpg")!179+    await #expect(throws: WallhavenError.self) { try await manager.download(from: errorURL) }180+  }181+182+  @Test("Desktop setter receives explicit and default screens")183+  @MainActor184+  func desktopSetter() throws {185+    guard let screen = NSScreen.screens.first else { return }186+    var received: [NSScreen] = []187+    let manager = WallpaperManager(desktopSetter: { _, target in received.append(target) })188+    let url = URL(fileURLWithPath: "/tmp/test-wallpaper.jpg")189+190+    try manager.setDesktopImage(from: url, screen: screen)191+    try manager.setDesktopImage(from: url)192+193+    #expect(received.count == 2)194+    #expect(received[0] === screen)195+  }196+197+  @Test("Default desktop adapter reports invalid local files")198+  func defaultDesktopAdapter() {199+    guard let screen = NSScreen.screens.first else { return }200+    let manager = WallpaperManager()201+    #expect(throws: Error.self) {202+      try manager.setDesktopImage(203+        from: URL(fileURLWithPath: "/definitely-missing-haven-wallpaper.jpg"), screen: screen)204+    }205+  }206+207+  @Test("Set wallpaper composes download and desktop update")208+  @MainActor209+  func setWallpaper() async throws {210+    guard let screen = NSScreen.screens.first else { return }211+    var receivedURL: URL?212+    let manager = WallpaperManager(213+      session: managerSession(), desktopSetter: { url, _ in receivedURL = url })214+    let remote = URL(string: "https://example.com/\(UUID().uuidString).jpg")!215+216+    try await manager.setWallpaper(from: remote, screen: screen)217+    defer { if let receivedURL { try? FileManager.default.removeItem(at: receivedURL) } }218+219+    #expect(receivedURL?.lastPathComponent == remote.lastPathComponent)220+  }221+222+  @Test("Saving creates the destination folder and copies the file")223+  func saveCreatesFolderAndCopies() throws {224+    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)225+    defer { try? FileManager.default.removeItem(at: root) }226+    let source = root.appendingPathComponent("source/wallpaper.jpg")227+    let destination = root.appendingPathComponent("nested/destination")228+    try FileManager.default.createDirectory(229+      at: source.deletingLastPathComponent(), withIntermediateDirectories: true)230+    try Data("first".utf8).write(to: source)231+232+    try WallpaperManager.shared.saveToFolder(from: source, to: destination)233+234+    let copied = try Data(contentsOf: destination.appendingPathComponent("wallpaper.jpg"))235+    #expect(copied == Data("first".utf8))236+  }237+238+  @Test("Saving replaces an existing file with fresh data")239+  func saveReplacesExistingFile() throws {240+    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)241+    defer { try? FileManager.default.removeItem(at: root) }242+    let source = root.appendingPathComponent("source/wallpaper.jpg")243+    let destination = root.appendingPathComponent("destination")244+    try FileManager.default.createDirectory(245+      at: source.deletingLastPathComponent(), withIntermediateDirectories: true)246+    try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)247+    try Data("new".utf8).write(to: source)248+    try Data("old".utf8).write(to: destination.appendingPathComponent("wallpaper.jpg"))249+250+    try WallpaperManager.shared.saveToFolder(from: source, to: destination)251+252+    let copied = try Data(contentsOf: destination.appendingPathComponent("wallpaper.jpg"))253+    #expect(copied == Data("new".utf8))254+  }255+}256+257+@Suite("Autostart persistence", .serialized)258+@MainActor259+struct AutostartManagerTests {260+  @Test("Enable writes a launch agent and disable removes it")261+  func enableAndDisable() throws {262+    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)263+    defer { try? FileManager.default.removeItem(at: root) }264+    let manager = AutostartManager(265+      libraryDirectory: root, executablePath: { "/Applications/Haven.app/Contents/MacOS/Haven" })266+267+    #expect(!manager.isEnabled)268+    manager.isEnabled = true269+    #expect(manager.isEnabled)270+    manager.isEnabled = true271+272+    let plist = root.appendingPathComponent("LaunchAgents/com.june.Haven.plist")273+    let contents =274+      try PropertyListSerialization.propertyList(275+        from: Data(contentsOf: plist), format: nil) as? [String: Any]276+    #expect(contents?["Label"] as? String == "com.june.Haven")277+    #expect(278+      contents?["ProgramArguments"] as? [String]279+        == ["/Applications/Haven.app/Contents/MacOS/Haven"])280+    #expect(contents?["RunAtLoad"] as? Bool == true)281+282+    manager.isEnabled = false283+    #expect(!manager.isEnabled)284+    manager.isEnabled = false285+  }286+287+  @Test("Missing executable leaves autostart disabled")288+  func missingExecutable() {289+    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)290+    defer { try? FileManager.default.removeItem(at: root) }291+    let manager = AutostartManager(libraryDirectory: root, executablePath: { nil })292+293+    manager.isEnabled = true294+295+    #expect(!manager.isEnabled)296+  }297+298+  @Test("Default executable provider writes only inside the injected library")299+  func defaultExecutableProvider() {300+    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)301+    defer { try? FileManager.default.removeItem(at: root) }302+    let manager = AutostartManager(libraryDirectory: root)303+    manager.isEnabled = true304+    #expect(manager.isEnabled)305+    manager.isEnabled = false306+  }307+308+  @Test("Unwritable launch-agent roots do not enable autostart")309+  func writeFailure() throws {310+    let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)311+    defer { try? FileManager.default.removeItem(at: file) }312+    try Data("not a directory".utf8).write(to: file)313+    let manager = AutostartManager(libraryDirectory: file, executablePath: { "/tmp/Haven" })314+    manager.isEnabled = true315+    #expect(!manager.isEnabled)316+  }317+318+  @Test("Removal failures leave the launch agent enabled")319+  func removalFailure() {320+    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)321+    defer { try? FileManager.default.removeItem(at: root) }322+    let manager = AutostartManager(323+      libraryDirectory: root, executablePath: { "/tmp/Haven" },324+      removePlist: { _ in throw CocoaError(.fileWriteNoPermission) })325+    manager.isEnabled = true326+    manager.isEnabled = false327+    #expect(manager.isEnabled)328+  }329+}330+331+@Suite("Local wallpaper library", .serialized)332+@MainActor333+struct LocalWallpapersManagerTests {334+  private func waitForScan(_ manager: LocalWallpapersManager) async {335+    for _ in 0..<100 where manager.isLoading {336+      try? await Task.sleep(nanoseconds: 10_000_000)337+    }338+  }339+340+  @Test("Scan filters files, reads metadata, and deletes wallpapers")341+  func scanAndDelete() async throws {342+    let defaults = UserDefaults.standard343+    let oldPath = defaults.string(forKey: "saveFolderPath")344+    defaults.removeObject(forKey: "saveFolderPath")345+    defer {346+      if let oldPath {347+        defaults.set(oldPath, forKey: "saveFolderPath")348+      } else {349+        defaults.removeObject(forKey: "saveFolderPath")350+      }351+    }352+    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)353+    defer { try? FileManager.default.removeItem(at: root) }354+    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)355+    try onePixelPNG.write(to: root.appendingPathComponent("wallpaper.png"))356+    try Data("broken".utf8).write(to: root.appendingPathComponent("broken.png"))357+    try Data("ignored".utf8).write(to: root.appendingPathComponent("notes.txt"))358+    let manager = LocalWallpapersManager()359+360+    manager.scanFolder(path: root.path)361+    manager.scanFolder(path: root.path)362+    await waitForScan(manager)363+364+    let wallpaper = try #require(manager.wallpapers.first)365+    #expect(manager.wallpapers.count == 1)366+    #expect(wallpaper.thumbnailURL == wallpaper.url)367+    #expect(wallpaper.filename == "wallpaper.png")368+    #expect(wallpaper.resolution == "1x1")369+    #expect(LocalWallpapersManager.wallpaper(at: root.appendingPathComponent("broken.png")) == nil)370+    #expect(LocalWallpapersManager.wallpaper(at: root.appendingPathComponent("missing.png")) == nil)371+372+    manager.deleteWallpaper(wallpaper)373+    #expect(manager.wallpapers.isEmpty)374+    manager.deleteWallpaper(wallpaper)375+    #expect(manager.errorMessage?.hasPrefix("Failed to delete") == true)376+  }377+378+  @Test("Invalid scan roots surface an error")379+  func scanError() async throws {380+    let defaults = UserDefaults.standard381+    defaults.removeObject(forKey: "saveFolderPath")382+    let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)383+    defer { try? FileManager.default.removeItem(at: file) }384+    try Data("file".utf8).write(to: file)385+    let manager = LocalWallpapersManager()386+387+    manager.scanFolder(path: file.path)388+    await waitForScan(manager)389+390+    #expect(manager.errorMessage?.hasPrefix("Failed to scan folder") == true)391+    #expect(!manager.isLoading)392+  }393+394+  @Test("Monitoring configured folders observes filesystem changes and stops")395+  func monitoring() async throws {396+    let defaults = UserDefaults.standard397+    let oldPath = defaults.string(forKey: "saveFolderPath")398+    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)399+    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)400+    defaults.set(root.path, forKey: "saveFolderPath")401+    defer {402+      try? FileManager.default.removeItem(at: root)403+      if let oldPath {404+        defaults.set(oldPath, forKey: "saveFolderPath")405+      } else {406+        defaults.removeObject(forKey: "saveFolderPath")407+      }408+    }409+    let manager = LocalWallpapersManager()410+    await waitForScan(manager)411+412+    try onePixelPNG.write(to: root.appendingPathComponent("observed.png"))413+    try? await Task.sleep(nanoseconds: 100_000_000)414+    await waitForScan(manager)415+    #expect(manager.wallpapers.contains(where: { $0.filename == "observed.png" }))416+417+    manager.stopMonitoring()418+    try? await Task.sleep(nanoseconds: 500_000_000)419+420+    let invalidMonitor = FolderMonitor(folderPath: root.appendingPathComponent("missing").path) {}421+    invalidMonitor.start()422+    invalidMonitor.closeFileDescriptor()423+424+    let descriptor = open("/dev/null", O_RDONLY)425+    let descriptorMonitor = FolderMonitor(426+      folderPath: "/dev/null", fileDescriptor: descriptor, callback: {})427+    descriptorMonitor.closeFileDescriptor()428+    #expect(fcntl(descriptor, F_GETFD) == -1)429+  }430+}

view file

Tests/HavenTests/ModelsTests.swift+71 −0
@@ -0,0 +1,71 @@1+import Foundation2+import Testing3+4+@testable import Haven5+6+@Suite("API models")7+struct ModelsTests {8+  @Test("Wallpaper round-trips through Codable")9+  func wallpaperCodableRoundTrip() throws {10+    let wallpaper = makeWallpaper()11+12+    let data = try JSONEncoder().encode(wallpaper)13+    let decoded = try JSONDecoder().decode(Wallpaper.self, from: data)14+15+    #expect(decoded.id == wallpaper.id)16+    #expect(decoded.url == wallpaper.url)17+    #expect(decoded.path == wallpaper.path)18+    #expect(decoded.resolution == wallpaper.resolution)19+    #expect(decoded.file_size == wallpaper.file_size)20+    #expect(decoded.thumbs == wallpaper.thumbs)21+  }22+23+  @Test("Wallpaper identity equality is based on Wallhaven ID")24+  func wallpaperEqualityUsesID() {25+    let first = makeWallpaper(id: "same", path: "https://example.com/first.jpg")26+    let second = makeWallpaper(id: "same", path: "https://example.com/second.jpg")27+    let third = makeWallpaper(id: "different")28+29+    #expect(first == second)30+    #expect(first != third)31+  }32+33+  @Test("Optional API metadata may be absent")34+  func responseWithoutMetadataDecodes() throws {35+    let data = try JSONSerialization.data(withJSONObject: [36+      "data": [37+        [38+          "id": "abc123",39+          "url": "https://wallhaven.cc/w/abc123",40+          "path": "https://w.wallhaven.cc/full/ab/wallhaven-abc123.jpg",41+          "resolution": "1920x1080",42+          "purity": "sfw",43+          "thumbs": [44+            "large": "https://example.com/large.jpg",45+            "original": "https://example.com/original.jpg",46+            "small": "https://example.com/small.jpg",47+          ],48+        ]49+      ]50+    ])51+52+    let decoded = try JSONDecoder().decode(WallhavenResponse.self, from: data)53+54+    #expect(decoded.data.count == 1)55+    #expect(decoded.data[0].file_size == nil)56+    #expect(decoded.meta == nil)57+  }58+59+  @Test("Metadata uses the API's snake-case keys")60+  func metadataDecodesSnakeCaseKeys() throws {61+    let data = Data(62+      #"{"data":[],"meta":{"current_page":3,"last_page":7,"per_page":24,"total":150}}"#.utf8)63+64+    let decoded = try JSONDecoder().decode(WallhavenResponse.self, from: data)65+66+    #expect(decoded.meta?.current_page == 3)67+    #expect(decoded.meta?.last_page == 7)68+    #expect(decoded.meta?.per_page == 24)69+    #expect(decoded.meta?.total == 150)70+  }71+}

view file

Tests/HavenTests/PlatformCoverageTests.swift+142 −0
@@ -0,0 +1,142 @@1+import AppKit2+import Testing3+4+@testable import Haven5+6+@MainActor7+private final class PopoverStub: NSPopover {8+  var stubShown = false9+  var showCount = 010+  var closeCount = 011+  override var isShown: Bool { stubShown }12+  override func show(13+    relativeTo positioningRect: NSRect, of positioningView: NSView,14+    preferredEdge: NSRectEdge15+  ) {16+    stubShown = true17+    showCount += 118+  }19+  override func performClose(_ sender: Any?) {20+    stubShown = false21+    closeCount += 122+  }23+}24+25+@MainActor26+private final class OpenPanelStub: NSOpenPanel {27+  var completion: ((NSApplication.ModalResponse) -> Void)?28+29+  override func begin(30+    completionHandler handler: @escaping (NSApplication.ModalResponse) -> Void31+  ) {32+    completion = handler33+  }34+}35+36+@Suite("AppKit adapters", .serialized)37+@MainActor38+struct PlatformCoverageTests {39+  @Test("Application delegate creates the status item and menu controller")40+  func appDelegateLaunch() {41+    let delegate = AppDelegate()42+43+    delegate.applicationDidFinishLaunching(44+      Notification(name: NSApplication.didFinishLaunchingNotification)45+    )46+47+    #expect(delegate.statusItem != nil)48+    #expect(delegate.menuController != nil)49+    #expect(delegate.statusItem.button?.image != nil)50+  }51+52+  @Test("Menu controller exercises popover and modal lifecycle")53+  func menuControllerLifecycle() {54+    let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)55+    let popover = PopoverStub()56+    let controller = MenuController(statusItem: statusItem, popover: popover)57+58+    #expect(MenuController.shared === controller)59+    controller.beginModalPresentation()60+    #expect(controller.popover.behavior == .applicationDefined)61+    controller.endModalPresentation()62+    #expect(controller.popover.behavior == .transient)63+64+    popover.stubShown = true65+    controller.endModalPresentation()66+    controller.eventMonitor?.handler(nil)67+    popover.stubShown = true68+    controller.togglePopover(nil)69+70+    controller.showPopover(sender: nil)71+    controller.togglePopover(nil)72+    controller.togglePopover(nil)73+    controller.closePopover(sender: nil)74+    controller.togglePopover(nil)75+    controller.closePopover(sender: nil)76+    #expect(popover.showCount > 0)77+    #expect(popover.closeCount > 0)78+79+    NSStatusBar.system.removeStatusItem(statusItem)80+  }81+82+  @Test("Event monitor start and stop are idempotent")83+  func eventMonitorLifecycle() {84+    var eventSeen = false85+    let monitor = EventMonitor(mask: [.leftMouseDown]) { _ in eventSeen = true }86+87+    monitor.start()88+    monitor.stop()89+    monitor.stop()90+91+    #expect(!eventSeen)92+  }93+94+  @Test("Screen manager exposes AppKit screen metadata")95+  func screenManager() {96+    let manager = ScreenManager.shared97+    #expect(manager.screens == NSScreen.screens)98+    if let screen = manager.screens.first {99+      #expect(manager.screenName(for: screen) == screen.localizedName)100+    }101+  }102+103+  @Test("Application bootstrap wires the delegate and optional event loop")104+  func applicationBootstrap() {105+    let delegate = AppDelegate()106+    var received: AppDelegate?107+    var runs = 0108+    HavenApplication.start(109+      delegate: delegate, shouldRun: false,110+      setDelegate: { received = $0 }, run: { runs += 1 })111+    #expect(received === delegate)112+    #expect(runs == 0)113+114+    HavenApplication.start(115+      delegate: delegate, shouldRun: true,116+      setDelegate: { received = $0 }, run: { runs += 1 })117+    #expect(runs == 1)118+  }119+120+  @Test("Folder panel adapter configures directory selection")121+  func folderPanel() {122+    let directory = URL(fileURLWithPath: "/tmp")123+    let configured = AppState.makeFolderPanel(at: directory)124+    #expect(!configured.canChooseFiles)125+    #expect(configured.canChooseDirectories)126+    #expect(configured.canCreateDirectories)127+    #expect(!configured.allowsMultipleSelection)128+    #expect(configured.directoryURL == directory)129+  }130+131+  @Test("Folder picker adapter forwards cancellation")132+  func folderPicker() {133+    let panel = OpenPanelStub()134+    var result: URL? = URL(fileURLWithPath: "/sentinel")135+136+    AppState.presentFolderPicker(panel: panel) { result = $0 }137+    #expect(panel.completion != nil)138+139+    panel.completion?(.cancel)140+    #expect(result == nil)141+  }142+}

view file

Tests/HavenTests/TestFixtures.swift+34 −0
@@ -0,0 +1,34 @@1+import Foundation2+3+@testable import Haven4+5+func makeWallpaper(6+  id: String = "abc123",7+  path: String = "https://w.wallhaven.cc/full/ab/wallhaven-abc123.jpg"8+) -> Wallpaper {9+  Wallpaper(10+    id: id,11+    url: "https://wallhaven.cc/w/\(id)",12+    path: path,13+    resolution: "1920x1080",14+    purity: "sfw",15+    file_size: 1_024,16+    thumbs: Wallpaper.Thumbs(17+      large: "https://th.wallhaven.cc/lg/\(id).jpg",18+      original: "https://th.wallhaven.cc/orig/\(id).jpg",19+      small: "https://th.wallhaven.cc/small/\(id).jpg"20+    )21+  )22+}23+24+func encodedSearchResponse(25+  wallpapers: [Wallpaper] = [makeWallpaper()],26+  currentPage: Int = 1,27+  lastPage: Int = 228+) throws -> Data {29+  let response = WallhavenResponse(30+    data: wallpapers,31+    meta: Meta(current_page: currentPage, last_page: lastPage, per_page: 24, total: 48)32+  )33+  return try JSONEncoder().encode(response)34+}

view file

Tests/HavenTests/ViewCoverageTests.swift+238 −0
@@ -0,0 +1,238 @@1+import AppKit2+import Foundation3+import SwiftUI4+import Testing5+6+@testable import Haven7+8+@Suite("SwiftUI view construction", .serialized)9+@MainActor10+struct ViewCoverageTests {11+  private func render<V: View>(_ view: V) {12+    let hostingView = NSHostingView(rootView: view)13+    hostingView.frame = NSRect(x: 0, y: 0, width: 450, height: 640)14+    hostingView.layoutSubtreeIfNeeded()15+    hostingView.displayIfNeeded()16+  }17+18+  @Test("Main and explore views render every feed selection")19+  func mainAndExploreViews() async {20+    let search = SearchStub()21+    search.results = Array(22+      repeating: .success(([], Meta(current_page: 1, last_page: 1, per_page: 24, total: 0))),23+      count: 20)24+    let state = AppState(api: search, observeChanges: false)25+    for tab in [AppTab.explore, .local, .favorites, .history, .settings] {26+      state.activeTab = tab27+      render(MainView(appState: state))28+    }29+30+    let explore = ExploreView(appState: state)31+    render(explore.resolutionOption("Any"))32+    render(explore.ratioOption("Any"))33+    await explore.refresh()34+    explore.refreshAction()35+    await explore.loadMore()36+    explore.loadMoreAction()37+    for mode in FeedMode.allCases {38+      state.feedMode = mode39+      render(explore)40+      explore.select(mode)41+      render(42+        FeedModePill(mode: mode, isActive: true) {43+          state.feedMode = mode44+        })45+      render(FeedModePill(mode: mode, isActive: false) {})46+    }47+  }48+49+  @Test("Grid, item, favorites, and history states render")50+  func remoteWallpaperViews() async {51+    let search = SearchStub()52+    search.results = [.success(([], nil))]53+    let wallpaperManager = WallpaperStub()54+    let state = AppState(55+      api: search, wallpaperManager: wallpaperManager, observeChanges: false)56+    let wallpaper = makeWallpaper(id: "view")57+    var actionCount = 058+59+    let item = WallpaperItem(60+      wallpaper: wallpaper, action: { actionCount += 1 }, maxAttempts: 1,61+      retryDelayNanoseconds: 0)62+    render(item)63+    render(item.thumbnail(for: .empty))64+    render(item.thumbnail(for: .success(Image(systemName: "photo"))))65+    render(item.thumbnail(for: .failure(URLError(.badServerResponse))))66+    await item.retryAfterFailure()67+    let exhausted = WallpaperItem(68+      wallpaper: wallpaper, action: {}, attempt: 1, maxAttempts: 1,69+      retryDelayNanoseconds: 0)70+    render(exhausted.thumbnail(for: .failure(URLError(.badServerResponse))))71+    await exhausted.retryAfterFailure()72+    item.toggleFavorite()73+    item.toggleFavorite()74+    item.action()75+    #expect(actionCount == 1)76+77+    render(WallpaperGrid(wallpapers: [], appState: state, onEndReached: nil))78+    let grid = WallpaperGrid(wallpapers: [wallpaper], appState: state, onEndReached: {})79+    render(grid)80+    state.wallpapers = [wallpaper]81+    state.isLoading = true82+    render(grid)83+    state.isLoading = false84+    await grid.set(wallpaper)85+    await grid.refresh()86+87+    state.favoritesManager.favorites = []88+    render(FavoritesView(appState: state))89+    state.favoritesManager.favorites = [wallpaper]90+    render(FavoritesView(appState: state))91+92+    state.historyManager.history = []93+    render(HistoryView(appState: state))94+    state.historyManager.history = [wallpaper]95+    let history = HistoryView(appState: state)96+    render(history)97+    history.clearHistory()98+    state.historyManager.history = [wallpaper]99+    render(history)100+  }101+102+  @Test("Local gallery renders enabled, disabled, empty, and populated states")103+  func localViews() async throws {104+    let defaults = UserDefaults.standard105+    let oldEnabled = defaults.object(forKey: "saveToFolderEnabled")106+    let oldPath = defaults.string(forKey: "saveFolderPath")107+    defer {108+      if let oldEnabled {109+        defaults.set(oldEnabled, forKey: "saveToFolderEnabled")110+      } else {111+        defaults.removeObject(forKey: "saveToFolderEnabled")112+      }113+      if let oldPath {114+        defaults.set(oldPath, forKey: "saveFolderPath")115+      } else {116+        defaults.removeObject(forKey: "saveFolderPath")117+      }118+    }119+120+    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)121+    defer { try? FileManager.default.removeItem(at: root) }122+    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)123+    let fileURL = root.appendingPathComponent("local.png")124+    let png = try #require(125+      Data(126+        base64Encoded:127+          "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="128+      )129+    )130+    try png.write(to: fileURL)131+132+    defaults.set(false, forKey: "saveToFolderEnabled")133+    defaults.set(root.path, forKey: "saveFolderPath")134+    let wallpaperManager = WallpaperStub()135+    let state = AppState(wallpaperManager: wallpaperManager, observeChanges: false)136+    state.saveFolderPath = root.path137+    state.saveToFolderEnabled = false138+    let manager = LocalWallpapersManager()139+    let localView = LocalView(appState: state, localManager: manager)140+    render(localView)141+142+    state.saveToFolderEnabled = true143+    render(localView)144+145+    let local = LocalWallpaper(146+      id: fileURL.path,147+      url: fileURL,148+      filename: fileURL.lastPathComponent,149+      resolution: "2x2",150+      fileSize: 100,151+      createdAt: Date(),152+      modifiedAt: Date()153+    )154+    manager.isLoading = true155+    manager.wallpapers = []156+    render(localView)157+    manager.isLoading = false158+    state.saveToFolderEnabled = false159+    render(localView)160+    state.saveToFolderEnabled = true161+    manager.wallpapers = []162+    render(localView)163+    manager.wallpapers = [local]164+    render(localView)165+    var setCount = 0166+    var deleteCount = 0167+    render(168+      LocalWallpaperGrid(169+        wallpapers: [local],170+        onSetWallpaper: { _ in setCount += 1 },171+        onDeleteWallpaper: { _ in deleteCount += 1 }172+      ))173+    let item = LocalWallpaperItem(174+      wallpaper: local,175+      onSet: { setCount += 1 },176+      onDelete: { deleteCount += 1 }177+    )178+    render(item)179+    item.onSet()180+    item.onDelete()181+    #expect(setCount == 1)182+    #expect(deleteCount == 1)183+    render(184+      LocalWallpaperItem(185+        wallpaper: local, onSet: {}, onDelete: {},186+        initialImage: NSImage(size: NSSize(width: 1, height: 1)), isLoadingImage: false))187+    render(188+      LocalWallpaperItem(189+        wallpaper: local, onSet: {}, onDelete: {}, initialImage: nil,190+        isLoadingImage: false))191+    localView.scan()192+    await localView.set(local)193+    localView.requestDelete(local)194+    render(localView.deleteMessage(local))195+    render(localView.deleteActions(local))196+    localView.cancelDelete()197+    localView.delete(local)198+    render(LoadingState())199+  }200+201+  @Test("Settings and reusable components render and invoke actions")202+  func settingsAndComponents() {203+    var quitCalled = false204+    let state = AppState(205+      chooseFolder: { _, completion in completion(nil) },206+      terminateApplication: { quitCalled = true }, observeChanges: false)207+    state.saveToFolderEnabled = false208+    let settings = SettingsView(appState: state)209+    render(settings)210+    state.saveToFolderEnabled = true211+    render(settings)212+    settings.selectFolder()213+    settings.quit()214+    #expect(quitCalled)215+216+    render(EmptyState(icon: "photo", text: "Empty"))217+    var tab = AppTab.explore218+    let tabButton = TabButton(219+      icon: "heart", tab: .favorites,220+      activeTab: Binding(get: { tab }, set: { tab = $0 }))221+    render(tabButton)222+    tabButton.select()223+    #expect(tab == .favorites)224+225+    var invoked = false226+    let button = GlossyButton(title: "Run", icon: "play", action: { invoked = true })227+    render(button)228+    button.action()229+    #expect(invoked)230+    render(GlossyButton(title: "Run", icon: nil, action: {}))231+232+    render(233+      VisualEffectView(234+        material: .headerView,235+        blendingMode: .behindWindow236+      ))237+  }238+}

view file

Tests/HavenTests/WallhavenAPITests.swift+164 −0
@@ -0,0 +1,164 @@1+import Foundation2+import Testing3+4+@testable import Haven5+6+final class MockURLProtocol: URLProtocol, @unchecked Sendable {7+  nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?8+9+  override class func canInit(with request: URLRequest) -> Bool { true }10+11+  override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }12+13+  override func startLoading() {14+    guard let handler = Self.requestHandler else {15+      client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))16+      return17+    }18+19+    do {20+      let (response, data) = try handler(request)21+      client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)22+      client?.urlProtocol(self, didLoad: data)23+      client?.urlProtocolDidFinishLoading(self)24+    } catch {25+      client?.urlProtocol(self, didFailWithError: error)26+    }27+  }28+29+  override func stopLoading() {}30+}31+32+@Suite("Wallhaven API", .serialized)33+@MainActor34+struct WallhavenAPITests {35+  private func makeAPI() -> WallhavenAPI {36+    let configuration = URLSessionConfiguration.ephemeral37+    configuration.protocolClasses = [MockURLProtocol.self]38+    return WallhavenAPI(session: URLSession(configuration: configuration))39+  }40+41+  private func response(for request: URLRequest, statusCode: Int = 200) -> HTTPURLResponse {42+    HTTPURLResponse(43+      url: request.url!,44+      statusCode: statusCode,45+      httpVersion: "HTTP/1.1",46+      headerFields: ["Content-Type": "application/json"]47+    )!48+  }49+50+  @Test("Search sends every selected filter and decodes the response")51+  func searchBuildsExpectedRequest() async throws {52+    MockURLProtocol.requestHandler = { request in53+      let url = try #require(request.url)54+      let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))55+      let items = Dictionary(56+        uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") })57+58+      #expect(components.path == "/api/v1/search")59+      #expect(items["q"] == "mountain lake")60+      #expect(items["categories"] == "101")61+      #expect(items["purity"] == "110")62+      #expect(items["sorting"] == "toplist")63+      #expect(items["resolutions"] == "1920x1080,3840x2160")64+      #expect(items["ratios"] == "16x9,21x9")65+      #expect(items["limit"] == "12")66+      #expect(items["page"] == "3")67+      return (response(for: request), try encodedSearchResponse(currentPage: 3, lastPage: 9))68+    }69+70+    let (wallpapers, meta) = try await makeAPI().searchWallpapers(71+      query: "mountain lake",72+      categories: "101",73+      purity: "110",74+      sorting: .toplist,75+      resolutions: ["1920x1080", "3840x2160"],76+      ratios: ["16x9", "21x9"],77+      limit: 12,78+      page: 379+    )80+81+    #expect(wallpapers == [makeWallpaper()])82+    #expect(meta?.current_page == 3)83+    #expect(meta?.last_page == 9)84+  }85+86+  @Test("Empty filters are omitted and defaults are stable")87+  func emptyFiltersAreOmitted() async throws {88+    MockURLProtocol.requestHandler = { request in89+      let url = try #require(request.url)90+      let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))91+      let items = Dictionary(92+        uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") })93+94+      #expect(items["categories"] == "111")95+      #expect(items["purity"] == "100")96+      #expect(items["sorting"] == "date_added")97+      #expect(items["limit"] == "24")98+      #expect(items["page"] == "1")99+      #expect(items["resolutions"] == nil)100+      #expect(items["ratios"] == nil)101+      return (response(for: request), try encodedSearchResponse())102+    }103+104+    _ = try await makeAPI().searchWallpapers(query: "")105+  }106+107+  @Test("Random mode maps to the random sorting query")108+  func randomSortingIsEncoded() async throws {109+    MockURLProtocol.requestHandler = { request in110+      let url = try #require(request.url)111+      let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))112+      #expect(components.queryItems?.first(where: { $0.name == "sorting" })?.value == "random")113+      return (response(for: request), try encodedSearchResponse())114+    }115+116+    _ = try await makeAPI().searchWallpapers(query: "", sorting: .random)117+  }118+119+  @Test("Non-success HTTP responses become API errors")120+  func nonSuccessStatusThrowsAPIError() async {121+    MockURLProtocol.requestHandler = { request in122+      (response(for: request, statusCode: 503), Data("unavailable".utf8))123+    }124+125+    do {126+      _ = try await makeAPI().searchWallpapers(query: "")127+      Issue.record("Expected a WallhavenError.apiError")128+    } catch WallhavenError.apiError {129+      // Expected.130+    } catch {131+      Issue.record("Unexpected error: \(error)")132+    }133+  }134+135+  @Test("Malformed success payloads become decoding errors")136+  func malformedPayloadThrowsDecodingError() async {137+    MockURLProtocol.requestHandler = { request in138+      (response(for: request), Data(#"{"unexpected":true}"#.utf8))139+    }140+141+    do {142+      _ = try await makeAPI().searchWallpapers(query: "")143+      Issue.record("Expected a WallhavenError.decodingError")144+    } catch WallhavenError.decodingError {145+      // Expected.146+    } catch {147+      Issue.record("Unexpected error: \(error)")148+    }149+  }150+151+  @Test("Transport failures propagate to the caller")152+  func transportFailurePropagates() async {153+    MockURLProtocol.requestHandler = { _ in throw URLError(.notConnectedToInternet) }154+155+    do {156+      _ = try await makeAPI().searchWallpapers(query: "")157+      Issue.record("Expected a transport error")158+    } catch let error as URLError {159+      #expect(error.code == .notConnectedToInternet)160+    } catch {161+      Issue.record("Unexpected error: \(error)")162+    }163+  }164+}

view file