iOS/Sources/Shared/API/ConnectionInfo.swift

327 lines
10 KiB
Swift

import Alamofire
import Foundation
import Version
#if os(watchOS)
import Communicator
#endif
public struct ConnectionInfo: Codable, Equatable {
private var externalURL: URL?
private 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 internalSSIDs: [String]? {
didSet {
overrideActiveURLType = nil
}
}
public var internalHardwareAddresses: [String]? {
didSet {
overrideActiveURLType = nil
}
}
public var canUseCloud: Bool {
remoteUIURL != nil
}
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
) {
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
}
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.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 {
activeURLType = .internal
url = internalURL
} else if let remoteUIURL, useCloud {
activeURLType = .remoteUI
url = remoteUIURL
} else if let externalURL {
activeURLType = .external
url = externalURL
} else {
activeURLType = .none
url = nil
/*
No URL that can be used in this context is available
This can happen when only internal URL is set and
user tries to access the App remotely
*/
}
return url?.sanitized()
}
/// 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
}
}
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
}
/// 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))
}
}
completion(.success(updatedRequest))
}
}