493d07ba5c1a

show wallhaven outages as status in explore tab instead of a broken-looking grid (v0.7.0)

time
2026-09-19 14:18 UTC · 3 weeks ago
machine
koh2
agent
deepseek/deepseek-flash
session
fix pill clicks and error banner
parent
1fabc2abdbf1
9 files Info.plist+2 −2README.md+3 −0Sources/Haven/API/WallhavenAPI.swift+12 −6Sources/Haven/UI/AppState.swift+31 −1Sources/Haven/UI/Views/MainView.swift+43 −3Sources/Haven/Utils/Configuration.swift+3 −0Tests/HavenTests/AppStateTests.swift+43 −0Tests/HavenTests/ViewCoverageTests.swift+14 −0Tests/HavenTests/WallhavenAPITests.swift+21 −20
Info.plist+2 −2
@@ -9,9 +9,9 @@99     <key>CFBundleName</key>1010     <string>Haven</string>1111     <key>CFBundleShortVersionString</key>12-    <string>0.6.3</string>12+    <string>0.7.0</string>1313     <key>CFBundleVersion</key>14-    <string>9</string>14+    <string>10</string>1515     <key>LSMinimumSystemVersion</key>1616     <string>13.0</string>1717     <key>LSUIElement</key>

view file

README.md+3 −0
@@ -10,6 +10,9 @@1010 - **Explore** — search Wallhaven with live filtering by category1111   (General / Anime / People), purity (SFW / Sketchy), resolution, and aspect1212   ratio. Infinite scroll loads more as you go.13+- **Outage aware** — when Wallhaven's own servers are down, the Explore tab14+  explains that where the grid would be, links to Wallhaven's status page, and15+  offers a retry, so an upstream outage never reads as Haven failing.1316 - **Set instantly** — click a wallpaper to set it on your active display.1417 - **Local library** — wallpapers you download are saved to a folder of your1518   choice and browsable offline. Unreadable or broken image files are skipped

view file

Sources/Haven/API/WallhavenAPI.swift+12 −6
@@ -6,6 +6,10 @@66   case decodingError(Error)77   case noData88   case apiError(String)9+  /// Wallhaven's own server is down or serving its maintenance page. Kept10+  /// apart from `apiError` because the fault is upstream, not in the request,11+  /// and the UI says so differently.12+  case unavailable(String)913 1014   /// These strings reach the user verbatim through the error banner. Without1115   /// this, Swift bridges the enum to the opaque "Haven.WallhavenError error N"@@ -22,6 +26,8 @@2226       return "Wallhaven returned no data."2327     case .apiError(let message):2428       return message29+    case .unavailable(let message):30+      return message2531     }2632   }2733 }@@ -83,14 +89,14 @@8389   /// Wallhaven is fronted by Cloudflare, so when a search fails the status code8490   /// is the only thing that says whether the site is down (5xx), Haven has hit8591   /// its rate limit (429), or the request itself was rejected.86-  static func message(forStatus status: Int) -> String {92+  static func failure(forStatus status: Int) -> WallhavenError {8793     switch status {8894     case 429:89-      return "Wallhaven rate limit reached (HTTP 429). Try again in a minute."95+      return .apiError("Wallhaven rate limit reached (HTTP 429). Try again in a minute.")9096     case 500...:91-      return "Wallhaven is unavailable right now (HTTP \(status))."97+      return .unavailable("Wallhaven is unavailable right now (HTTP \(status)).")9298     default:93-      return "Wallhaven rejected the request (HTTP \(status))."99+      return .apiError("Wallhaven rejected the request (HTTP \(status)).")94100     }95101   }96102 @@ -114,14 +120,14 @@114120     }115121 116122     guard (200...299).contains(httpResponse.statusCode) else {117-      throw WallhavenError.apiError(Self.message(forStatus: httpResponse.statusCode))123+      throw Self.failure(forStatus: httpResponse.statusCode)118124     }119125 120126     // Wallhaven answers API calls with its HTML status page ("taking a little121127     // nap") while its server is down, under a 200 status. Saying so beats122128     // reporting a decoding failure for a body that was never going to decode.123129     guard !Self.isStatusPage(response: httpResponse) else {124-      throw WallhavenError.apiError(130+      throw WallhavenError.unavailable(125131         "Wallhaven is offline right now — it's serving its status page.")126132     }127133 

view file

