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
8 changes: 5 additions & 3 deletions Sources/App/Container/ContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
}
45 changes: 33 additions & 12 deletions Sources/App/Container/OnboardingStateObservable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -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? {
Expand All @@ -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)
}
Expand All @@ -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.
Expand All @@ -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)
Expand Down
10 changes: 5 additions & 5 deletions Sources/App/Frontend/WebView/FrontendView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand All @@ -15,15 +15,15 @@ struct FrontendView: UIViewControllerRepresentable {

init(
server: Server,
restorationType: WebViewRestorationType? = nil,
initialPath: String? = nil,
onWebViewController: ((WebViewController) -> Void)? = nil,
onWebViewLoaded: ((WebViewController) -> Void)? = nil,
resetFrontendAction: (() -> Void)? = nil,
reconnectManager: WebViewReconnectManager? = nil,
overlayState: WebFrontendOverlayState
) {
self.server = server
self.restorationType = restorationType
self.initialPath = initialPath
self.onWebViewController = onWebViewController
self.onWebViewLoaded = onWebViewLoaded
self.resetFrontendAction = resetFrontendAction
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ final class HomeAssistantViewModel: ObservableObject {
}

let server: Server
let initialPath: String?
let overlayState: WebFrontendOverlayState
let chrome: WebViewChromeState
let reconnectManager: WebViewReconnectManager
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions Sources/Shared/Settings/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading