diff --git a/Sources/App/Onboarding/Steps/Permissions/Steps/LocalAccessAndNetworkInput/NetworkInput/HomeNetworkInputView.swift b/Sources/App/Onboarding/Steps/Permissions/Steps/LocalAccessAndNetworkInput/NetworkInput/HomeNetworkInputView.swift index c5eeb477a5..dfd77beaa3 100644 --- a/Sources/App/Onboarding/Steps/Permissions/Steps/LocalAccessAndNetworkInput/NetworkInput/HomeNetworkInputView.swift +++ b/Sources/App/Onboarding/Steps/Permissions/Steps/LocalAccessAndNetworkInput/NetworkInput/HomeNetworkInputView.swift @@ -141,8 +141,9 @@ struct HomeNetworkInputView: View { } private func loadCurrentNetworkInfo() { - Current.connectivity.syncNetworkInformation { - networkName = Current.connectivity.currentWiFiSSID() ?? "" + Task { @MainActor in + let networkInfo = await Current.connectivity.currentNetworkInfo() + networkName = networkInfo.ssid ?? "" hardwareAddress = Current.connectivity.currentNetworkHardwareAddress() ?? "" } } diff --git a/Sources/App/WebView/Extensions/ConnectionInfo+WebView.swift b/Sources/App/WebView/Extensions/ConnectionInfo+WebView.swift index 9be3f4609e..591b590073 100644 --- a/Sources/App/WebView/Extensions/ConnectionInfo+WebView.swift +++ b/Sources/App/WebView/Extensions/ConnectionInfo+WebView.swift @@ -21,10 +21,35 @@ extension ConnectionInfo { return components } + /// Async version that fetches real-time network info. + mutating func webviewURLComponents() async -> URLComponents? { + if Current.appConfiguration == .fastlaneSnapshot, prefs.object(forKey: "useDemo") != nil { + return URLComponents(string: "https://companion.home-assistant.io/app/ios/demo")! + } + guard let activeURL = await activeURL() else { + Current.Log.error("No activeURL available while webviewURLComponents was called") + return nil + } + + guard var components = URLComponents(url: activeURL, resolvingAgainstBaseURL: true) else { + return nil + } + + let queryItem = URLQueryItem(name: "external_auth", value: "1") + components.queryItems = [queryItem] + + return components + } + mutating func webviewURL() -> URL? { webviewURLComponents()?.url } + /// Async version that fetches real-time network info. + mutating func webviewURL() async -> URL? { + await webviewURLComponents()?.url + } + mutating func webviewURL(from raw: String) -> URL? { guard let baseURLComponents = webviewURLComponents(), let baseURL = baseURLComponents.url else { return nil @@ -52,4 +77,33 @@ extension ConnectionInfo { return nil } } + + /// Async version that fetches real-time network info. + mutating func webviewURL(from raw: String) async -> URL? { + guard let baseURLComponents = await webviewURLComponents(), let baseURL = baseURLComponents.url else { + return nil + } + + if raw.starts(with: "/") { + if let rawComponents = URLComponents(string: raw) { + var components = baseURLComponents + components.path.append(rawComponents.path) + components.fragment = rawComponents.fragment + + if let items = rawComponents.queryItems { + var queryItems = components.queryItems ?? [] + queryItems.append(contentsOf: items) + components.queryItems = queryItems + } + + return components.url + } else { + return baseURL.appendingPathComponent(raw) + } + } else if let url = URL(string: raw), url.baseIsEqual(to: baseURL) { + return url + } else { + return nil + } + } } diff --git a/Sources/App/WebView/Views/ConnectionSecurityLevelBlock/ConnectionSecurityLevelBlockViewModel.swift b/Sources/App/WebView/Views/ConnectionSecurityLevelBlock/ConnectionSecurityLevelBlockViewModel.swift index 39621176aa..f70f397a0c 100644 --- a/Sources/App/WebView/Views/ConnectionSecurityLevelBlock/ConnectionSecurityLevelBlockViewModel.swift +++ b/Sources/App/WebView/Views/ConnectionSecurityLevelBlock/ConnectionSecurityLevelBlockViewModel.swift @@ -40,23 +40,28 @@ final class ConnectionSecurityLevelBlockViewModel: ObservableObject { } func loadRequirements() { - requirements = [] - - // Check if home network is defined - if server.info.connection.internalSSIDs?.isEmpty ?? true, - server.info.connection.internalHardwareAddresses?.isEmpty ?? true { - requirements.append(.homeNetworkMissing) - } else { - // Check if user is on home network - if !server.info.connection.isOnInternalNetwork { - requirements.append(.notOnHomeNetwork) + Task { @MainActor in + var newRequirements: [Requirement] = [] + + // Check if home network is defined + if server.info.connection.internalSSIDs?.isEmpty ?? true, + server.info.connection.internalHardwareAddresses?.isEmpty ?? true { + newRequirements.append(.homeNetworkMissing) + } else { + // Check if user is on home network (fetch real-time network info) + let isOnInternal = await server.info.connection.isOnInternalNetwork() + if !isOnInternal { + newRequirements.append(.notOnHomeNetwork) + } + } + + // Check location permission + let currentPermission = Current.locationManager.currentPermissionState + if currentPermission != .authorizedAlways, currentPermission != .authorizedWhenInUse { + newRequirements.append(.locationPermission) } - } - // Check location permission - let currentPermission = Current.locationManager.currentPermissionState - if currentPermission != .authorizedAlways, currentPermission != .authorizedWhenInUse { - requirements.append(.locationPermission) + requirements = newRequirements } } } diff --git a/Sources/App/WebView/WebViewController.swift b/Sources/App/WebView/WebViewController.swift index 5a60c0ffdd..2ef767343c 100644 --- a/Sources/App/WebView/WebViewController.swift +++ b/Sources/App/WebView/WebViewController.swift @@ -789,13 +789,13 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg loadActiveURLIfNeededInProgress = true Current.Log.info("loadActiveURLIfNeeded called") - Current.connectivity.syncNetworkInformation { [weak self] in + Task { @MainActor [weak self] in defer { self?.loadActiveURLIfNeededInProgress = false } guard let self else { return } - guard let webviewURL = server.info.connection.webviewURL() else { + guard let webviewURL = await server.info.connection.webviewURL() else { Current.Log.info("not loading, no url") showNoActiveURLError() return @@ -1534,10 +1534,10 @@ extension WebViewController: WebViewControllerProtocol { } @objc func refresh() { - Current.connectivity.syncNetworkInformation { [weak self] in + Task { @MainActor [weak self] in guard let self else { return } // called via menu/keyboard shortcut too - if let webviewURL = server.info.connection.webviewURL() { + if let webviewURL = await server.info.connection.webviewURL() { if webView.url?.baseIsEqual(to: webviewURL) == true, !lastNavigationWasServerError { reload() } else { diff --git a/Sources/Improv/ImprovDiscoverView.swift b/Sources/Improv/ImprovDiscoverView.swift index 536beffd11..fcdf9709d0 100644 --- a/Sources/Improv/ImprovDiscoverView.swift +++ b/Sources/Improv/ImprovDiscoverView.swift @@ -167,9 +167,10 @@ struct ImprovDiscoverView: View where Manager: ImprovManagerProtocol { selectedPeripheral = peripheral // This only works if location permission is permitted - Current.connectivity.syncNetworkInformation { - if let ssid = Current.connectivity.currentWiFiSSID() { - self.ssid = ssid + Task { @MainActor in + let networkInfo = await Current.connectivity.currentNetworkInfo() + if let networkSSID = networkInfo.ssid { + ssid = networkSSID } } showWifiAlert = true diff --git a/Sources/Shared/API/ConnectionInfo.swift b/Sources/Shared/API/ConnectionInfo.swift index 0174d62525..59c188e7dd 100644 --- a/Sources/Shared/API/ConnectionInfo.swift +++ b/Sources/Shared/API/ConnectionInfo.swift @@ -241,13 +241,72 @@ public struct ConnectionInfo: Codable, Equatable { activeURLType = .external url = externalURL } else if let internalURL, [.lessSecure, .undefined].contains(connectionAccessSecurityLevel) { - // Falback to internal URL if no other URL is set + // Fallback to internal URL if no other URL is set // In case user opted to not check for home network or haven't made a decision yet // we allow usage of internal URL as fallback activeURLType = .internal url = internalURL } else if let internalURL, internalURL.scheme == "https" { - // Falback to internal URL if no other URL is set and internal URL is HTTPS + // Fallback to internal URL if no other URL is set and internal URL is HTTPS + activeURLType = .internal + url = internalURL + } else { + url = nil + activeURLType = .none + } + + return url?.sanitized() + } + + /// Returns the url that should be used at this moment to access the Home Assistant instance. + /// This version fetches real-time network information asynchronously, avoiding race conditions. + public mutating func activeURL() async -> URL? { + if let overrideActiveURLType { + let overrideURL: URL? + + switch overrideActiveURLType { + case .internal: + activeURLType = .internal + overrideURL = internalURL + case .remoteUI: + activeURLType = .remoteUI + overrideURL = remoteUIURL + case .external: + activeURLType = .external + overrideURL = externalURL + case .none: + activeURLType = .none + overrideURL = nil + } + + if let overrideURL { + return overrideURL.sanitized() + } + } + + let url: URL? + let onInternalNetwork = await isOnInternalNetwork() + + if let internalURL, onInternalNetwork || overrideActiveURLType == .internal { + // Home network, local connection + activeURLType = .internal + url = internalURL + } else if let remoteUIURL, useCloud { + // Home Assistant Cloud connection + activeURLType = .remoteUI + url = remoteUIURL + } else if let externalURL { + // Custom remote connection + activeURLType = .external + url = externalURL + } else if let internalURL, [.lessSecure, .undefined].contains(connectionAccessSecurityLevel) { + // Fallback to internal URL if no other URL is set + // In case user opted to not check for home network or haven't made a decision yet + // we allow usage of internal URL as fallback + activeURLType = .internal + url = internalURL + } else if let internalURL, internalURL.scheme == "https" { + // Fallback to internal URL if no other URL is set and internal URL is HTTPS activeURLType = .internal url = internalURL } else { @@ -282,6 +341,15 @@ public struct ConnectionInfo: Codable, Equatable { } } + /// Returns the activeURL with /api appended. Fetches real-time network info. + public mutating func activeAPIURL() async -> URL? { + if let activeURL = await activeURL() { + return activeURL.appendingPathComponent("api", isDirectory: false) + } else { + return nil + } + } + public mutating func webhookURL() -> URL? { if let cloudhookURL, !isOnInternalNetwork { return cloudhookURL @@ -294,6 +362,20 @@ public struct ConnectionInfo: Codable, Equatable { } } + /// Returns the webhook URL. Fetches real-time network info. + public mutating func webhookURL() async -> URL? { + let onInternalNetwork = await isOnInternalNetwork() + if let cloudhookURL, !onInternalNetwork { + return cloudhookURL + } + + if let activeURL = await activeURL() { + return activeURL.appendingPathComponent(webhookPath, isDirectory: false) + } else { + return nil + } + } + public var webhookPath: String { "api/webhook/\(webhookID)" } @@ -321,6 +403,7 @@ public struct ConnectionInfo: Codable, Equatable { } /// Returns true if current SSID is SSID marked for internal URL use. + /// Note: This uses cached network info. For real-time network state, use `isOnInternalNetwork() async`. public var isOnInternalNetwork: Bool { if let current = Current.connectivity.currentWiFiSSID(), internalSSIDs?.contains(current) == true { @@ -335,6 +418,22 @@ public struct ConnectionInfo: Codable, Equatable { return false } + /// Returns true if current SSID is SSID marked for internal URL use. + /// This fetches real-time network information asynchronously. + public func isOnInternalNetwork() async -> Bool { + let networkInfo = await Current.connectivity.currentNetworkInfo() + if let ssid = networkInfo.ssid, internalSSIDs?.contains(ssid) == true { + return true + } + + if let current = Current.connectivity.currentNetworkHardwareAddress(), + internalHardwareAddresses?.contains(current) == true { + return true + } + + return false + } + public var hasInternalURLSet: Bool { internalURL != nil } diff --git a/Sources/Shared/Environment/ConnectivityWrapper.swift b/Sources/Shared/Environment/ConnectivityWrapper.swift index db58bd9657..2e25c78fd5 100644 --- a/Sources/Shared/Environment/ConnectivityWrapper.swift +++ b/Sources/Shared/Environment/ConnectivityWrapper.swift @@ -6,6 +6,17 @@ import Reachability import Communicator import NetworkExtension +/// Real-time network information fetched asynchronously +public struct NetworkInfo: Equatable { + public let ssid: String? + public let bssid: String? + + public init(ssid: String?, bssid: String?) { + self.ssid = ssid + self.bssid = bssid + } +} + /// Wrapper around CoreTelephony, Reachability public class ConnectivityWrapper { public var connectivityDidChangeNotification: () -> Notification.Name @@ -17,6 +28,10 @@ public class ConnectivityWrapper { public var cellularNetworkType: () -> NetworkType public var networkAttributes: () -> [String: Any] + /// Async method to fetch current WiFi network info in real-time. + /// This is the preferred method to use for critical operations that need the latest network state. + public var currentNetworkInfo: () async -> NetworkInfo + #if targetEnvironment(macCatalyst) init() { self.hasWiFi = { Current.macBridge.networkConnectivity.hasWiFi } @@ -43,6 +58,13 @@ public class ConnectivityWrapper { return [:] } } + // For macCatalyst, we can get the info synchronously from macBridge + self.currentNetworkInfo = { + NetworkInfo( + ssid: Current.macBridge.networkConnectivity.wifi?.ssid, + bssid: Current.macBridge.networkConnectivity.wifi?.bssid + ) + } } #elseif os(iOS) @@ -67,6 +89,11 @@ public class ConnectivityWrapper { self.currentNetworkHardwareAddress = { nil } self.networkAttributes = { [:] } + // Default async implementation that fetches real-time network info + self.currentNetworkInfo = { + await Self.fetchNetworkInfo() + } + syncNetworkInformation() NotificationCenter.default.addObserver( @@ -90,9 +117,11 @@ public class ConnectivityWrapper { self.cellularNetworkType = { .unknown } self.currentNetworkHardwareAddress = { nil } self.networkAttributes = { [:] } - - syncNetworkInformation() - // Reachability observer is not available for watchOS + // For watchOS, get SSID from user defaults (synced from iOS) + self.currentNetworkInfo = { + let ssid = WatchUserDefaults.shared.string(for: .watchSSID) + return NetworkInfo(ssid: ssid, bssid: nil) + } } #endif @@ -135,11 +164,32 @@ public class ConnectivityWrapper { #endif } - public func syncNetworkInformation() async { - await withCheckedContinuation { continuation in - syncNetworkInformation { - continuation.resume() + /// Fetches network info asynchronously. This should be used for operations requiring real-time data. + private static func fetchNetworkInfo() async -> NetworkInfo { + #if targetEnvironment(macCatalyst) + return NetworkInfo( + ssid: Current.macBridge.networkConnectivity.wifi?.ssid, + bssid: Current.macBridge.networkConnectivity.wifi?.bssid + ) + #elseif os(iOS) + return await withCheckedContinuation { continuation in + NEHotspotNetwork.fetchCurrent { hotspotNetwork in + #if targetEnvironment(simulator) + let ssid: String? = "Simulator" + #else + let ssid = hotspotNetwork?.ssid + #endif + let bssid = hotspotNetwork?.bssid + Current.Log + .verbose( + "Fetched network info - SSID: \(String(describing: ssid)), BSSID: \(String(describing: bssid))" + ) + continuation.resume(returning: NetworkInfo(ssid: ssid, bssid: bssid)) } } + #else + let ssid = WatchUserDefaults.shared.string(for: .watchSSID) + return NetworkInfo(ssid: ssid, bssid: nil) + #endif } } diff --git a/Tests/Shared/ConnectionInfo.test.swift b/Tests/Shared/ConnectionInfo.test.swift index 54a49c7ed7..acaf23952b 100644 --- a/Tests/Shared/ConnectionInfo.test.swift +++ b/Tests/Shared/ConnectionInfo.test.swift @@ -701,4 +701,128 @@ class ConnectionInfoTests: XCTestCase { XCTAssertEqual(info.activeURL(), internalURL) XCTAssertEqual(info.activeURLType, .internal) } + + // MARK: - Async API Tests + + func testAsyncActiveURLWithInternalNetwork() async { + let internalURL = URL(string: "http://internal.example.com:8123") + let externalURL = URL(string: "http://external.example.com:8123") + var info = ConnectionInfo( + externalURL: externalURL, + internalURL: internalURL, + cloudhookURL: nil, + remoteUIURL: nil, + webhookID: "webhook_id1", + webhookSecret: nil, + internalSSIDs: ["unit_tests"], + internalHardwareAddresses: nil, + isLocalPushEnabled: false, + securityExceptions: .init(), + connectionAccessSecurityLevel: .undefined + ) + + // Mock the async currentNetworkInfo to return the internal network SSID + Current.connectivity.currentNetworkInfo = { + NetworkInfo(ssid: "unit_tests", bssid: nil) + } + + let url = await info.activeURL() + XCTAssertEqual(url, internalURL) + XCTAssertEqual(info.activeURLType, .internal) + } + + func testAsyncActiveURLWithExternalNetwork() async { + let internalURL = URL(string: "http://internal.example.com:8123") + let externalURL = URL(string: "http://external.example.com:8123") + var info = ConnectionInfo( + externalURL: externalURL, + internalURL: internalURL, + cloudhookURL: nil, + remoteUIURL: nil, + webhookID: "webhook_id1", + webhookSecret: nil, + internalSSIDs: ["home_network"], + internalHardwareAddresses: nil, + isLocalPushEnabled: false, + securityExceptions: .init(), + connectionAccessSecurityLevel: .undefined + ) + + // Mock the async currentNetworkInfo to return a different network + Current.connectivity.currentNetworkInfo = { + NetworkInfo(ssid: "coffee_shop", bssid: nil) + } + + let url = await info.activeURL() + XCTAssertEqual(url, externalURL) + XCTAssertEqual(info.activeURLType, .external) + } + + func testAsyncIsOnInternalNetwork() async { + var info = ConnectionInfo( + externalURL: URL(string: "http://external.example.com:8123"), + internalURL: URL(string: "http://internal.example.com:8123"), + cloudhookURL: nil, + remoteUIURL: nil, + webhookID: "webhook_id1", + webhookSecret: nil, + internalSSIDs: ["home_wifi"], + internalHardwareAddresses: nil, + isLocalPushEnabled: false, + securityExceptions: .init(), + connectionAccessSecurityLevel: .undefined + ) + + // Test when on internal network + Current.connectivity.currentNetworkInfo = { + NetworkInfo(ssid: "home_wifi", bssid: nil) + } + + let onInternal = await info.isOnInternalNetwork() + XCTAssertTrue(onInternal) + + // Test when on external network + Current.connectivity.currentNetworkInfo = { + NetworkInfo(ssid: "other_wifi", bssid: nil) + } + + let onExternal = await info.isOnInternalNetwork() + XCTAssertFalse(onExternal) + } + + func testAsyncWebhookURL() async { + let internalURL = URL(string: "http://internal.example.com:8123") + let externalURL = URL(string: "http://external.example.com:8123") + let cloudhookURL = URL(string: "http://cloudhook.example.com") + + var info = ConnectionInfo( + externalURL: externalURL, + internalURL: internalURL, + cloudhookURL: cloudhookURL, + remoteUIURL: nil, + webhookID: "webhook_id1", + webhookSecret: nil, + internalSSIDs: ["home_wifi"], + internalHardwareAddresses: nil, + isLocalPushEnabled: false, + securityExceptions: .init(), + connectionAccessSecurityLevel: .undefined + ) + + // Test when on internal network - should return internal URL webhook + Current.connectivity.currentNetworkInfo = { + NetworkInfo(ssid: "home_wifi", bssid: nil) + } + + let internalWebhookURL = await info.webhookURL() + XCTAssertEqual(internalWebhookURL, internalURL?.appendingPathComponent("api/webhook/webhook_id1")) + + // Test when on external network - should return cloudhook URL + Current.connectivity.currentNetworkInfo = { + NetworkInfo(ssid: "coffee_shop", bssid: nil) + } + + let externalWebhookURL = await info.webhookURL() + XCTAssertEqual(externalWebhookURL, cloudhookURL) + } }