Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ final class WebViewScriptMessageHandler: NSObject, WKScriptMessageHandler {
}
}.catch { [weak self] error in
self?.sendGetExternalAuthFailure(callbackName: callbackName)
// The frontend swallows the rejection and retries, so without this the failure would stay
// invisible and the stand-by loader would spin forever.
self?.webView?.handleExternalAuthFailure(error: error)
Current.Log.error("Failed to authenticate webview: \(error)")
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Alamofire
import Shared
import SwiftUI
import UIKit
Expand Down Expand Up @@ -57,6 +58,44 @@ extension WebViewController {
connectionState == .disconnected && latestLoadError != nil
}

/// Arms the grace timer that shows the empty state unless a `connected`/`loaded` frontend state
/// arrives first. The timer clears itself as it fires so a later failure can arm a fresh one.
func scheduleEmptyStateAfterGracePeriod() {
emptyStateTimer?.invalidate()
let timeout = TimeInterval(Current.settingsStore.webViewEmptyStateTimeout)
emptyStateTimer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in
self?.emptyStateTimer = nil
self?.showEmptyState()
}
}

/// The frontend asks the app for an access token before it can connect to the server, and keeps
/// retrying while that fails. A failure there produces neither a navigation error nor a frontend
/// connection state — the page itself loaded fine, it just can't authenticate — so nothing would
/// ever take the stand-by loader down and the app appears to load forever. Treat it like a failed
/// load instead: keep the error for the details screen and fall back to the empty state.
func handleExternalAuthFailure(error: Error) {
guard !connectionState.isReadyForDisplay else { return }
latestLoadError = Self.presentableExternalAuthError(for: error)

// The frontend retries in a tight loop, so only the first failure arms the grace period; an
// empty state that is already up must not be pushed back by the retries behind it.
guard emptyStateTimer == nil, overlayState?.emptyState == nil else { return }

Current.Log.error("Frontend could not be authenticated, showing empty state: \(error)")
let resolvedState: FrontEndConnectionState = connectionState == .authInvalid ? .authInvalid : .disconnected
connectionState = resolvedState
overlayState?.connectionState = resolvedState
scheduleEmptyStateAfterGracePeriod()
}

/// Unwraps Alamofire's session-task wrapper so the error details screen shows the `URLError` the
/// user can act on (offline, local network blocked, TLS) rather than the transport wrapper, which
/// carries no failing URL and no actionable domain/code.
static func presentableExternalAuthError(for error: Error) -> Error {
(error.asAFError?.underlyingError as? URLError) ?? error
}

