mirror of
https://github.com/home-assistant/iOS.git
synced 2026-06-25 06:22:24 -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 --> This PR adds functionality of resetting web cache when server version changes and also refactor WebView direct access around the project, becoming now private to WebViewController and only interactable through the controller's protocol. ## 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. -->
466 lines
21 KiB
Swift
466 lines
21 KiB
Swift
import CoreBluetooth
|
|
import Foundation
|
|
import Improv_iOS
|
|
import PromiseKit
|
|
@preconcurrency import Shared
|
|
import SwiftMessages
|
|
import SwiftUI
|
|
|
|
protocol WebViewExternalMessageHandlerProtocol {
|
|
var webViewController: WebViewControllerProtocol? { get set }
|
|
func handleExternalMessage(_ dictionary: [String: Any])
|
|
func sendExternalBus(message: WebSocketMessage) -> Promise<Void>
|
|
|
|
// TODO: Move these methods below to their proper handlers
|
|
func scanImprov()
|
|
func stopImprovScanIfNeeded()
|
|
func showAssist(server: Server, pipeline: String, autoStartRecording: Bool, animated: Bool)
|
|
}
|
|
|
|
final class WebViewExternalMessageHandler: @preconcurrency WebViewExternalMessageHandlerProtocol {
|
|
weak var webViewController: WebViewControllerProtocol?
|
|
private let improvManager: any ImprovManagerProtocol
|
|
|
|
private var improvController: UIViewController?
|
|
|
|
init(
|
|
improvManager: any ImprovManagerProtocol
|
|
) {
|
|
self.improvManager = improvManager
|
|
}
|
|
|
|
@MainActor
|
|
func handleExternalMessage(_ dictionary: [String: Any]) {
|
|
guard let webViewController else {
|
|
Current.Log.error("WebViewExternalMessageHandler has nil webViewController")
|
|
return
|
|
}
|
|
guard let incomingMessage = WebSocketMessage(dictionary) else {
|
|
Current.Log.error("Received invalid external message \(dictionary)")
|
|
return
|
|
}
|
|
|
|
var response: Guarantee<WebSocketMessage>?
|
|
|
|
if let externalBusMessage = WebViewExternalBusMessage(rawValue: incomingMessage.MessageType) {
|
|
switch externalBusMessage {
|
|
case .configGet:
|
|
response = Guarantee { seal in
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
seal(WebSocketMessage(
|
|
id: incomingMessage.ID!,
|
|
type: "result",
|
|
result: [
|
|
"hasSettingsScreen": !Current.isCatalyst,
|
|
"canWriteTag": Current.tags.isNFCAvailable,
|
|
"canCommissionMatter": Current.matter.isAvailable,
|
|
"canImportThreadCredentials": Current.matter.threadCredentialsSharingEnabled,
|
|
"hasBarCodeScanner": true,
|
|
"canTransferThreadCredentialsToKeychain": Current.matter
|
|
.threadCredentialsStoreInKeychainEnabled,
|
|
"hasAssist": true,
|
|
"canSetupImprov": true,
|
|
]
|
|
))
|
|
}
|
|
}
|
|
case .configScreenShow:
|
|
showSettingsViewController()
|
|
case .haptic:
|
|
guard let hapticType = incomingMessage.Payload?["hapticType"] as? String else {
|
|
Current.Log.error("Received haptic via bus but hapticType was not string! \(incomingMessage)")
|
|
return
|
|
}
|
|
handleHaptic(hapticType)
|
|
case .connectionStatus:
|
|
guard let connEvt = incomingMessage.Payload?["event"] as? String else {
|
|
Current.Log.error("Received connection-status via bus but event was not string! \(incomingMessage)")
|
|
return
|
|
}
|
|
webViewController.updateFrontendConnectionState(state: connEvt)
|
|
case .tagRead:
|
|
response = Current.tags.readNFC().map { tag in
|
|
WebSocketMessage(id: incomingMessage.ID!, type: "result", result: ["success": true, "tag": tag])
|
|
}.recover { _ in
|
|
.value(WebSocketMessage(id: incomingMessage.ID!, type: "result", result: ["success": false]))
|
|
}
|
|
case .tagWrite:
|
|
let (promise, seal) = Guarantee<Bool>.pending()
|
|
response = promise.map { success in
|
|
WebSocketMessage(id: incomingMessage.ID!, type: "result", result: ["success": success])
|
|
}
|
|
|
|
firstly { () throws -> Promise<(tag: String, name: String?)> in
|
|
if let tag = incomingMessage.Payload?["tag"] as? String, tag.isEmpty == false {
|
|
return .value((tag: tag, name: incomingMessage.Payload?["name"] as? String))
|
|
} else {
|
|
throw HomeAssistantAPI.APIError.invalidResponse
|
|
}
|
|
}.then { tagInfo in
|
|
Current.tags.writeNFC(value: tagInfo.tag)
|
|
}.done { _ in
|
|
Current.Log.info("wrote tag via external bus")
|
|
seal(true)
|
|
}.catch { error in
|
|
Current.Log.error("couldn't write tag via external bus: \(error)")
|
|
seal(false)
|
|
}
|
|
case .themeUpdate:
|
|
webViewController.evaluateJavaScript("notifyThemeColors()", completion: nil)
|
|
case .matterCommission:
|
|
matterComissioningHandler(incomingMessage: incomingMessage)
|
|
case .threadImportCredentials:
|
|
transferKeychainThreadCredentialsToHARequested()
|
|
case .barCodeScanner:
|
|
guard let title = incomingMessage.Payload?["title"] as? String,
|
|
let description = incomingMessage.Payload?["description"] as? String,
|
|
let incomingMessageId = incomingMessage.ID else { return }
|
|
barcodeScannerRequested(
|
|
title: title,
|
|
description: description,
|
|
alternativeOptionLabel: incomingMessage.Payload?["alternative_option_label"] as? String,
|
|
incomingMessageId: incomingMessageId
|
|
)
|
|
case .barCodeScannerClose:
|
|
if webViewController.overlayedController as? BarcodeScannerHostingController != nil {
|
|
webViewController.dismissControllerAboveOverlayController()
|
|
webViewController.dismissOverlayController(animated: true, completion: nil)
|
|
}
|
|
case .barCodeScannerNotify:
|
|
guard let message = incomingMessage.Payload?["message"] as? String else { return }
|
|
presentBarcodeScannerMessage(message: message)
|
|
case .threadStoreCredentialInAppleKeychain:
|
|
guard let macExtendedAddress = incomingMessage.Payload?["mac_extended_address"] as? String,
|
|
let activeOperationalDataset = incomingMessage.Payload?["active_operational_dataset"] as? String else { return }
|
|
transferHAThreadCredentialsToKeychain(
|
|
macExtendedAddress: macExtendedAddress,
|
|
activeOperationalDataset: activeOperationalDataset
|
|
)
|
|
case .assistShow:
|
|
let startListening = incomingMessage.Payload?["start_listening"] as? Bool
|
|
let pipelineId = incomingMessage.Payload?["pipeline_id"] as? String
|
|
showAssist(
|
|
server: webViewController.server,
|
|
pipeline: pipelineId ?? "",
|
|
autoStartRecording: startListening ?? false,
|
|
animated: true
|
|
)
|
|
case .scanForImprov:
|
|
scanImprov()
|
|
case .improvConfigureDevice:
|
|
let deviceName = incomingMessage.Payload?["name"] as? String
|
|
presentImprov(deviceName: deviceName)
|
|
}
|
|
} else {
|
|
Current.Log.error("unknown: \(incomingMessage.MessageType)")
|
|
}
|
|
|
|
response?.then { [self] outgoing in
|
|
sendExternalBus(message: outgoing)
|
|
}.cauterize()
|
|
}
|
|
|
|
func showSettingsViewController() {
|
|
if Current.sceneManager.supportsMultipleScenes, Current.isCatalyst {
|
|
Current.sceneManager.activateAnyScene(for: .settings)
|
|
} else {
|
|
let settingsView = SettingsViewController()
|
|
settingsView.hidesBottomBarWhenPushed = true
|
|
let navController = UINavigationController(rootViewController: settingsView)
|
|
webViewController?.presentOverlayController(controller: navController, animated: true)
|
|
}
|
|
}
|
|
|
|
func handleHaptic(_ hapticType: String) {
|
|
Current.Log.verbose("Handle haptic type \(hapticType)")
|
|
switch hapticType {
|
|
case "success":
|
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
|
case "error", "failure":
|
|
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
|
case "warning":
|
|
UINotificationFeedbackGenerator().notificationOccurred(.warning)
|
|
case "light":
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
case "medium":
|
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
|
case "heavy":
|
|
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
|
case "selection":
|
|
UISelectionFeedbackGenerator().selectionChanged()
|
|
default:
|
|
Current.Log.verbose("Unknown haptic type \(hapticType)")
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
public func sendExternalBus(message: WebSocketMessage) -> Promise<Void> {
|
|
Promise<Void> { seal in
|
|
DispatchQueue.main.async { [self] in
|
|
do {
|
|
let encodedMsg = try JSONEncoder().encode(message)
|
|
let jsonString = String(decoding: encodedMsg, as: UTF8.self)
|
|
let script = "window.externalBus(\(jsonString))"
|
|
Current.Log.verbose("sending \(jsonString)")
|
|
webViewController?.evaluateJavaScript(script, completion: { _, error in
|
|
if let error {
|
|
Current.Log.error("failed to fire message to externalBus: \(error)")
|
|
seal.reject(error)
|
|
} else {
|
|
seal.fulfill(())
|
|
}
|
|
})
|
|
} catch {
|
|
Current.Log.error("failed to send \(message): \(error)")
|
|
seal.reject(error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func transferKeychainThreadCredentialsToHARequested() {
|
|
guard let webViewController else {
|
|
Current.Log.error("WebViewExternalMessageHandler has nil webViewController")
|
|
return
|
|
}
|
|
|
|
if #available(iOS 16.4, *) {
|
|
let threadManagementView =
|
|
UIHostingController(
|
|
rootView: ThreadCredentialsSharingView<ThreadTransferCredentialToHAViewModel>
|
|
.buildTransferToHomeAssistant(server: webViewController.server)
|
|
)
|
|
threadManagementView.view.backgroundColor = .clear
|
|
threadManagementView.modalPresentationStyle = .overFullScreen
|
|
threadManagementView.modalTransitionStyle = .crossDissolve
|
|
webViewController.presentOverlayController(controller: threadManagementView, animated: true)
|
|
}
|
|
}
|
|
|
|
private func transferHAThreadCredentialsToKeychain(macExtendedAddress: String, activeOperationalDataset: String) {
|
|
if #available(iOS 16.4, *) {
|
|
let threadManagementView =
|
|
UIHostingController(
|
|
rootView: ThreadCredentialsSharingView<ThreadTransferCredentialToKeychainViewModel>
|
|
.buildTransferToAppleKeychain(
|
|
macExtendedAddress: macExtendedAddress,
|
|
activeOperationalDataset: activeOperationalDataset
|
|
)
|
|
)
|
|
threadManagementView.view.backgroundColor = .clear
|
|
threadManagementView.modalPresentationStyle = .overFullScreen
|
|
threadManagementView.modalTransitionStyle = .crossDissolve
|
|
webViewController?.presentOverlayController(controller: threadManagementView, animated: true)
|
|
}
|
|
}
|
|
|
|
private func barcodeScannerRequested(
|
|
title: String,
|
|
description: String,
|
|
alternativeOptionLabel: String?,
|
|
incomingMessageId: Int
|
|
) {
|
|
let barcodeController = BarcodeScannerHostingController(rootView: BarcodeScannerView(
|
|
title: title,
|
|
description: description,
|
|
alternativeOptionLabel: alternativeOptionLabel,
|
|
incomingMessageId: incomingMessageId
|
|
))
|
|
barcodeController.modalPresentationStyle = .fullScreen
|
|
webViewController?.presentOverlayController(controller: barcodeController, animated: true)
|
|
}
|
|
|
|
private func matterComissioningHandler(incomingMessage: WebSocketMessage) {
|
|
// So we avoid conflicting credentials (or absence) between servers
|
|
cleanPreferredThreadCredentials()
|
|
let preferredNetWorkMacExtendedAddress = incomingMessage
|
|
.Payload?[PayloadConstants.macExtendedAddress.rawValue] as? String
|
|
let preferredNetWorkActiveOperationalDataset = incomingMessage
|
|
.Payload?[PayloadConstants.activeOperationalDataset.rawValue] as? String
|
|
let preferredNetworkExtendedPANID = incomingMessage.Payload?[PayloadConstants.extendedPanId.rawValue] as? String
|
|
|
|
Current.Log
|
|
.verbose(
|
|
"Matter comission received preferredNetWorkMacExtendedAddress from frontend: \(String(describing: preferredNetWorkMacExtendedAddress))"
|
|
)
|
|
Current.Log
|
|
.verbose(
|
|
"Matter comission received preferredNetWorkActiveOperationalDataset from frontend: \(String(describing: preferredNetWorkActiveOperationalDataset))"
|
|
)
|
|
Current.Log
|
|
.verbose(
|
|
"Matter comission received preferredNetworkExtendedPANID from frontend: \(String(describing: preferredNetworkExtendedPANID))"
|
|
)
|
|
|
|
if let preferredNetWorkMacExtendedAddress, !preferredNetWorkMacExtendedAddress.isEmpty,
|
|
let preferredNetWorkActiveOperationalDataset, !preferredNetWorkActiveOperationalDataset.isEmpty,
|
|
let preferredNetworkExtendedPANID, !preferredNetworkExtendedPANID.isEmpty {
|
|
// This information will be used in 'MatterRequestHandler'
|
|
Current.settingsStore
|
|
.matterLastPreferredNetWorkMacExtendedAddress = preferredNetWorkMacExtendedAddress
|
|
Current.settingsStore
|
|
.matterLastPreferredNetWorkActiveOperationalDataset = preferredNetWorkActiveOperationalDataset
|
|
Current.settingsStore
|
|
.matterLastPreferredNetWorkExtendedPANID = preferredNetworkExtendedPANID
|
|
|
|
// Saving credential in keychain before moving forward as required, docs: https://developer.apple.com/documentation/mattersupport/matteradddeviceextensionrequesthandler/selectthreadnetwork(from:)
|
|
Current.matter.threadClientService.saveCredential(
|
|
macExtendedAddress: preferredNetWorkMacExtendedAddress,
|
|
operationalDataSet: preferredNetWorkActiveOperationalDataset
|
|
) { [weak self] error in
|
|
if let error {
|
|
Current.Log
|
|
.error(
|
|
"Error saving credentials in keychain while comissioning matter device, error: \(error.localizedDescription)"
|
|
)
|
|
let alert = UIAlertController(
|
|
title: L10n.Thread.SaveCredential.Fail.Alert.title(error.localizedDescription),
|
|
message: L10n.Thread.SaveCredential.Fail.Alert.message,
|
|
preferredStyle: .alert
|
|
)
|
|
alert.addAction(.init(title: L10n.cancelLabel, style: .default))
|
|
alert.addAction(.init(title: L10n.continueLabel, style: .destructive, handler: { [weak self] _ in
|
|
self?.comissionMatterDevice()
|
|
}))
|
|
self?.webViewController?.presentOverlayController(controller: alert, animated: false)
|
|
} else {
|
|
Current.Log
|
|
.verbose(
|
|
"Succeeded saving thread credentials in keychain, moving forward to matter comissioning"
|
|
)
|
|
self?.comissionMatterDevice()
|
|
}
|
|
}
|
|
} else {
|
|
comissionMatterDevice()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func presentBarcodeScannerMessage(message: String) {
|
|
var config = SwiftMessages.Config()
|
|
config.dimMode = .none
|
|
config.presentationStyle = .bottom
|
|
config.duration = .seconds(seconds: 3)
|
|
let view = MessageView.viewFromNib(layout: .cardView)
|
|
view.configureContent(
|
|
title: nil,
|
|
body: message,
|
|
iconImage: nil,
|
|
iconText: nil,
|
|
buttonImage: nil,
|
|
buttonTitle: nil,
|
|
buttonTapHandler: { _ in
|
|
DispatchQueue.main.async {
|
|
SwiftMessages.hide()
|
|
}
|
|
}
|
|
)
|
|
view.id = "BarcodeScannerMessage"
|
|
SwiftMessages.show(config: config, view: view)
|
|
}
|
|
|
|
private func cleanPreferredThreadCredentials() {
|
|
Current.settingsStore.matterLastPreferredNetWorkMacExtendedAddress = nil
|
|
Current.settingsStore.matterLastPreferredNetWorkActiveOperationalDataset = nil
|
|
Current.settingsStore.matterLastPreferredNetWorkExtendedPANID = nil
|
|
}
|
|
|
|
private func comissionMatterDevice() {
|
|
guard let webViewController else {
|
|
Current.Log.error("WebViewController not available while commissioning matter device")
|
|
return
|
|
}
|
|
Current.matter.commission(webViewController.server).done {
|
|
Current.Log.info("commission call completed")
|
|
}.catch { error in
|
|
// we don't show a user-visible error because even a successful operation will return 'cancelled'
|
|
// but the errors aren't public, so we can't compare -- the apple ui shows errors visually though
|
|
Current.Log.error(error)
|
|
}.finally { [weak self] in
|
|
self?.webViewController?.reload()
|
|
}
|
|
}
|
|
|
|
func showAssist(server: Server, pipeline: String = "", autoStartRecording: Bool = false, animated: Bool) {
|
|
if AssistSession.shared.inProgress {
|
|
AssistSession.shared.requestNewSession(.init(
|
|
server: server,
|
|
pipelineId: pipeline,
|
|
autoStartRecording: autoStartRecording
|
|
))
|
|
return
|
|
}
|
|
let assistView = UIHostingController(rootView: AssistView.build(
|
|
server: server,
|
|
preferredPipelineId: pipeline,
|
|
autoStartRecording: autoStartRecording
|
|
))
|
|
|
|
webViewController?.presentOverlayController(controller: assistView, animated: animated)
|
|
}
|
|
|
|
func scanImprov() {
|
|
switch Current.bluetoothPermissionStatus {
|
|
case .denied, .restricted:
|
|
break
|
|
case .allowedAlways:
|
|
improvManager.delegate = self
|
|
improvManager.scan()
|
|
default:
|
|
// Mac Catalyst doesn't trigger bluetooth permission for some reason
|
|
guard !Current.isCatalyst else { return }
|
|
let bluetoothPermissionView = UIHostingController(rootView: BluetoothPermissionView())
|
|
webViewController?.presentOverlayController(controller: bluetoothPermissionView, animated: true)
|
|
}
|
|
}
|
|
|
|
private func presentImprov(deviceName: String?) {
|
|
improvManager.stopScan()
|
|
improvManager.delegate = nil
|
|
|
|
improvController =
|
|
UIHostingController(rootView: ImprovDiscoverView<ImprovManager>(
|
|
improvManager: improvManager,
|
|
deviceName: deviceName,
|
|
redirectRequest: { [weak self] redirectUrlPath in
|
|
self?.webViewController?.navigateToPath(path: redirectUrlPath)
|
|
}
|
|
))
|
|
|
|
guard let improvController else { return }
|
|
improvController.modalTransitionStyle = .crossDissolve
|
|
improvController.modalPresentationStyle = .overFullScreen
|
|
improvController.view.backgroundColor = .clear
|
|
webViewController?.presentOverlayController(controller: improvController, animated: true)
|
|
}
|
|
|
|
func stopImprovScanIfNeeded() {
|
|
if improvManager.scanInProgress {
|
|
improvManager.stopScan()
|
|
}
|
|
}
|
|
}
|
|
|
|
extension WebViewExternalMessageHandler: @preconcurrency ImprovManagerDelegate {
|
|
func didUpdateBluetoohState(_ state: CBManagerState) {
|
|
if state == .poweredOn {
|
|
improvManager.scan()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func didUpdateFoundDevices(devices: [String: CBPeripheral]) {
|
|
devices.forEach { [weak self] _, value in
|
|
if let name = value.name {
|
|
self?.sendExternalBus(message: .init(
|
|
command: WebViewExternalBusOutgoingMessage.improvDiscoveredDevice.rawValue,
|
|
payload: [
|
|
"name": name,
|
|
]
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|