987de19ffe51

fix feed pill hit area, name wallhaven failures by cause, drop duplicate launch searches (v0.6.2)

time
2026-09-19 13:49 UTC · 3 weeks ago
machine
koh2
agent
deepseek/deepseek-flash
session
fix pill clicks and error banner
parent
300c20aa6f13
6 files Info.plist+2 −2Sources/Haven/API/WallhavenAPI.swift+39 −5Sources/Haven/UI/AppState.swift+8 −1Sources/Haven/UI/Views/MainView.swift+5 −0Tests/HavenTests/AppStateTests.swift+23 −0Tests/HavenTests/WallhavenAPITests.swift+60 −1
Info.plist+2 −2
@@ -9,9 +9,9 @@99     <key>CFBundleName</key>1010     <string>Haven</string>1111     <key>CFBundleShortVersionString</key>12-    <string>0.6.1</string>12+    <string>0.6.2</string>1313     <key>CFBundleVersion</key>14-    <string>7</string>14+    <string>8</string>1515     <key>LSMinimumSystemVersion</key>1616     <string>13.0</string>1717     <key>LSUIElement</key>

view file

Sources/Haven/API/WallhavenAPI.swift+39 −5
@@ -1,11 +1,29 @@11 import Foundation22 3-enum WallhavenError: Error {3+enum WallhavenError: Error, LocalizedError {44   case invalidURL55   case networkError(Error)66   case decodingError(Error)77   case noData88   case apiError(String)9+10+  /// These strings reach the user verbatim through the error banner. Without11+  /// this, Swift bridges the enum to the opaque "Haven.WallhavenError error N"12+  /// that says nothing about what actually failed.13+  var errorDescription: String? {14+    switch self {15+    case .invalidURL:16+      return "That isn't a valid Wallhaven URL."17+    case .networkError(let error):18+      return "Couldn't reach Wallhaven: \(error.localizedDescription)"19+    case .decodingError:20+      return "Wallhaven sent a response Haven couldn't read."21+    case .noData:22+      return "Wallhaven returned no data."23+    case .apiError(let message):24+      return message25+    }26+  }927 }1028 1129 /// Sort order for Wallhaven search results. Maps to the `sorting` query@@ -62,6 +80,20 @@6280     return try await performSearch(queryItems: queryItems)6381   }6482 83+  /// Wallhaven is fronted by Cloudflare, so when a search fails the status code84+  /// is the only thing that says whether the site is down (5xx), Haven has hit85+  /// its rate limit (429), or the request itself was rejected.86+  static func message(forStatus status: Int) -> String {87+    switch status {88+    case 429:89+      return "Wallhaven rate limit reached (HTTP 429). Try again in a minute."90+    case 500...:91+      return "Wallhaven is unavailable right now (HTTP \(status))."92+    default:93+      return "Wallhaven rejected the request (HTTP \(status))."94+    }95+  }96+6597   private func performSearch(queryItems: [URLQueryItem]) async throws -> ([Wallpaper], Meta?) {6698     var urlComponents = URLComponents(6799       url: Configuration.baseURL.appendingPathComponent("search"), resolvingAgainstBaseURL: true)!@@ -71,10 +103,12 @@71103 72104     let (data, response) = try await session.data(from: url)73105 74-    guard let httpResponse = response as? HTTPURLResponse,75-      (200...299).contains(httpResponse.statusCode)76-    else {77-      throw WallhavenError.apiError("Server returned an error")106+    guard let httpResponse = response as? HTTPURLResponse else {107+      throw WallhavenError.apiError("Wallhaven returned an unexpected response.")108+    }109+110+    guard (200...299).contains(httpResponse.statusCode) else {111+      throw WallhavenError.apiError(Self.message(forStatus: httpResponse.statusCode))78112     }79113 80114     do {

view file

Sources/Haven/UI/AppState.swift+8 −1
@@ -153,20 +153,27 @@153153 154154     guard observeChanges else { return }155155 156-    // Debounce search156+    // `@Published` replays its current value to every new subscriber, so157+    // without `dropFirst` these three pipelines each launch a search as soon158+    // as an AppState exists — three identical requests at startup, before the159+    // popover has ever been opened. The initial load comes from the views'160+    // `onAppear` instead.157161     $searchText162+      .dropFirst()158163       .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)159164       .sink { [weak self] _ in self?.scheduleSearch() }160165       .store(in: &cancellables)161166 162167     // Observe filters163168     $categories169+      .dropFirst()164170       .map { _ in () }165171       .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)166172       .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }167173       .store(in: &cancellables)168174 169175     $purity176+      .dropFirst()170177       .map { _ in () }171178       .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)172179       .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }

view file

Sources/Haven/UI/Views/MainView.swift+5 −0
@@ -245,6 +245,11 @@245245         RoundedRectangle(cornerRadius: 10)246246           .stroke(Color.secondary.opacity(isActive ? 0 : 0.45), lineWidth: 1)247247       )248+      // A `.plain` button hit-tests only its label's content (icon and text),249+      // not the background or flexible frame around them, so without an250+      // explicit shape the padded interior of the pill swallows clicks that251+      // look like they land on it.252+      .contentShape(Rectangle())248253     }249254     .buttonStyle(.plain)250255   }

view file

Tests/HavenTests/AppStateTests.swift+23 −0
@@ -371,6 +371,29 @@371371     #expect(search.requests.count >= 5)372372   }373373 374+  @Test("Initialization alone launches no search")375+  func initializationLaunchesNoSearch() async {376+    let search = SearchStub()377+    search.results = Array(repeating: .success(([], nil)), count: 8)378+    let state = AppState(379+      api: search, chooseFolder: { _, completion in completion(nil) },380+      terminateApplication: {}, observeChanges: true)381+382+    // `@Published` replays its current value to every new subscriber, so the383+    // debounced pipelines would otherwise turn that replay into searches that384+    // fire before the popover is ever opened.385+    try? await Task.sleep(nanoseconds: 1_200_000_000)386+    #expect(search.requests.isEmpty)387+388+    // The instance is still alive and its pipelines still work, so the389+    // assertion above is not just observing a deallocated state object.390+    state.scheduleSearch()391+    for _ in 0..<100 where search.requests.isEmpty {392+      try? await Task.sleep(nanoseconds: 20_000_000)393+    }394+    #expect(search.requests.count == 1)395+  }396+374397   @Test("Resolution and ratio changes are debounced like other filters")375398   func debouncedResolutionAndRatio() async {376399     let search = SearchStub()

view file

Tests/HavenTests/WallhavenAPITests.swift+60 −1
@@ -4,7 +4,7 @@44 @testable import Haven55 66 final class MockURLProtocol: URLProtocol, @unchecked Sendable {7-  nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?7+  nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (URLResponse, Data))?88 99   override class func canInit(with request: URLRequest) -> Bool { true }1010 @@ -130,6 +130,65 @@130130     } catch {131131       Issue.record("Unexpected error: \(error)")132132     }133+  }134+135+  @Test("Failures name the cause instead of an opaque error code")136+  func failuresDescribeTheirCause() async {137+    let cases: [(Int, String)] = [138+      (521, "Wallhaven is unavailable right now (HTTP 521)."),139+      (429, "Wallhaven rate limit reached (HTTP 429). Try again in a minute."),140+      (404, "Wallhaven rejected the request (HTTP 404)."),141+    ]142+143+    for (status, expected) in cases {144+      MockURLProtocol.requestHandler = { request in145+        (response(for: request, statusCode: status), Data())146+      }147+148+      do {149+        _ = try await makeAPI().searchWallpapers(query: "")150+        Issue.record("Expected HTTP \(status) to throw")151+      } catch WallhavenError.apiError(let message) {152+        #expect(message == expected)153+      } catch {154+        Issue.record("Unexpected error: \(error)")155+      }156+    }157+  }158+159+  @Test("A body-less response reports an unexpected response")160+  func nonHTTPResponseDescribesItself() async {161+    MockURLProtocol.requestHandler = { request in162+      (163+        URLResponse(164+          url: request.url!, mimeType: nil, expectedContentLength: 0, textEncodingName: nil),165+        Data()166+      )167+    }168+169+    do {170+      _ = try await makeAPI().searchWallpapers(query: "")171+      Issue.record("Expected a non-HTTP response to throw")172+    } catch WallhavenError.apiError(let message) {173+      #expect(message == "Wallhaven returned an unexpected response.")174+    } catch {175+      Issue.record("Unexpected error: \(error)")176+    }177+  }178+179+  @Test("Every error case carries a human-readable description")180+  func errorCasesLocalizeTheirDescriptions() {181+    #expect(WallhavenError.invalidURL.localizedDescription == "That isn't a valid Wallhaven URL.")182+    #expect(WallhavenError.noData.localizedDescription == "Wallhaven returned no data.")183+    #expect(184+      WallhavenError.apiError("Wallhaven is unavailable right now (HTTP 521).").localizedDescription185+        == "Wallhaven is unavailable right now (HTTP 521).")186+    #expect(187+      WallhavenError.networkError(URLError(.notConnectedToInternet)).localizedDescription188+        .hasPrefix("Couldn't reach Wallhaven: "))189+    #expect(190+      WallhavenError.decodingError(URLError(.cannotParseResponse)).localizedDescription191+        == "Wallhaven sent a response Haven couldn't read.")133192   }134193 135194   @Test("Malformed success payloads become decoding errors")

view file