func presentLatestLoadErrorDetails() {
guard let latestLoadError else { return }
presentOverlayController(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,7 @@ extension WebViewController: WebViewControllerProtocol {
showEmptyState()
case .disconnected, .unknown:
// Start a timer. If not interrupted by a 'connected' state, show the empty state.
let timeout = TimeInterval(Current.settingsStore.webViewEmptyStateTimeout)
emptyStateTimer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in
self?.showEmptyState()
}
scheduleEmptyStateAfterGracePeriod()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ protocol WebViewControllerProtocol: AnyObject {
func dismissOverlayController(animated: Bool, completion: (() -> Void)?)
func dismissControllerAboveOverlayController()
func updateFrontendConnectionState(state: String)
func handleExternalAuthFailure(error: Error)
func navigateToPath(path: String)
func showBanner(request: BannerRequest)
func hideBanner(id: String)
Expand Down
14 changes: 10 additions & 4 deletions Sources/HADesignSystem/Sources/Components/ExternalLinkButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,17 @@ public struct ExternalLinkButton: View {
icon
.frame(width: 30, height: 30)
.font(.title2)
.tint(tint)
.foregroundStyle(tint)
Text(title)
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
.tint(Color(uiColor: .label))
.foregroundStyle(Color(uiColor: .label))
.font(.body.bold())
}
}
// The row draws its own background below; without this Mac Catalyst adds the bordered
// button style's background on top of it, so every row shows two stacked backgrounds.
.buttonStyle(.plain)
.frame(maxWidth: 600)
.padding()
.background(background)
Expand Down Expand Up @@ -69,14 +72,17 @@ public struct ActionLinkButton: View {
icon
.frame(width: 30, height: 30)
.font(.title2)
.tint(tint)
.foregroundStyle(tint)
Text(title)
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
.tint(Color(uiColor: .label))
.foregroundStyle(Color(uiColor: .label))
.font(.body.bold())
}
})
// The row draws its own background below; without this Mac Catalyst adds the bordered
// button style's background on top of it, so every row shows two stacked backgrounds.
.buttonStyle(.plain)
.frame(maxWidth: 600)
.padding()
.background(Color(uiColor: .secondarySystemBackground))
Expand Down
9 changes: 9 additions & 0 deletions Tests/App/WebView/Mocks/MockWebViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ final class MockWebViewController: WebViewControllerProtocol {
var presentAlertControllerCalled = false
var shownBannerRequests = [BannerRequest]()
var hiddenBannerIDs = [String]()
var handleExternalAuthFailureCalled = false
var lastExternalAuthFailure: Error?
var handleExternalAuthFailureExpectation: XCTestExpectation?

init() {
self.webViewExternalMessageHandler = MockWebViewExternalMessageHandler()
Expand Down Expand Up @@ -91,6 +94,12 @@ final class MockWebViewController: WebViewControllerProtocol {
lastSettingButtonState = state
}

func handleExternalAuthFailure(error: Error) {
handleExternalAuthFailureCalled = true
lastExternalAuthFailure = error
handleExternalAuthFailureExpectation?.fulfill()
}

func updateImprovEntryView(show: Bool) {
updateImprovEntryViewCalled = true
lastUpdateImprovEntryViewState = show
Expand Down
74 changes: 74 additions & 0 deletions Tests/App/WebView/WebViewControllerTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Alamofire
import GRDB
@testable import HomeAssistant
@testable import Shared
Expand Down Expand Up @@ -74,6 +75,79 @@ final class WebViewControllerTests: XCTestCase {
XCTAssertNil(overlayState.emptyState)
}

func testExternalAuthFailureMarksDisconnectedAndArmsEmptyStateTimer() {
let sut = makeSUT()
let overlayState = WebFrontendOverlayState()
sut.overlayState = overlayState

sut.handleExternalAuthFailure(error: URLError(.notConnectedToInternet))

XCTAssertEqual(sut.connectionState, .disconnected)
XCTAssertEqual(overlayState.connectionState, .disconnected)
XCTAssertEqual((sut.latestLoadError as? URLError)?.code, .notConnectedToInternet)
XCTAssertNotNil(sut.emptyStateTimer)
}

func testExternalAuthFailureUnwrapsSessionTaskErrorForTheDetailsScreen() {
let sut = makeSUT()
sut.overlayState = WebFrontendOverlayState()

sut.handleExternalAuthFailure(
error: AFError.sessionTaskFailed(error: URLError(.notConnectedToInternet))
)

XCTAssertEqual((sut.latestLoadError as? URLError)?.code, .notConnectedToInternet)
}

func testExternalAuthFailureIsIgnoredWhileFrontendIsDisplayed() {
let sut = makeSUT()
sut.overlayState = WebFrontendOverlayState()
sut.connectionState = .loaded

sut.handleExternalAuthFailure(error: URLError(.notConnectedToInternet))

XCTAssertEqual(sut.connectionState, .loaded)
XCTAssertNil(sut.latestLoadError)
XCTAssertNil(sut.emptyStateTimer)
}

func testExternalAuthFailureKeepsAuthInvalid() {
let sut = makeSUT()
sut.overlayState = WebFrontendOverlayState()
sut.connectionState = .authInvalid

sut.handleExternalAuthFailure(error: URLError(.notConnectedToInternet))

XCTAssertEqual(sut.connectionState, .authInvalid)
}

func testRepeatedExternalAuthFailuresDoNotPushBackTheEmptyState() {
let sut = makeSUT()
let overlayState = WebFrontendOverlayState()
sut.overlayState = overlayState

sut.handleExternalAuthFailure(error: URLError(.notConnectedToInternet))
let armedTimer = sut.emptyStateTimer

sut.handleExternalAuthFailure(error: URLError(.timedOut))

XCTAssertTrue(armedTimer === sut.emptyStateTimer)
XCTAssertEqual((sut.latestLoadError as? URLError)?.code, .timedOut)
}

func testExternalAuthFailureDoesNotReArmWhileEmptyStateIsShown() {
let sut = makeSUT()
let overlayState = WebFrontendOverlayState()
sut.overlayState = overlayState
sut.connectionState = .disconnected
sut.showEmptyState()
XCTAssertNotNil(overlayState.emptyState)

sut.handleExternalAuthFailure(error: URLError(.notConnectedToInternet))

XCTAssertNil(sut.emptyStateTimer)
}

func testUpdateFrontendConnectionStateClearsLatestLoadError() {
let sut = makeSUT()
sut.latestLoadError = URLError(.timedOut)
Expand Down