diff --git a/Sources/App/Container/ContainerView.swift b/Sources/App/Container/ContainerView.swift index 78974b688..0e19ecd95 100644 --- a/Sources/App/Container/ContainerView.swift +++ b/Sources/App/Container/ContainerView.swift @@ -26,8 +26,8 @@ struct ContainerView: View { case let .onboarding(style): OnboardingHostingView(onboardingStyle: style) .id(style) - case let .webView(server): - HomeAssistantView(server: server) { webViewController in + case let .webView(server, initialPath): + HomeAssistantView(server: server, initialPath: initialPath) { webViewController in coordinator.setFrontend(webViewController) Current.sceneManager.setWebViewController(webViewController) } @@ -115,7 +115,9 @@ struct ContainerView: View { } private var isShowingWebView: Bool { - if case .webView = state.screen { return true } + if case .webView = state.screen { + return true + } return false } } diff --git a/Sources/App/Container/OnboardingStateObservable.swift b/Sources/App/Container/OnboardingStateObservable.swift index 3a0474f2a..0578313a3 100644 --- a/Sources/App/Container/OnboardingStateObservable.swift +++ b/Sources/App/Container/OnboardingStateObservable.swift @@ -23,7 +23,9 @@ enum RecoveredServerReauthenticationError: LocalizedError { final class OnboardingStateObservable: ObservableObject { enum Screen: Equatable { case onboarding(OnboardingStyle) - case webView(Server) + /// `initialPath` restores the last viewed path on cold launch only; in-session server switches + /// pass `nil` so switching never yanks the web view back to the launch path. + case webView(Server, initialPath: String?) case recoveredServerImport case recoveredServerReauth(Server) } @@ -40,7 +42,7 @@ final class OnboardingStateObservable: ObservableObject { /// Switches the displayed screen to `server`'s web view. Called by the app coordinator's `open(server:)`. func showWebView(for server: Server) { - screen = .webView(server) + screen = .webView(server, initialPath: nil) } /// A change to the kiosk server requires a different web view (rebuilt by `ContainerView` via the @@ -77,7 +79,9 @@ final class OnboardingStateObservable: ObservableObject { } private var currentServer: Server? { - if case let .webView(server) = screen { return server } + if case let .webView(server, _) = screen { + return server + } return nil } @@ -163,8 +167,8 @@ final class OnboardingStateObservable: ObservableObject { } } - /// Mirrors `WebViewWindowController.setup()`: onboarding when required, otherwise the first server's - /// web view. + /// Mirrors `WebViewWindowController.setup()`: onboarding when required, otherwise the last viewed + /// server's web view (restoring its last path). private static func initialScreen() -> Screen { if Current.servers.isMirrorRestorePending { return .recoveredServerImport @@ -176,21 +180,38 @@ final class OnboardingStateObservable: ObservableObject { return .onboarding(style) } if let server = preferredInitialServer() { - return .webView(server) + return .webView(server, initialPath: restoredInitialPath(for: server)) } return .onboarding(.initial) } /// The server to show at launch: the kiosk-configured server when kiosk mode is enabled, otherwise the - /// first registered server. - private static func preferredInitialServer() -> Server? { + /// last server the user was viewing, falling back to the first registered server. Non-private for tests. + static func preferredInitialServer() -> Server? { let kiosk = Current.kioskSettings if kiosk.enabled, let server = Current.servers.server(forServerIdentifier: kiosk.serverId) { return server } + if let identifier = Current.settingsStore.lastActiveServerIdentifier, + let server = Current.servers.server(forServerIdentifier: identifier) { + return server + } return Current.servers.all.first } + /// The path to restore into the launch web view, or `nil` to load the server default. Gated by the + /// "Remember Last Page" setting and only applied when the launch server is the one that was persisted, + /// so a fallback to the first server (e.g. after the saved server was removed) never inherits a stale + /// path. Only the path is stored; the base URL is re-resolved at load time from current connectivity. + /// Non-private for tests. + static func restoredInitialPath(for server: Server) -> String? { + guard Current.settingsStore.restoreLastURL, + Current.settingsStore.lastActiveServerIdentifier == server.identifier.rawValue else { + return nil + } + return Current.settingsStore.lastActiveURLPath + } + /// The server shown at launch (preferring one that doesn't need re-auth), if it requires re-auth after a /// keychain-mirror restore. Mirrors `WebViewWindowController.nextRecoveredServerNeedingReauthentication`. private static func recoveredServerNeedingReauthentication() -> Server? { @@ -208,7 +229,7 @@ final class OnboardingStateObservable: ObservableObject { // A server was removed / logged out. Fall back to another server if one remains, // otherwise restart onboarding. Mirrors `WebViewWindowController.onboardingStateDidChange`. if let server = Current.servers.all.first { - screen = .webView(server) + screen = .webView(server, initialPath: nil) } else { screen = .onboarding(.initial) } @@ -219,7 +240,7 @@ final class OnboardingStateObservable: ObservableObject { } case .complete: if let server = Current.servers.all.first { - screen = .webView(server) + screen = .webView(server, initialPath: nil) } case .didConnect: // Connection established mid-onboarding; the `.complete` transition drives the screen swap. @@ -229,8 +250,8 @@ final class OnboardingStateObservable: ObservableObject { } extension OnboardingStateObservable: OnboardingStateObserver { - // `OnboardingStateObservation` may notify from a non-main thread, so hop to the main actor before - // mutating the published `screen`. + /// `OnboardingStateObservation` may notify from a non-main thread, so hop to the main actor before + /// mutating the published `screen`. nonisolated func onboardingStateDidChange(to state: OnboardingState) { Task { @MainActor [weak self] in self?.apply(state) diff --git a/Sources/App/Frontend/WebView/FrontendView.swift b/Sources/App/Frontend/WebView/FrontendView.swift index f91e31034..cbc87e934 100644 --- a/Sources/App/Frontend/WebView/FrontendView.swift +++ b/Sources/App/Frontend/WebView/FrontendView.swift @@ -6,7 +6,7 @@ import UIKit /// fresh controller and discards the previous one. struct FrontendView: UIViewControllerRepresentable { let server: Server - var restorationType: WebViewRestorationType? + var initialPath: String? var onWebViewController: ((WebViewController) -> Void)? var onWebViewLoaded: ((WebViewController) -> Void)? var resetFrontendAction: (() -> Void)? @@ -15,7 +15,7 @@ struct FrontendView: UIViewControllerRepresentable { init( server: Server, - restorationType: WebViewRestorationType? = nil, + initialPath: String? = nil, onWebViewController: ((WebViewController) -> Void)? = nil, onWebViewLoaded: ((WebViewController) -> Void)? = nil, resetFrontendAction: (() -> Void)? = nil, @@ -23,7 +23,7 @@ struct FrontendView: UIViewControllerRepresentable { overlayState: WebFrontendOverlayState ) { self.server = server - self.restorationType = restorationType + self.initialPath = initialPath self.onWebViewController = onWebViewController self.onWebViewLoaded = onWebViewLoaded self.resetFrontendAction = resetFrontendAction @@ -41,10 +41,10 @@ struct FrontendView: UIViewControllerRepresentable { // No-op: a server change recreates this view (keyed by server in `ContainerView`), never updates it. } - // Non-private for tests. + /// Non-private for tests. func makeWebViewController() -> WebViewController { let controller = WebViewController(server: server) - controller.initialURL = restorationType?.initialURL + controller.initialURLPath = initialPath controller.overlayState = overlayState controller.resetFrontendAction = resetFrontendAction controller.reconnectManager = reconnectManager diff --git a/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantView.swift b/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantView.swift index cbbafebfc..0baee8d0c 100644 --- a/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantView.swift +++ b/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantView.swift @@ -8,15 +8,20 @@ struct HomeAssistantView: View, WebFrontendView { @Environment(\.accessibilityReduceMotion) private var reduceMotion init(server: Server, onWebViewController: @escaping (WebViewController) -> Void) { + self.init(server: server, initialPath: nil, onWebViewController: onWebViewController) + } + + init(server: Server, initialPath: String?, onWebViewController: @escaping (WebViewController) -> Void) { _viewModel = StateObject( wrappedValue: HomeAssistantViewModel( server: server, + initialPath: initialPath, onWebViewController: onWebViewController ) ) } - // The themed status-bar strip keeps the last frontend-provided colour until WebKit sends a new update. + /// The themed status-bar strip keeps the last frontend-provided colour until WebKit sends a new update. private var themedStatusBar: some View { GeometryReader { proxy in if let color = viewModel.overlayState.statusBarColor { @@ -59,6 +64,7 @@ struct HomeAssistantView: View, WebFrontendView { private var homeAssistant: some View { FrontendView( server: viewModel.server, + initialPath: viewModel.initialPath, onWebViewController: viewModel.handleWebViewController, onWebViewLoaded: viewModel.handleWebViewLoaded, resetFrontendAction: viewModel.resetWebFrontend, diff --git a/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantViewModel.swift b/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantViewModel.swift index de3b87ac8..27607b0d6 100644 --- a/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantViewModel.swift +++ b/Sources/App/Frontend/WebView/HomeAssistantView/HomeAssistantViewModel.swift @@ -19,6 +19,7 @@ final class HomeAssistantViewModel: ObservableObject { } let server: Server + let initialPath: String? let overlayState: WebFrontendOverlayState let chrome: WebViewChromeState let reconnectManager: WebViewReconnectManager @@ -49,12 +50,14 @@ final class HomeAssistantViewModel: ObservableObject { init( server: Server, + initialPath: String? = nil, overlayState: WebFrontendOverlayState? = nil, chrome: WebViewChromeState? = nil, reconnectManager: WebViewReconnectManager? = nil, onWebViewController: ((WebViewController) -> Void)? = nil ) { self.server = server + self.initialPath = initialPath self.overlayState = overlayState ?? WebFrontendOverlayState() self.chrome = chrome ?? WebViewChromeState() self.reconnectManager = reconnectManager ?? WebViewReconnectManager() @@ -100,8 +103,8 @@ final class HomeAssistantViewModel: ObservableObject { overlayState.emptyState == nil && !isFullScreenLoaderVisible ? 0 : 1 } - // On servers that support `frontend/loaded`, the first bootstrap must wait for that event (the frontend's - // own launcher screen is still up on plain `connected`). + /// On servers that support `frontend/loaded`, the first bootstrap must wait for that event (the frontend's + /// own launcher screen is still up on plain `connected`). private func didReachLoaderReadyState(_ connectionState: FrontEndConnectionState) -> Bool { guard server.info.version >= .frontendLoadedExternalBus else { return connectionState.isReadyForDisplay diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+URLLoading.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+URLLoading.swift index 5c7f0ed9d..10ad49875 100644 --- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+URLLoading.swift +++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+URLLoading.swift @@ -168,9 +168,12 @@ extension WebViewController { Current.Log.info("loading kiosk dashboard path: \(kioskURL.path)") return kioskURL } - if Current.settingsStore.restoreLastURL, let initialURL, initialURL.baseIsEqual(to: webviewURL) { - Current.Log.info("restoring initial url path: \(initialURL.path)") - return initialURL + if Current.settingsStore.restoreLastURL, webView.url == nil, let initialURLPath, + let restored = Self.restoredURL(base: webviewURL, relativePath: initialURLPath) { + // Keep the resolved full URL so the WebKit delegates can detect it finishing (or 404ing). + initialURL = restored + Current.Log.info("restoring last path: \(restored.path)") + return restored } if let currentURL = webView.url, currentURL.path.count > 1 { // Preserve the current path when the base URL changes (e.g., switching between internal/external) @@ -195,6 +198,19 @@ extension WebViewController { return webviewURL } + /// Rebuilds a stored relative reference (`path?query#fragment`) onto the currently active base URL, so + /// a page saved on one network/location reopens correctly on another. Non-private for tests. + static func restoredURL(base: URL, relativePath: String) -> URL? { + guard var components = URLComponents(url: base, resolvingAgainstBaseURL: true), + let relative = URLComponents(string: relativePath) else { + return nil + } + components.path = relative.path + components.query = relative.query + components.fragment = relative.fragment + return components.url + } + /// The URL of the kiosk-configured dashboard for this server, or `nil` when kiosk mode is off, this /// isn't the kiosk server, or no specific dashboard was chosen (in which case the server default loads). private func kioskDashboardURL(for webviewURL: URL) async -> URL? { diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController+WebViewSetup.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController+WebViewSetup.swift index 981570b47..cb5504ba5 100644 --- a/Sources/App/Frontend/WebView/WebViewController/WebViewController+WebViewSetup.swift +++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController+WebViewSetup.swift @@ -55,6 +55,20 @@ extension WebViewController { RestorableStateKey.server.rawValue: server.identifier.rawValue, ] userActivity?.becomeCurrent() + + // Persist the server and a host-agnostic path so cold launch reopens here; the base URL is + // re-resolved from current connectivity at load time (see `resolvedLoadURL`). + Current.settingsStore.lastActiveServerIdentifier = server.identifier.rawValue + if let components = URLComponents(url: cleanURL, resolvingAgainstBaseURL: false) { + var relative = components.path.isEmpty ? "/" : components.path + if let query = components.query { + relative += "?\(query)" + } + if let fragment = components.fragment { + relative += "#\(fragment)" + } + Current.settingsStore.lastActiveURLPath = relative + } } } } diff --git a/Sources/App/Frontend/WebView/WebViewController/WebViewController.swift b/Sources/App/Frontend/WebView/WebViewController/WebViewController.swift index 55666d580..e04679cb6 100644 --- a/Sources/App/Frontend/WebView/WebViewController/WebViewController.swift +++ b/Sources/App/Frontend/WebView/WebViewController/WebViewController.swift @@ -27,6 +27,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg var latestLoadError: Error? var initialURL: URL? + var initialURLPath: String? var statusBarButtonsStack: UIStackView? var lastNavigationWasServerError = false var didHandleServerErrorResponse = false @@ -95,7 +96,9 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg } #if targetEnvironment(macCatalyst) - override var canBecomeFirstResponder: Bool { true } + override var canBecomeFirstResponder: Bool { + true + } override var keyCommands: [UIKeyCommand]? { func prioritised(_ input: String, _ action: Selector) -> UIKeyCommand { @@ -285,7 +288,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg onWebViewLoaded?(self) } - // Workaround for webview rotation issues: https://github.com/Telerik-Verified-Plugins/WKWebView/pull/263 + /// Workaround for webview rotation issues: https://github.com/Telerik-Verified-Plugins/WKWebView/pull/263 override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) coordinator.animate(alongsideTransition: { _ in diff --git a/Sources/Shared/Settings/SettingsStore.swift b/Sources/Shared/Settings/SettingsStore.swift index 1577d7a84..068c7a7d1 100644 --- a/Sources/Shared/Settings/SettingsStore.swift +++ b/Sources/Shared/Settings/SettingsStore.swift @@ -208,6 +208,24 @@ public class SettingsStore { } } + public var lastActiveServerIdentifier: String? { + get { + prefs.string(forKey: "lastActiveServerIdentifier") + } + set { + prefs.set(newValue, forKey: "lastActiveServerIdentifier") + } + } + + public var lastActiveURLPath: String? { + get { + prefs.string(forKey: "lastActiveURLPath") + } + set { + prefs.set(newValue, forKey: "lastActiveURLPath") + } + } + public func hasSeenWhatsNew(releaseID: String) -> Bool { seenWhatsNewReleaseIDs.contains(releaseID) } diff --git a/Tests/App/Container/OnboardingStateObservableTests.swift b/Tests/App/Container/OnboardingStateObservableTests.swift new file mode 100644 index 000000000..6fe95d305 --- /dev/null +++ b/Tests/App/Container/OnboardingStateObservableTests.swift @@ -0,0 +1,77 @@ +@testable import HomeAssistant +@testable import Shared +import XCTest + +@MainActor +final class OnboardingStateObservableTests: XCTestCase { + private var previousServers: ServerManager! + private var firstServer: Server! + private var secondServer: Server! + + override func setUp() { + super.setUp() + previousServers = Current.servers + let servers = FakeServerManager(initial: 0) + firstServer = servers.add(identifier: .init(rawValue: "server-1"), serverInfo: .fake()) + secondServer = servers.add(identifier: .init(rawValue: "server-2"), serverInfo: .fake()) + Current.servers = servers + + resetPersistedState() + } + + override func tearDown() { + Current.servers = previousServers + resetPersistedState() + super.tearDown() + } + + private func resetPersistedState() { + Current.settingsStore.restoreLastURL = true + Current.settingsStore.lastActiveServerIdentifier = nil + Current.settingsStore.lastActiveURLPath = nil + } + + func testPreferredInitialServerReturnsPersistedServerWhenPresent() { + Current.settingsStore.lastActiveServerIdentifier = "server-2" + + XCTAssertEqual(OnboardingStateObservable.preferredInitialServer()?.identifier, secondServer.identifier) + } + + func testPreferredInitialServerFallsBackToFirstWhenNoneStored() { + Current.settingsStore.lastActiveServerIdentifier = nil + + XCTAssertEqual(OnboardingStateObservable.preferredInitialServer()?.identifier, firstServer.identifier) + } + + func testPreferredInitialServerFallsBackToFirstWhenStoredServerMissing() { + Current.settingsStore.lastActiveServerIdentifier = "server-removed" + + XCTAssertEqual(OnboardingStateObservable.preferredInitialServer()?.identifier, firstServer.identifier) + } + + func testRestoredInitialPathReturnsStoredPathForMatchingServerWhenEnabled() { + Current.settingsStore.restoreLastURL = true + Current.settingsStore.lastActiveServerIdentifier = "server-2" + Current.settingsStore.lastActiveURLPath = "/lovelace/kitchen" + + XCTAssertEqual(OnboardingStateObservable.restoredInitialPath(for: secondServer), "/lovelace/kitchen") + } + + func testRestoredInitialPathIsNilWhenRememberLastPageOff() { + Current.settingsStore.restoreLastURL = false + Current.settingsStore.lastActiveServerIdentifier = "server-2" + Current.settingsStore.lastActiveURLPath = "/lovelace/kitchen" + + XCTAssertNil(OnboardingStateObservable.restoredInitialPath(for: secondServer)) + } + + func testRestoredInitialPathIsNilWhenServerDoesNotMatchStored() { + // Launch fell back to the first server after the saved one was removed: it must not inherit the + // path that belonged to the removed server. + Current.settingsStore.restoreLastURL = true + Current.settingsStore.lastActiveServerIdentifier = "server-2" + Current.settingsStore.lastActiveURLPath = "/lovelace/kitchen" + + XCTAssertNil(OnboardingStateObservable.restoredInitialPath(for: firstServer)) + } +} diff --git a/Tests/App/WebView/WebViewControllerTests.swift b/Tests/App/WebView/WebViewControllerTests.swift index e96dd4d32..a70e0b11f 100644 --- a/Tests/App/WebView/WebViewControllerTests.swift +++ b/Tests/App/WebView/WebViewControllerTests.swift @@ -228,8 +228,8 @@ final class WebViewControllerTests: XCTestCase { XCTAssertEqual(decision, .allow) } - func testServerErrorResponseDecisionReloadsDefaultURLForRestoredPage() { - let restoredURL = URL(string: "https://example.com/history")! + func testServerErrorResponseDecisionReloadsDefaultURLForRestoredPage() throws { + let restoredURL = try XCTUnwrap(URL(string: "https://example.com/history")) for statusCode in [404, 500] { let decision = WebViewController.decisionForMainFrameErrorResponse( @@ -243,8 +243,8 @@ final class WebViewControllerTests: XCTestCase { } } - func testServerErrorLoadErrorCarriesFailingURL() { - let url = URL(string: "https://example.com/lovelace")! + func testServerErrorLoadErrorCarriesFailingURL() throws { + let url = try XCTUnwrap(URL(string: "https://example.com/lovelace")) let error = WebViewController.serverErrorLoadError(for: url) @@ -279,6 +279,35 @@ final class WebViewControllerTests: XCTestCase { XCTAssertEqual((sut.latestLoadError as? URLError)?.code, .badServerResponse) } + func testRestoredURLRebuildsSavedPathOntoLiveBaseIgnoringSavedHost() throws { + // A path saved on the internal base is restored against whatever base is active now (e.g. remote + // UI), so only path/query/fragment carry over -- never the host. + let restored = try WebViewController.restoredURL( + base: XCTUnwrap(URL(string: "https://remote.example.com:8123")), + relativePath: "/lovelace/kitchen" + ) + + XCTAssertEqual(restored, URL(string: "https://remote.example.com:8123/lovelace/kitchen")) + } + + func testRestoredURLPreservesQueryAndFragment() throws { + let restored = try WebViewController.restoredURL( + base: XCTUnwrap(URL(string: "http://homeassistant.local:8123")), + relativePath: "/history?back=1#anchor" + ) + + XCTAssertEqual(restored, URL(string: "http://homeassistant.local:8123/history?back=1#anchor")) + } + + func testRestoredURLHandlesRootPath() throws { + let restored = try WebViewController.restoredURL( + base: XCTUnwrap(URL(string: "http://homeassistant.local:8123")), + relativePath: "/" + ) + + XCTAssertEqual(restored, URL(string: "http://homeassistant.local:8123/")) + } + private func makeSUT(server: Server = .fake()) -> WebViewController { let sut = WebViewController(server: server) let containerView = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 640))