diff --git a/Sources/App/Frontend/Extensions/WebViewScriptMessageHandler.swift b/Sources/App/Frontend/Extensions/WebViewScriptMessageHandler.swift index 07251c9f6c..5b3d11c75b 100644 --- a/Sources/App/Frontend/Extensions/WebViewScriptMessageHandler.swift +++ b/Sources/App/Frontend/Extensions/WebViewScriptMessageHandler.swift @@ -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)") } } diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+EmptyState.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+EmptyState.swift index 27a7a58f9f..638d450332 100644 --- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+EmptyState.swift +++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+EmptyState.swift @@ -1,3 +1,4 @@ +import Alamofire import Shared import SwiftUI import UIKit @@ -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( diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+ProtocolConformance.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+ProtocolConformance.swift index e3dead2c21..13a0c8441f 100644 --- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+ProtocolConformance.swift +++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+ProtocolConformance.swift @@ -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() } } diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewControllerProtocol.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewControllerProtocol.swift index 0a4c24b003..a230fdd4d9 100644 --- a/Sources/App/Frontend/WebView/WebViewController/WebViewControllerProtocol.swift +++ b/Sources/App/Frontend/WebView/WebViewController/WebViewControllerProtocol.swift @@ -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) diff --git a/Sources/HADesignSystem/Sources/Components/ExternalLinkButton.swift b/Sources/HADesignSystem/Sources/Components/ExternalLinkButton.swift index 0981f13aef..a96c877cf0 100644 --- a/Sources/HADesignSystem/Sources/Components/ExternalLinkButton.swift +++ b/Sources/HADesignSystem/Sources/Components/ExternalLinkButton.swift @@ -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) @@ -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)) diff --git a/Tests/App/WebView/Mocks/MockWebViewController.swift b/Tests/App/WebView/Mocks/MockWebViewController.swift index 1645b90136..fc0f07d5de 100644 --- a/Tests/App/WebView/Mocks/MockWebViewController.swift +++ b/Tests/App/WebView/Mocks/MockWebViewController.swift @@ -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() @@ -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 diff --git a/Tests/App/WebView/WebViewControllerTests.swift b/Tests/App/WebView/WebViewControllerTests.swift index 2c5d18c080..f6bcf9fead 100644 --- a/Tests/App/WebView/WebViewControllerTests.swift +++ b/Tests/App/WebView/WebViewControllerTests.swift @@ -1,3 +1,4 @@ +import Alamofire import GRDB @testable import HomeAssistant @testable import Shared @@ -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)