Skip to content
Closed
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
4 changes: 4 additions & 0 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 = "<group>"; };
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 = "<group>"; };
825E1E44BA9ABF1BF53733D3 /* KioskConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskConstants.swift; sourceTree = "<group>"; };
851BAEAD0BF400015BB5FE9F /* WebViewControllerPendingURLTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewControllerPendingURLTests.swift; sourceTree = "<group>"; };
862436CFE6E3F4B31500EFB2 /* ComplicationListViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComplicationListViewModel.swift; sourceTree = "<group>"; };
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 = "<group>"; };
892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseMigration.test.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4517,6 +4519,7 @@
429481EA2DA93FA000A8B468 /* WebViewJavascriptCommandsTests.swift */,
4228D0002DB903AA00FC6912 /* WKUserContentControllerMessageTests.swift */,
42C5E5AC2F7E74DE004797B5 /* SafeScriptMessageHandlerTests.swift */,
851BAEAD0BF400015BB5FE9F /* WebViewControllerPendingURLTests.swift */,
);
path = WebView;
sourceTree = "<group>";
Expand Down Expand Up @@ -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;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
91 changes: 61 additions & 30 deletions Sources/App/Frontend/WebView/WebViewController+URLLoading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")!))
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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
}
}

Expand All @@ -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)
}

Expand Down
8 changes: 8 additions & 0 deletions Sources/App/Frontend/WebView/WebViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
129 changes: 129 additions & 0 deletions Tests/App/WebView/WebViewControllerPendingURLTests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading