mirror of
https://github.com/home-assistant/iOS.git
synced 2026-02-04 02:46:35 -06:00
<!-- Thank you for submitting a Pull Request and helping to improve Home Assistant. Please complete the following sections to help the processing and review of your changes. Please do not delete anything from this template. --> ## Summary <!-- Provide a brief summary of the changes you have made and most importantly what they aim to achieve --> ## Screenshots <!-- If this is a user-facing change not in the frontend, please include screenshots in light and dark mode. --> <img width="2360" height="1640" alt="Simulator Screenshot - iPad Air 11-inch (M3) - 2026-01-21 at 13 27 07" src="https://github.com/user-attachments/assets/1f7aac7c-2b1b-4252-b81d-e2ca27c40700" /> ## Link to pull request in Documentation repository <!-- Pull requests that add, change or remove functionality must have a corresponding pull request in the Companion App Documentation repository (https://github.com/home-assistant/companion.home-assistant). Please add the number of this pull request after the "#" --> Documentation: home-assistant/companion.home-assistant# ## Any other notes <!-- If there is any other information of note, like if this Pull Request is part of a bigger change, please include it here. --> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
410 lines
14 KiB
Swift
410 lines
14 KiB
Swift
import Alamofire
|
|
import Foundation
|
|
import Version
|
|
#if os(watchOS)
|
|
import Communicator
|
|
#endif
|
|
|
|
public enum ConnectionSecurityLevel: String, Codable {
|
|
// User has not opted in or out of security checks
|
|
case undefined
|
|
// Checks for home network before connecting to non-https URLs
|
|
case mostSecure
|
|
// Allows non-https URLs always
|
|
case lessSecure
|
|
|
|
public var description: String {
|
|
switch self {
|
|
case .undefined:
|
|
return L10n.Settings.ConnectionSection.ConnectionAccessSecurityLevel.Undefined.title
|
|
case .mostSecure:
|
|
return L10n.Settings.ConnectionSection.ConnectionAccessSecurityLevel.MostSecure.title
|
|
case .lessSecure:
|
|
return L10n.Settings.ConnectionSection.ConnectionAccessSecurityLevel.LessSecure.title
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct ConnectionInfo: Codable, Equatable {
|
|
private var externalURL: URL?
|
|
public private(set) var internalURL: URL?
|
|
private var remoteUIURL: URL?
|
|
public var webhookID: String
|
|
public var webhookSecret: String?
|
|
public var useCloud: Bool = false
|
|
public var cloudhookURL: URL?
|
|
public var connectionAccessSecurityLevel: ConnectionSecurityLevel = .undefined
|
|
public var internalSSIDs: [String]? {
|
|
didSet {
|
|
overrideActiveURLType = nil
|
|
}
|
|
}
|
|
|
|
public var internalHardwareAddresses: [String]? {
|
|
didSet {
|
|
overrideActiveURLType = nil
|
|
}
|
|
}
|
|
|
|
public var canUseCloud: Bool {
|
|
remoteUIURL != nil
|
|
}
|
|
|
|
public var hasRemoteConnectionSetup: Bool {
|
|
externalURL != nil || remoteUIURL != nil
|
|
}
|
|
|
|
public var hasNonHTTPSURLOption: Bool {
|
|
let https = "https"
|
|
if let externalURL, externalURL.scheme?.lowercased() != https {
|
|
return true
|
|
}
|
|
|
|
if let internalURL, internalURL.scheme?.lowercased() != https {
|
|
return true
|
|
}
|
|
|
|
if let remoteUIURL, remoteUIURL.scheme?.lowercased() != https {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
public var overrideActiveURLType: URLType?
|
|
public private(set) var activeURLType: URLType = .external
|
|
|
|
public var isLocalPushEnabled = true {
|
|
didSet {
|
|
guard oldValue != isLocalPushEnabled else { return }
|
|
Current.Log.verbose("updated local push from \(oldValue) to \(isLocalPushEnabled)")
|
|
}
|
|
}
|
|
|
|
public var securityExceptions: SecurityExceptions = .init()
|
|
public func evaluate(_ challenge: URLAuthenticationChallenge)
|
|
-> (URLSession.AuthChallengeDisposition, URLCredential?) {
|
|
securityExceptions.evaluate(challenge)
|
|
}
|
|
|
|
public init(
|
|
externalURL: URL?,
|
|
internalURL: URL?,
|
|
cloudhookURL: URL?,
|
|
remoteUIURL: URL?,
|
|
webhookID: String,
|
|
webhookSecret: String?,
|
|
internalSSIDs: [String]?,
|
|
internalHardwareAddresses: [String]?,
|
|
isLocalPushEnabled: Bool,
|
|
securityExceptions: SecurityExceptions,
|
|
connectionAccessSecurityLevel: ConnectionSecurityLevel
|
|
) {
|
|
self.externalURL = externalURL
|
|
self.internalURL = internalURL
|
|
self.cloudhookURL = cloudhookURL
|
|
self.remoteUIURL = remoteUIURL
|
|
self.webhookID = webhookID
|
|
self.webhookSecret = webhookSecret
|
|
self.internalSSIDs = internalSSIDs
|
|
self.internalHardwareAddresses = internalHardwareAddresses
|
|
self.isLocalPushEnabled = isLocalPushEnabled
|
|
self.securityExceptions = securityExceptions
|
|
self.connectionAccessSecurityLevel = connectionAccessSecurityLevel
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.externalURL = try container.decodeIfPresent(URL.self, forKey: .externalURL)
|
|
self.internalURL = try container.decodeIfPresent(URL.self, forKey: .internalURL)
|
|
self.remoteUIURL = try container.decodeIfPresent(URL.self, forKey: .remoteUIURL)
|
|
self.webhookID = try container.decode(String.self, forKey: .webhookID)
|
|
self.webhookSecret = try container.decodeIfPresent(String.self, forKey: .webhookSecret)
|
|
self.cloudhookURL = try container.decodeIfPresent(URL.self, forKey: .cloudhookURL)
|
|
self.internalSSIDs = try container.decodeIfPresent([String].self, forKey: .internalSSIDs)
|
|
self.internalHardwareAddresses =
|
|
try container.decodeIfPresent([String].self, forKey: .internalHardwareAddresses)
|
|
self.useCloud = try container.decodeIfPresent(Bool.self, forKey: .useCloud) ?? false
|
|
self.connectionAccessSecurityLevel = try container.decodeIfPresent(
|
|
ConnectionSecurityLevel.self,
|
|
forKey: .connectionAccessSecurityLevel
|
|
) ?? .undefined
|
|
self.isLocalPushEnabled = try container.decodeIfPresent(Bool.self, forKey: .isLocalPushEnabled) ?? true
|
|
self.securityExceptions = try container.decodeIfPresent(
|
|
SecurityExceptions.self,
|
|
forKey: .securityExceptions
|
|
) ?? .init()
|
|
}
|
|
|
|
public enum URLType: Int, Codable, CaseIterable, CustomStringConvertible, CustomDebugStringConvertible {
|
|
case `internal`
|
|
case remoteUI
|
|
case external
|
|
case none
|
|
|
|
public var debugDescription: String {
|
|
switch self {
|
|
case .internal:
|
|
return "Internal URL"
|
|
case .remoteUI:
|
|
return "Remote UI"
|
|
case .external:
|
|
return "External URL"
|
|
case .none:
|
|
return "No URL (Active URL nil)"
|
|
}
|
|
}
|
|
|
|
public var description: String {
|
|
switch self {
|
|
case .internal:
|
|
return L10n.Settings.ConnectionSection.InternalBaseUrl.title
|
|
case .remoteUI:
|
|
return L10n.Settings.ConnectionSection.RemoteUiUrl.title
|
|
case .external:
|
|
return L10n.Settings.ConnectionSection.ExternalBaseUrl.title
|
|
case .none:
|
|
return L10n.Settings.ConnectionSection.NoBaseUrl.title
|
|
}
|
|
}
|
|
|
|
public var isAffectedBySSID: Bool {
|
|
switch self {
|
|
case .internal: return true
|
|
case .remoteUI, .external, .none: return false
|
|
}
|
|
}
|
|
|
|
public var isAffectedByCloud: Bool {
|
|
switch self {
|
|
case .internal: return false
|
|
case .remoteUI, .external, .none: return true
|
|
}
|
|
}
|
|
|
|
public var isAffectedByHardwareAddress: Bool {
|
|
switch self {
|
|
case .internal: return Current.isCatalyst
|
|
case .remoteUI, .external, .none: return false
|
|
}
|
|
}
|
|
|
|
public var hasLocalPush: Bool {
|
|
switch self {
|
|
case .internal:
|
|
if Current.isCatalyst {
|
|
return false
|
|
}
|
|
return true
|
|
default: return false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns the url that should be used at this moment to access the Home Assistant instance.
|
|
public mutating func activeURL() -> 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?
|
|
|
|
if let internalURL, isOnInternalNetwork || 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) {
|
|
// Falback 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
|
|
activeURLType = .internal
|
|
url = internalURL
|
|
} else {
|
|
url = nil
|
|
activeURLType = .none
|
|
}
|
|
|
|
return url?.sanitized()
|
|
}
|
|
|
|
/// Returns the url that should be used at this moment to share with someone else to access the Home Assistant
|
|
/// instance.
|
|
/// Cloud > Remote > Internal
|
|
public func invitationURL() -> URL? {
|
|
if useCloud, let remoteUIURL {
|
|
return remoteUIURL.sanitized()
|
|
} else if let externalURL {
|
|
return externalURL.sanitized()
|
|
} else if let internalURL {
|
|
return internalURL.sanitized()
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Returns the activeURL with /api appended.
|
|
public mutating func activeAPIURL() -> URL? {
|
|
if let activeURL = activeURL() {
|
|
return activeURL.appendingPathComponent("api", isDirectory: false)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public mutating func webhookURL() -> URL? {
|
|
if let cloudhookURL, !isOnInternalNetwork {
|
|
return cloudhookURL
|
|
}
|
|
|
|
if let activeURL = activeURL() {
|
|
return activeURL.appendingPathComponent(webhookPath, isDirectory: false)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public var webhookPath: String {
|
|
"api/webhook/\(webhookID)"
|
|
}
|
|
|
|
public func address(for addressType: URLType) -> URL? {
|
|
switch addressType {
|
|
case .internal: return internalURL
|
|
case .external: return externalURL
|
|
case .remoteUI: return remoteUIURL
|
|
case .none: return nil
|
|
}
|
|
}
|
|
|
|
/// Returns a URL for troubleshooting purposes, such as displaying in error messages or running connectivity checks.
|
|
/// This method provides read-only access to connection URLs that are otherwise private.
|
|
/// - Parameter type: The type of URL to retrieve for troubleshooting
|
|
/// - Returns: The URL for the specified type, if available
|
|
public func urlForTroubleshooting(type: URLType) -> URL? {
|
|
address(for: type)
|
|
}
|
|
|
|
public mutating func set(address: URL?, for addressType: URLType) {
|
|
switch addressType {
|
|
case .internal:
|
|
internalURL = address
|
|
case .external:
|
|
externalURL = address
|
|
case .remoteUI:
|
|
remoteUIURL = address
|
|
case .none:
|
|
break
|
|
}
|
|
}
|
|
|
|
/// Returns true if current SSID is SSID marked for internal URL use.
|
|
public var isOnInternalNetwork: Bool {
|
|
if let current = Current.connectivity.currentWiFiSSID(),
|
|
internalSSIDs?.contains(current) == true {
|
|
return true
|
|
}
|
|
|
|
if let current = Current.connectivity.currentNetworkHardwareAddress(),
|
|
internalHardwareAddresses?.contains(current) == true {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
public var hasInternalURLSet: Bool {
|
|
internalURL != nil
|
|
}
|
|
|
|
/// Secret as byte array
|
|
func webhookSecretBytes(version: Version) -> [UInt8]? {
|
|
guard let webhookSecret, webhookSecret.count.isMultiple(of: 2) else {
|
|
return nil
|
|
}
|
|
|
|
guard version >= .fullWebhookSecretKey else {
|
|
if let end = webhookSecret.index(
|
|
webhookSecret.startIndex,
|
|
offsetBy: 32,
|
|
limitedBy: webhookSecret.endIndex
|
|
) {
|
|
return .init(webhookSecret.utf8[webhookSecret.startIndex ..< end])
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var stringIterator = webhookSecret.makeIterator()
|
|
|
|
return Array(AnyIterator<UInt8> {
|
|
guard let first = stringIterator.next(), let second = stringIterator.next() else {
|
|
return nil
|
|
}
|
|
|
|
return UInt8(String(first) + String(second), radix: 16)
|
|
})
|
|
}
|
|
}
|
|
|
|
class ServerRequestAdapter: RequestAdapter {
|
|
let server: Server
|
|
|
|
init(server: Server) {
|
|
self.server = server
|
|
}
|
|
|
|
func adapt(
|
|
_ urlRequest: URLRequest,
|
|
for session: Session,
|
|
completion: @escaping (Result<URLRequest, Error>) -> Void
|
|
) {
|
|
var updatedRequest: URLRequest = urlRequest
|
|
|
|
if let currentURL = urlRequest.url {
|
|
if let activeURL = server.info.connection.activeURL() {
|
|
let expectedURL = activeURL.adapting(url: currentURL)
|
|
if currentURL != expectedURL {
|
|
Current.Log.verbose("Changing request URL from \(currentURL) to \(expectedURL)")
|
|
updatedRequest.url = expectedURL
|
|
}
|
|
} else {
|
|
Current.Log.error("ActiveURL was not avaiable when ServerRequestAdapter adapt was called")
|
|
completion(.failure(ServerConnectionError.noActiveURL(server.info.name)))
|
|
}
|
|
}
|
|
|
|
completion(.success(updatedRequest))
|
|
}
|
|
}
|