mirror of
https://github.com/home-assistant/iOS.git
synced 2026-04-12 15:26:45 -05: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 --> Hide connection security level config when user has no internal URL set or has one that is already HTTPS ## Screenshots <!-- If this is a user-facing change not in the frontend, please include screenshots in light and dark mode. --> ## 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. -->
321 lines
11 KiB
Swift
321 lines
11 KiB
Swift
import Combine
|
|
import Foundation
|
|
import HAKit
|
|
import PromiseKit
|
|
import Shared
|
|
import UIKit
|
|
|
|
/// ViewModel for ConnectionSettingsView, managing server connection settings and state
|
|
@MainActor
|
|
final class ConnectionSettingsViewModel: ObservableObject {
|
|
// MARK: - Published Properties
|
|
|
|
@Published var serverName: String = ""
|
|
@Published var connectionPath: String = ""
|
|
@Published var version: String = ""
|
|
@Published var websocketState: HAConnectionState?
|
|
@Published var localPushStatus: String = ""
|
|
@Published var loggedInUser: String = ""
|
|
@Published var locationName: String = ""
|
|
@Published var deviceName: String = ""
|
|
@Published var internalURL: String = ""
|
|
@Published var externalURL: String = ""
|
|
@Published var securityLevel: ConnectionSecurityLevel = .mostSecure
|
|
@Published var locationPrivacy: ServerLocationPrivacy = .never
|
|
@Published var sensorPrivacy: ServerSensorPrivacy = .none
|
|
@Published var showDeleteConfirmation = false
|
|
@Published var isDeleting = false
|
|
@Published var clientCertificate: ClientCertificate?
|
|
@Published var isImportingCertificate = false
|
|
@Published var certificateError: Error?
|
|
|
|
// MARK: - Properties
|
|
|
|
let server: Server
|
|
private var tokens: [HACancellable] = []
|
|
private var localPushObserver: HACancellable?
|
|
private var notificationCenterObserver: NSObjectProtocol?
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
var canShareServer: Bool {
|
|
server.info.connection.invitationURL() != nil
|
|
}
|
|
|
|
var hasMultipleServers: Bool {
|
|
Current.servers.all.count > 1
|
|
}
|
|
|
|
var versionRequiresLocationGPSOptional: Bool {
|
|
server.info.version <= .updateLocationGPSOptional
|
|
}
|
|
|
|
var shouldShowSecurityLevelPicker: Bool {
|
|
guard let internalURL = server.info.connection.address(for: .internal) else {
|
|
return false
|
|
}
|
|
|
|
return internalURL.scheme?.lowercased() == "http"
|
|
}
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(server: Server) {
|
|
self.server = server
|
|
setupObservers()
|
|
loadInitialData()
|
|
}
|
|
|
|
deinit {
|
|
tokens.forEach { $0.cancel() }
|
|
localPushObserver?.cancel()
|
|
if let observer = notificationCenterObserver {
|
|
NotificationCenter.default.removeObserver(observer)
|
|
}
|
|
}
|
|
|
|
func updateAppDatabase() {
|
|
server.refreshAppDatabase(forceUpdate: true)
|
|
}
|
|
|
|
// MARK: - Setup
|
|
|
|
private func setupObservers() {
|
|
// Observe server info changes
|
|
tokens.append(server.observe { [weak self] info in
|
|
self?.updateFromServerInfo(info)
|
|
})
|
|
|
|
// Observe websocket connection
|
|
if let connection = Current.api(for: server)?.connection {
|
|
// Observe websocket state
|
|
notificationCenterObserver = NotificationCenter.default.addObserver(
|
|
forName: HAConnectionState.didTransitionToStateNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
guard let self else { return }
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
self.websocketState = Current.api(for: self.server)?.connection.state
|
|
}
|
|
}
|
|
websocketState = connection.state
|
|
|
|
// Observe logged in user
|
|
tokens.append(connection.caches.user.subscribe { [weak self] _, user in
|
|
guard let self else { return }
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
self.loggedInUser = user.name ?? ""
|
|
}
|
|
})
|
|
}
|
|
|
|
// Observe local push notifications
|
|
let manager = Current.notificationManager.localPushManager
|
|
localPushObserver = manager.addObserver(for: server) { [weak self] _ in
|
|
self?.updateLocalPushStatus()
|
|
}
|
|
updateLocalPushStatus()
|
|
}
|
|
|
|
private func loadInitialData() {
|
|
updateFromServerInfo(server.info)
|
|
updateURLs()
|
|
clientCertificate = server.info.connection.clientCertificate
|
|
}
|
|
|
|
private func updateFromServerInfo(_ info: ServerInfo) {
|
|
serverName = info.name
|
|
connectionPath = info.connection.activeURLType.description
|
|
version = info.version.description
|
|
locationName = info.setting(for: .localName) ?? ""
|
|
deviceName = info.setting(for: .overrideDeviceName) ?? ""
|
|
securityLevel = info.connection.connectionAccessSecurityLevel
|
|
locationPrivacy = info.setting(for: .locationPrivacy)
|
|
sensorPrivacy = info.setting(for: .sensorPrivacy)
|
|
updateURLs()
|
|
}
|
|
|
|
private func updateURLs() {
|
|
internalURL = server.info.connection.address(for: .internal)?.absoluteString ?? "—"
|
|
|
|
if server.info.connection.useCloud, server.info.connection.canUseCloud {
|
|
externalURL = L10n.Settings.ConnectionSection.HomeAssistantCloud.title
|
|
} else {
|
|
externalURL = server.info.connection.address(for: .external)?.absoluteString ?? "—"
|
|
}
|
|
}
|
|
|
|
private func updateLocalPushStatus() {
|
|
let manager = Current.notificationManager.localPushManager
|
|
switch manager.status(for: server) {
|
|
case .disabled:
|
|
localPushStatus = L10n.SettingsDetails.Notifications.LocalPush.Status.disabled
|
|
case .unsupported:
|
|
localPushStatus = L10n.SettingsDetails.Notifications.LocalPush.Status.unsupported
|
|
case let .allowed(state):
|
|
switch state {
|
|
case .unavailable:
|
|
localPushStatus = L10n.SettingsDetails.Notifications.LocalPush.Status.unavailable
|
|
case .establishing:
|
|
localPushStatus = L10n.SettingsDetails.Notifications.LocalPush.Status.establishing
|
|
case let .available(received: received):
|
|
let formatted = NumberFormatter.localizedString(
|
|
from: NSNumber(value: received),
|
|
number: .decimal
|
|
)
|
|
localPushStatus = L10n.SettingsDetails.Notifications.LocalPush.Status.available(formatted)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
func updateLocationName(_ newName: String?) {
|
|
server.info.setSetting(value: newName, for: .localName)
|
|
locationName = newName ?? ""
|
|
}
|
|
|
|
func updateDeviceName(_ newName: String?) {
|
|
server.info.setSetting(value: newName, for: .overrideDeviceName)
|
|
deviceName = newName ?? ""
|
|
}
|
|
|
|
func updateSecurityLevel(_ level: ConnectionSecurityLevel) {
|
|
server.update { info in
|
|
info.connection.connectionAccessSecurityLevel = level
|
|
}
|
|
securityLevel = level
|
|
}
|
|
|
|
func updateLocationPrivacy(_ privacy: ServerLocationPrivacy) {
|
|
server.info.setSetting(value: privacy, for: .locationPrivacy)
|
|
locationPrivacy = privacy
|
|
HomeAssistantAPI.manuallyUpdate(
|
|
applicationState: UIApplication.shared.applicationState,
|
|
type: .programmatic
|
|
).cauterize()
|
|
}
|
|
|
|
func updateSensorPrivacy(_ privacy: ServerSensorPrivacy) {
|
|
server.info.setSetting(value: privacy, for: .sensorPrivacy)
|
|
sensorPrivacy = privacy
|
|
Current.api(for: server)?.registerSensors().cauterize()
|
|
}
|
|
|
|
func shareServer() -> UIActivityViewController? {
|
|
guard let invitationServerURL = server.info.connection.invitationURL() else {
|
|
Current.Log.error("Invitation button failed, no invitation URL found for server \(server.identifier)")
|
|
return nil
|
|
}
|
|
|
|
guard let invitationURL = AppConstants.invitationURL(serverURL: invitationServerURL) else {
|
|
Current.Log
|
|
.error("Invitation button failed, could not create invitation URL for server \(server.identifier)")
|
|
return nil
|
|
}
|
|
|
|
return UIActivityViewController(activityItems: [invitationURL], applicationActivities: nil)
|
|
}
|
|
|
|
func activateServer() {
|
|
if Current.isCatalyst, Current.settingsStore.macNativeFeaturesOnly {
|
|
if let url = server.info.connection.activeURL() {
|
|
URLOpener.shared.open(url, options: [:], completionHandler: nil)
|
|
}
|
|
} else {
|
|
Current.sceneManager.webViewWindowControllerPromise.done {
|
|
$0.open(server: self.server)
|
|
}
|
|
}
|
|
}
|
|
|
|
func deleteServer() async throws {
|
|
isDeleting = true
|
|
defer { isDeleting = false }
|
|
|
|
let waitAtLeast = after(seconds: 3.0)
|
|
|
|
await race(
|
|
when(resolved: Current.apis.map { $0.tokenManager.revokeToken() }).asVoid(),
|
|
after(seconds: 10.0)
|
|
).async()
|
|
|
|
await waitAtLeast.async()
|
|
|
|
Current.api(for: server)?.connection.disconnect()
|
|
Current.servers.remove(identifier: server.identifier)
|
|
Current.onboardingObservation.needed(.logout)
|
|
}
|
|
|
|
// MARK: - Client Certificate
|
|
|
|
/// Import a PKCS#12 certificate file
|
|
func importCertificate(from url: URL, password: String) async {
|
|
isImportingCertificate = true
|
|
certificateError = nil
|
|
|
|
// Perform file I/O and Keychain work off the main actor
|
|
let serverIdentifier = server.identifier.rawValue
|
|
let resultTask = Task.detached(priority: .utility) { () -> Result<ClientCertificate> in
|
|
// Access security-scoped resource for file-importer URLs
|
|
guard url.startAccessingSecurityScopedResource() else {
|
|
return .rejected(ClientCertificateError.invalidP12Data)
|
|
}
|
|
defer { url.stopAccessingSecurityScopedResource() }
|
|
|
|
do {
|
|
// Read the file data
|
|
let data = try Data(contentsOf: url)
|
|
|
|
// Import into Keychain
|
|
let certificate = try ClientCertificateManager.shared.importP12(
|
|
data: data,
|
|
password: password,
|
|
identifier: serverIdentifier
|
|
)
|
|
return .fulfilled(certificate)
|
|
} catch {
|
|
return .rejected(error)
|
|
}
|
|
}
|
|
let result = await resultTask.value
|
|
|
|
switch result {
|
|
case let .fulfilled(certificate):
|
|
// Update server connection info
|
|
server.update { info in
|
|
info.connection.clientCertificate = certificate
|
|
}
|
|
|
|
clientCertificate = certificate
|
|
Current.Log.info("Successfully imported client certificate: \(certificate.displayName)")
|
|
case let .rejected(error):
|
|
Current.Log.error("Failed to import certificate: \(error)")
|
|
certificateError = error
|
|
}
|
|
|
|
isImportingCertificate = false
|
|
}
|
|
|
|
/// Remove the current client certificate
|
|
func removeCertificate() {
|
|
guard let certificate = clientCertificate else { return }
|
|
|
|
do {
|
|
try ClientCertificateManager.shared.delete(certificate: certificate)
|
|
} catch {
|
|
Current.Log.error("Failed to delete certificate from Keychain: \(error)")
|
|
}
|
|
|
|
server.update { info in
|
|
info.connection.clientCertificate = nil
|
|
}
|
|
|
|
clientCertificate = nil
|
|
Current.Log.info("Removed client certificate")
|
|
}
|
|
}
|