Sources/Haven/UI/AppState.swift+31 −1
@@ -44,6 +44,24 @@4444   }4545 }4646 47+/// Wallhaven being down, as opposed to Haven failing. Surfaced in the Explore48+/// tab where the wallpapers would be, because an empty grid otherwise reads as49+/// Haven being broken.50+struct WallhavenStatus: Equatable {51+  let title: String52+  let detail: String53+54+  /// Only `WallhavenError.unavailable` is an outage. A rejected request, a55+  /// transport failure, or a decoding change is Haven's problem to report.56+  init?(error: Error) {57+    guard let error = error as? WallhavenError,58+      case .unavailable(let detail) = error59+    else { return nil }60+    self.title = "Wallhaven is offline"61+    self.detail = detail62+  }63+}64+4765 @MainActor4866 class AppState: ObservableObject {4967   // Navigation@@ -61,6 +79,9 @@6179   @Published var selectedRatio: String = ""6280   @Published var isLoading: Bool = false6381   @Published var errorMessage: String?82+  /// Set while Wallhaven itself is down; the Explore tab explains it in place83+  /// of the grid instead of leaving an unexplained blank area.84+  @Published var wallhavenStatus: WallhavenStatus?6485 6586   // Pagination6687   @Published var canLoadMore = true@@ -280,6 +301,7 @@280301       )281302 282303       if requestGeneration != searchGeneration { return }304+      self.wallhavenStatus = nil283305       if reset {284306         self.wallpapers = newWallpapers285307       } else {@@ -306,7 +328,15 @@306328 307329     } catch {308330       if reset && requestGeneration == searchGeneration {309-        self.errorMessage = error.localizedDescription331+        if let status = WallhavenStatus(error: error) {332+          wallhavenStatus = status333+          // The Explore tab shows the outage in place of the empty grid, so334+          // the banner would only repeat it. With results still on screen335+          // there is no panel, so the banner stays the only notice.336+          errorMessage = wallpapers.isEmpty ? nil : status.detail337+        } else {338+          errorMessage = error.localizedDescription339+        }310340       }311341     }312342 

view file

Sources/Haven/UI/Views/MainView.swift+43 −3
@@ -199,11 +199,14 @@199199 200200       Divider()201201 202-      WallpaperGrid(203-        wallpapers: appState.wallpapers, appState: appState, onEndReached: loadMoreAction)202+      if let status = appState.wallhavenStatus, appState.wallpapers.isEmpty {203+        WallhavenStatusPanel(status: status, retry: refreshAction)204+      } else {205+        WallpaperGrid(206+          wallpapers: appState.wallpapers, appState: appState, onEndReached: loadMoreAction)207+      }204208     }205209   }206-207210   func resolutionOption(_ resolution: String) -> some View { Text(resolution).tag(resolution) }208211   func ratioOption(_ ratio: String) -> some View { Text(ratio).tag(ratio) }209212   func refresh() async { await appState.fetchWallpapers(reset: true) }@@ -213,6 +216,43 @@213216   }214217   func loadMore() async { await appState.loadMore() }215218   func loadMoreAction() { Task { await loadMore() } }219+}220+221+/// Shown in place of the grid while Wallhaven is down, so an empty Haven reads222+/// as Wallhaven's outage rather than as Haven having failed to load anything.223+struct WallhavenStatusPanel: View {224+  let status: WallhavenStatus225+  let retry: () -> Void226+227+  var body: some View {228+    VStack(spacing: 12) {229+      Image(systemName: "icloud.slash")230+        .font(.system(size: 40))231+        .foregroundColor(.orange)232+233+      Text(status.title)234+        .font(.headline)235+236+      Text(status.detail)237+        .font(.callout)238+        .foregroundColor(.secondary)239+        .multilineTextAlignment(.center)240+241+      Text("Nothing is wrong with Haven — these wallpapers come from Wallhaven's servers.")242+        .font(.caption)243+        .foregroundColor(.secondary)244+        .multilineTextAlignment(.center)245+246+      HStack(spacing: 12) {247+        Link("Check Wallhaven's status", destination: Configuration.statusURL)248+          .font(.callout)249+        Button("Try Again", action: retry)250+      }251+      .padding(.top, 4)252+    }253+    .padding(24)254+    .frame(maxWidth: .infinity, maxHeight: .infinity)255+  }216256 }217257 218258 struct FeedModePill: View {

view file

