diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 72786c181..a6bb0ed1e 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -1588,6 +1588,7 @@ E60778133EC44B10A61D551C /* TestFlightCommunicationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 212E30CB76DE4ED1B6E317CD /* TestFlightCommunicationModels.swift */; }; E92E09E3A93650D56E3C5093 /* KioskScreensaverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62CDCFDB29D283A7902A3ABE /* KioskScreensaverViewController.swift */; }; F0D1DD41A8F55F6D767EBF37 /* TemplatePreviewSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332C060487B468055C487070 /* TemplatePreviewSection.swift */; }; + F810A6B2F8EE83B572364BBB /* WebViewControllerPendingURLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 851BAEAD0BF400015BB5FE9F /* WebViewControllerPendingURLTests.swift */; }; FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */; }; FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */; }; FD3BC66C29BA00D600B19FBE /* CarPlayEntitiesListTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66B29BA00D600B19FBE /* CarPlayEntitiesListTemplate.swift */; }; @@ -3105,6 +3106,7 @@ 7C3CCF89D04DB409DDFC4A09 /* Pods-iOS-Shared-iOS-Tests-Shared.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS-Tests-Shared.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared.release.xcconfig"; sourceTree = ""; }; 7DC07BDAC69AD95BDEFD8AFF /* Pods-iOS-Extensions-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationService.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationService/Pods-iOS-Extensions-NotificationService.release.xcconfig"; sourceTree = ""; }; 825E1E44BA9ABF1BF53733D3 /* KioskConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskConstants.swift; sourceTree = ""; }; + 851BAEAD0BF400015BB5FE9F /* WebViewControllerPendingURLTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewControllerPendingURLTests.swift; sourceTree = ""; }; 862436CFE6E3F4B31500EFB2 /* ComplicationListViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComplicationListViewModel.swift; sourceTree = ""; }; 86BFD63671D2D0A012DFE169 /* Pods-iOS-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-App/Pods-iOS-App.debug.xcconfig"; sourceTree = ""; }; 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseMigration.test.swift; sourceTree = ""; }; @@ -4517,6 +4519,7 @@ 429481EA2DA93FA000A8B468 /* WebViewJavascriptCommandsTests.swift */, 4228D0002DB903AA00FC6912 /* WKUserContentControllerMessageTests.swift */, 42C5E5AC2F7E74DE004797B5 /* SafeScriptMessageHandlerTests.swift */, + 851BAEAD0BF400015BB5FE9F /* WebViewControllerPendingURLTests.swift */, ); path = WebView; sourceTree = ""; @@ -9800,6 +9803,7 @@ C574CE3276BCE901743FF8C9 /* KioskSettings.test.swift in Sources */, DEFBE1A5E9A005B0A5392D27 /* KioskLocalization.test.swift in Sources */, A1619F1ED93FB8B0E7E53C38 /* KioskLifecycleBrightness.test.swift in Sources */, + F810A6B2F8EE83B572364BBB /* WebViewControllerPendingURLTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/App/Frontend/WebView/WebViewController+Navigation.swift b/Sources/App/Frontend/WebView/WebViewController+Navigation.swift index ba83cdb02..fbe4ad1cc 100644 --- a/Sources/App/Frontend/WebView/WebViewController+Navigation.swift +++ b/Sources/App/Frontend/WebView/WebViewController+Navigation.swift @@ -28,6 +28,9 @@ extension WebViewController { ) return } + // Remember this as the authoritative URL so a racing `loadActiveURLIfNeeded()` on cold + // start can't replace it with the default server URL, discarding the navigation (#4145). + pendingOpenInlineURL = url load(request: URLRequest(url: url)) } else { openURLInBrowser(url, self) diff --git a/Sources/App/Frontend/WebView/WebViewController+URLLoading.swift b/Sources/App/Frontend/WebView/WebViewController+URLLoading.swift index 304f8c79f..fef0a3059 100644 --- a/Sources/App/Frontend/WebView/WebViewController+URLLoading.swift +++ b/Sources/App/Frontend/WebView/WebViewController+URLLoading.swift @@ -73,36 +73,7 @@ extension WebViewController { } // if we aren't showing a url or it's an incorrect url, update it -- otherwise, leave it alone - let request: URLRequest - - if Current.settingsStore.restoreLastURL, - let initialURL, initialURL.baseIsEqual(to: webviewURL) { - Current.Log.info("restoring initial url path: \(initialURL.path)") - request = URLRequest(url: initialURL) - } else if let currentURL = webView.url, currentURL.path.count > 1 { - // Preserve the current path when the base URL changes (e.g., switching between internal/external) - var components = URLComponents(url: webviewURL, resolvingAgainstBaseURL: true) - components?.path = currentURL.path - if let query = currentURL.query { - // Preserve external_auth if present, add other query items - var queryItems = components?.queryItems ?? [] - let currentQueryItems = URLComponents(url: currentURL, resolvingAgainstBaseURL: false)? - .queryItems ?? [] - for item in currentQueryItems where item.name != "external_auth" { - queryItems.append(item) - } - components?.queryItems = queryItems - } - components?.fragment = currentURL.fragment - let newURL = components?.url ?? webviewURL - Current.Log.info("preserving current path on base URL change: \(newURL.path)") - request = URLRequest(url: newURL) - } else { - Current.Log.info("loading default url path: \(webviewURL.path)") - request = URLRequest(url: webviewURL) - } - - load(request: request) + load(request: activeURLRequest(for: webviewURL)) } if Current.isCatalyst { @@ -114,6 +85,51 @@ extension WebViewController { } } + /// Picks the request `loadActiveURLIfNeeded()` should load into a blank/stale webview. Priority: + /// 1. An explicit `open(inline:)` URL (notification/deep link) targeting the active server — must + /// win so cold-start navigation isn't discarded (#4145). + /// 2. The restored "last URL" when `restoreLastURL` is enabled. + /// 3. The current path re-based onto `webviewURL` when only the base changed (internal/external). + /// 4. The server's default URL. + private func activeURLRequest(for webviewURL: URL) -> URLRequest { + if let prioritizedURL = Self.prioritizedInlineURL( + pendingOpenInlineURL: pendingOpenInlineURL, + webviewURL: webviewURL + ) { + Current.Log.info("loading explicitly requested url path: \(prioritizedURL.path)") + return URLRequest(url: prioritizedURL) + } + + if Current.settingsStore.restoreLastURL, + let initialURL, initialURL.baseIsEqual(to: webviewURL) { + Current.Log.info("restoring initial url path: \(initialURL.path)") + return URLRequest(url: initialURL) + } + + if let currentURL = webView.url, currentURL.path.count > 1 { + // Preserve the current path when the base URL changes (e.g., switching between internal/external) + var components = URLComponents(url: webviewURL, resolvingAgainstBaseURL: true) + components?.path = currentURL.path + if let query = currentURL.query { + // Preserve external_auth if present, add other query items + var queryItems = components?.queryItems ?? [] + let currentQueryItems = URLComponents(url: currentURL, resolvingAgainstBaseURL: false)? + .queryItems ?? [] + for item in currentQueryItems where item.name != "external_auth" { + queryItems.append(item) + } + components?.queryItems = queryItems + } + components?.fragment = currentURL.fragment + let newURL = components?.url ?? webviewURL + Current.Log.info("preserving current path on base URL change: \(newURL.path)") + return URLRequest(url: newURL) + } + + Current.Log.info("loading default url path: \(webviewURL.path)") + return URLRequest(url: webviewURL) + } + func showNoActiveURLError() { // Load about:blank in webview to prevent any current connections load(request: URLRequest(url: URL(string: "about:blank")!)) @@ -181,4 +197,19 @@ extension WebViewController { Current.appDatabaseUpdater.update(server: server, forceUpdate: false) Current.panelsUpdater.update() } + + /// When an explicit `open(inline:)` URL is pending and targets the active server, it must be + /// loaded instead of the default/restored URL so a cold-start `loadActiveURLIfNeeded()` race + /// can't discard a notification's or deep link's URL (#4145). Returns `nil` when there is + /// nothing to prioritize and the normal restore/default logic should run. + /// + /// The match is by `baseIsEqual` (scheme/host/port) only — enough to confirm the pending URL + /// belongs to the active server; the path is intentionally not compared, since the whole point + /// is to load the pending path rather than the default one. + static func prioritizedInlineURL(pendingOpenInlineURL: URL?, webviewURL: URL) -> URL? { + guard let pendingOpenInlineURL, pendingOpenInlineURL.baseIsEqual(to: webviewURL) else { + return nil + } + return pendingOpenInlineURL + } } diff --git a/Sources/App/Frontend/WebView/WebViewController+WebKitDelegates.swift b/Sources/App/Frontend/WebView/WebViewController+WebKitDelegates.swift index db0197171..5ae627c1c 100644 --- a/Sources/App/Frontend/WebView/WebViewController+WebKitDelegates.swift +++ b/Sources/App/Frontend/WebView/WebViewController+WebKitDelegates.swift @@ -38,6 +38,14 @@ extension WebViewController { func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { refreshControl.endRefreshing() + + // A committed navigation truly failed; stop forcing the requested URL so the default can take + // over. Only on a *real* failure — a `.cancelled` error just means a newer load superseded an + // in-flight one, and clearing then would revive the cold-start race (#4145). + if !error.isCancelled { + pendingOpenInlineURL = nil + } + if let err = error as? URLError { if err.code != .cancelled { Current.Log.error("Failure during nav: \(err)") @@ -77,6 +85,9 @@ extension WebViewController { if shouldShowError { latestLoadError = error showEmptyState() + // The requested URL won't load; stop forcing it (see note in didFail). `shouldShowError` + // is already false for cancellations, so a superseded load keeps its pending URL (#4145). + pendingOpenInlineURL = nil } } @@ -87,6 +98,13 @@ extension WebViewController { // in case the view appears again, don't reload initialURL = nil + // the explicit navigation has landed; stop forcing it on subsequent active-URL loads (#4145). + // skip about:blank, which `showNoActiveURLError()` loads — clearing then would drop the + // pending URL before the real navigation gets a chance to run. + if webView.url?.absoluteString != "about:blank" { + pendingOpenInlineURL = nil + } + updateWebViewSettings(reason: .load) } diff --git a/Sources/App/Frontend/WebView/WebViewController.swift b/Sources/App/Frontend/WebView/WebViewController.swift index 174bf8577..e2661682f 100644 --- a/Sources/App/Frontend/WebView/WebViewController.swift +++ b/Sources/App/Frontend/WebView/WebViewController.swift @@ -32,6 +32,14 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg var latestLoadError: Error? var initialURL: URL? + + /// A URL requested explicitly via `open(inline:)` (notification tap or deep link) that must take + /// priority over the automatic active-URL load. On cold start the webview begins blank, so + /// `loadActiveURLIfNeeded()` — fired from `viewWillAppear` and the HA-connect notification — would + /// otherwise race with and overwrite this navigation with the default server URL, discarding the + /// requested URL (#4145). Cleared once a navigation finishes (`didFinish`). + var pendingOpenInlineURL: URL? + var statusBarButtonsStack: UIStackView? var lastNavigationWasServerError = false var reconnectBackgroundTimer: Timer? { diff --git a/Tests/App/WebView/WebViewControllerPendingURLTests.swift b/Tests/App/WebView/WebViewControllerPendingURLTests.swift new file mode 100644 index 000000000..9e260cd29 --- /dev/null +++ b/Tests/App/WebView/WebViewControllerPendingURLTests.swift @@ -0,0 +1,129 @@ +@testable import HomeAssistant +@testable import Shared +import UIKit +import WebKit +import XCTest + +/// Tests for the cold-start pending-URL behavior that keeps a notification/deep-link URL from being +/// discarded by the automatic active-URL load (#4145). +final class WebViewControllerPendingURLTests: XCTestCase { + // MARK: - prioritizedInlineURL + + func testPrioritizedInlineURLReturnsPendingURLWhenTargetingActiveServer() { + let webviewURL = URL(string: "https://example.com:8123/")! + let pending = URL(string: "https://example.com:8123/frigate/review/123")! + + let result = WebViewController.prioritizedInlineURL(pendingOpenInlineURL: pending, webviewURL: webviewURL) + + XCTAssertEqual(result, pending) + } + + func testPrioritizedInlineURLReturnsNilWhenPendingURLTargetsDifferentServer() { + let webviewURL = URL(string: "https://example.com:8123/")! + let pending = URL(string: "https://other.example.com:8123/frigate")! + + let result = WebViewController.prioritizedInlineURL(pendingOpenInlineURL: pending, webviewURL: webviewURL) + + XCTAssertNil(result) + } + + func testPrioritizedInlineURLReturnsNilWhenNoPendingURL() { + let webviewURL = URL(string: "https://example.com:8123/")! + + let result = WebViewController.prioritizedInlineURL(pendingOpenInlineURL: nil, webviewURL: webviewURL) + + XCTAssertNil(result) + } + + // MARK: - open(inline:) records the pending URL + + func testOpenInlineRecordsPendingURLForFrontendPath() { + let sut = makeSUT() + let url = URL(string: "https://example.com:8123/lovelace/default")! + + sut.open(inline: url) + + XCTAssertEqual(sut.pendingOpenInlineURL, url) + } + + // MARK: - clearing on navigation failure + + func testRealProvisionalNavigationFailureClearsPendingURL() { + let sut = makeSUT() + sut.pendingOpenInlineURL = URL(string: "https://example.com:8123/frigate")! + + sut.webView(sut.webView, didFailProvisionalNavigation: nil, withError: URLError(.timedOut)) + + XCTAssertNil(sut.pendingOpenInlineURL) + } + + func testCancelledProvisionalNavigationKeepsPendingURL() { + // A cancellation means a newer load superseded an in-flight one — clearing then would + // revive the cold-start race, so the pending URL must survive (#4145). + let sut = makeSUT() + let pending = URL(string: "https://example.com:8123/frigate")! + sut.pendingOpenInlineURL = pending + + sut.webView(sut.webView, didFailProvisionalNavigation: nil, withError: URLError(.cancelled)) + + XCTAssertEqual(sut.pendingOpenInlineURL, pending) + } + + // MARK: - loadActiveURLIfNeeded honors the pending URL (the cold-start race) + + /// Reproduces the #4145 race: on a blank (cold-start) webview, `loadActiveURLIfNeeded()` must load + /// the pending notification URL, not the server's default URL. Without the fix this loads the + /// default and the test fails. + func testLoadActiveURLIfNeededPrefersPendingURLOverDefaultURL() throws { + let wasCatalyst = Current.isCatalyst + // Take the synchronous load path (skips the async connectivity sync used on iOS). + Current.isCatalyst = true + defer { Current.isCatalyst = wasCatalyst } + + let sut = makeSUT() + let capturingWebView = CapturingWebView( + frame: .zero, + configuration: WebViewController.makeWebViewConfiguration() + ) + sut.webView = capturingWebView // blank webview: `url` is nil, like a cold start + + let webviewURL = try XCTUnwrap(sut.server.info.connection.webviewURL()) + var components = try XCTUnwrap(URLComponents(url: webviewURL, resolvingAgainstBaseURL: false)) + components.path = "/frigate/review/1" + let pending = try XCTUnwrap(components.url) + sut.pendingOpenInlineURL = pending + + sut.loadActiveURLIfNeeded() + + let loaded = try XCTUnwrap( + capturingWebView.loadedRequests.last?.url, + "expected loadActiveURLIfNeeded to load a URL" + ) + XCTAssertEqual( + loaded.path, + "/frigate/review/1", + "the pending notification URL must win over the default URL on a blank webview (#4145)" + ) + } + + // MARK: - Helpers + + private func makeSUT() -> WebViewController { + let sut = WebViewController(server: .fake()) + let containerView = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 640)) + sut.setValue(containerView, forKey: "view") + sut.webView = WKWebView(frame: .zero, configuration: WebViewController.makeWebViewConfiguration()) + return sut + } +} + +/// A `WKWebView` that records load requests instead of performing them, so tests can assert which URL +/// `WebViewController` chose without hitting the network. +private final class CapturingWebView: WKWebView { + private(set) var loadedRequests: [URLRequest] = [] + + override func load(_ request: URLRequest) -> WKNavigation? { + loadedRequests.append(request) + return nil + } +}