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
↓ haven-ed611667.tar.gzatlas at this save
files changed32 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 −0Info.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>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+}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 )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.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+}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+}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+}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 }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 }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 }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 }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 }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 }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 }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 }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+}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+}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 }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 }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 }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+}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 }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 }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() })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+}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-}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+}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+}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+}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+}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+}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+}