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
↓ haven-493d07ba.tar.gzatlas at this save
files changed9 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 −20Info.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>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 skippedSources/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 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 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 {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 }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()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)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: "))