Sources/Haven/Utils/Configuration.swift+3 −0
@@ -2,4 +2,7 @@22 33 struct Configuration {44   static let baseURL = URL(string: "https://wallhaven.cc/api/v1")!5+  /// Wallhaven's front page doubles as its incident status page: while the site6+  /// is down it serves the "taking a little nap" notice there.7+  static let statusURL = URL(string: "https://wallhaven.cc/")!58 }

view file

Tests/HavenTests/AppStateTests.swift+43 −0
@@ -394,6 +394,49 @@394394     #expect(search.requests.count == 1)395395   }396396 397+  @Test("Wallhaven outages become status the Explore tab explains")398+  func outageBecomesWallhavenStatus() async {399+    let outage = WallhavenError.unavailable("Wallhaven is unavailable right now (HTTP 521).")400+    let search = SearchStub()401+    search.results = [402+      .failure(outage),403+      .success(([makeWallpaper(id: "recovered")], nil)),404+      .failure(outage),405+    ]406+    let state = makeState(search: search)407+408+    await state.fetchWallpapers()409+410+    #expect(state.wallhavenStatus?.title == "Wallhaven is offline")411+    #expect(state.wallhavenStatus?.detail == "Wallhaven is unavailable right now (HTTP 521).")412+    // The empty grid carries the explanation, so the banner stays quiet.413+    #expect(state.errorMessage == nil)414+415+    await state.fetchWallpapers()416+417+    #expect(state.wallhavenStatus == nil)418+    #expect(state.wallpapers.map(\.id) == ["recovered"])419+420+    // Refreshing keeps the results on screen, so there is no panel to explain421+    // the outage and the banner has to.422+    await state.fetchWallpapers(refreshing: true)423+424+    #expect(state.wallhavenStatus?.detail == "Wallhaven is unavailable right now (HTTP 521).")425+    #expect(state.errorMessage == "Wallhaven is unavailable right now (HTTP 521).")426+  }427+428+  @Test("Failures that are not a Wallhaven outage stay in the banner")429+  func nonOutageFailureStaysInBanner() async {430+    let search = SearchStub()431+    search.results = [.failure(URLError(.notConnectedToInternet))]432+    let state = makeState(search: search)433+434+    await state.fetchWallpapers()435+436+    #expect(state.wallhavenStatus == nil)437+    #expect(state.errorMessage != nil)438+  }439+397440   @Test("Resolution and ratio changes are debounced like other filters")398441   func debouncedResolutionAndRatio() async {399442     let search = SearchStub()

view file

Tests/HavenTests/ViewCoverageTests.swift+14 −0
@@ -34,6 +34,20 @@3434     explore.refreshAction()3535     await explore.loadMore()3636     explore.loadMoreAction()37+38+    // Outage state: the Explore tab swaps the grid for the status panel, and39+    // the panel itself renders with its retry action wired up.40+    if let status = WallhavenStatus(41+      error: WallhavenError.unavailable("Wallhaven is unavailable right now (HTTP 521)."))42+    {43+      state.wallhavenStatus = status44+      render(explore)45+      let panel = WallhavenStatusPanel(status: status) {}46+      render(panel)47+      panel.retry()48+    }49+    state.wallhavenStatus = nil50+3751     for mode in FeedMode.allCases {3852       state.feedMode = mode3953       render(explore)

view file

Tests/HavenTests/WallhavenAPITests.swift+21 −20
@@ -118,31 +118,14 @@118118     _ = try await makeAPI().searchWallpapers(query: "", sorting: .random)119119   }120120 121-  @Test("Non-success HTTP responses become API errors")122-  func nonSuccessStatusThrowsAPIError() async {123-    MockURLProtocol.requestHandler = { request in124-      (response(for: request, statusCode: 503), Data("unavailable".utf8))125-    }126-127-    do {128-      _ = try await makeAPI().searchWallpapers(query: "")129-      Issue.record("Expected a WallhavenError.apiError")130-    } catch WallhavenError.apiError {131-      // Expected.132-    } catch {133-      Issue.record("Unexpected error: \(error)")134-    }135-  }136-137121   @Test("Failures name the cause instead of an opaque error code")138122   func failuresDescribeTheirCause() async {139-    let cases: [(Int, String)] = [140-      (521, "Wallhaven is unavailable right now (HTTP 521)."),123+    let rejectionCases: [(Int, String)] = [141124       (429, "Wallhaven rate limit reached (HTTP 429). Try again in a minute."),142125       (404, "Wallhaven rejected the request (HTTP 404)."),143126     ]144127 145-    for (status, expected) in cases {128+    for (status, expected) in rejectionCases {146129       MockURLProtocol.requestHandler = { request in147130         (response(for: request, statusCode: status), Data())148131       }@@ -156,6 +139,20 @@156139         Issue.record("Unexpected error: \(error)")157140       }158141     }142+143+    // 5xx is Wallhaven's own outage, so it is reported as one.144+    MockURLProtocol.requestHandler = { request in145+      (response(for: request, statusCode: 521), Data())146+    }147+148+    do {149+      _ = try await makeAPI().searchWallpapers(query: "")150+      Issue.record("Expected HTTP 521 to throw")151+    } catch WallhavenError.unavailable(let message) {152+      #expect(message == "Wallhaven is unavailable right now (HTTP 521).")153+    } catch {154+      Issue.record("Unexpected error: \(error)")155+    }159156   }160157 161158   @Test("Wallhaven's HTML status page is reported as an outage, not a bad response")@@ -172,7 +169,7 @@172169     do {173170       _ = try await makeAPI().searchWallpapers(query: "")174171       Issue.record("Expected a status page to throw")175-    } catch WallhavenError.apiError(let message) {172+    } catch WallhavenError.unavailable(let message) {176173       #expect(message == "Wallhaven is offline right now — it's serving its status page.")177174     } catch {178175       Issue.record("Unexpected error: \(error)")@@ -206,6 +203,10 @@206203     #expect(207204       WallhavenError.apiError("Wallhaven is unavailable right now (HTTP 521).").localizedDescription208205         == "Wallhaven is unavailable right now (HTTP 521).")206+    #expect(207+      WallhavenError.unavailable("Wallhaven is offline right now — it's serving its status page.")208+        .localizedDescription209+        == "Wallhaven is offline right now — it's serving its status page.")209210     #expect(210211       WallhavenError.networkError(URLError(.notConnectedToInternet)).localizedDescription211212         .hasPrefix("Couldn't reach Wallhaven: "))

view file