import CoreBluetooth import Foundation import Improv_iOS import PromiseKit import SFSafeSymbols @preconcurrency import Shared import SwiftMessages import SwiftUI // MARK: - Protocol protocol WebViewExternalMessageHandlerProtocol { var webViewController: WebViewControllerProtocol? { get set } func handleExternalMessage(_ dictionary: [String: Any]) func sendExternalBus(message: WebSocketMessage) -> Promise // TODO: Move these methods below to their proper handlers func scanImprov() func stopImprovScanIfNeeded() func showAssist(server: Server, pipeline: String, autoStartRecording: Bool) } final class WebViewExternalMessageHandler: @preconcurrency WebViewExternalMessageHandlerProtocol { weak var webViewController: WebViewControllerProtocol? private let improvManager: any ImprovManagerProtocol private lazy var entityAddToHandler: EntityAddToHandler = .init(webViewController: webViewController) private var improvController: UIViewController? init( improvManager: any ImprovManagerProtocol ) { self.improvManager = improvManager } // swiftlint:disable cyclomatic_complexity @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? 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: WebViewExternalBusMessage.configResult )) } } 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.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 ) case .scanForImprov: scanImprov() case .improvConfigureDevice: let deviceName = incomingMessage.Payload?["name"] as? String presentImprov(deviceName: deviceName) case .focusElement: guard let elementId = incomingMessage.Payload?["element_id"] as? String else { Current.Log .error("Received focus_element via bus but element_id was not string! \(incomingMessage)") return } handleElementFocus(elementId: elementId) case .toastShow: guard let toastPayload = ToastShowPayload(payload: incomingMessage.Payload) else { Current.Log .error("Received toast/show via bus but missing or invalid parameters! \(incomingMessage)") return } showToast(payload: toastPayload) case .toastHide: guard let toastPayload = ToastHidePayload(payload: incomingMessage.Payload) else { Current.Log.error("Received toast/hide via bus but id was not string! \(incomingMessage)") return } hideToast(id: toastPayload.id) case .entityAddToGetActions: guard let entityId = incomingMessage.Payload?["entity_id"] as? String else { Current.Log .error("Received entity/add_to/get_actions but entity_id was not string! \(incomingMessage)") return } response = handleGetEntityAddToActions(entityId: entityId, messageId: incomingMessage.ID) case .entityAddTo: guard let entityId = incomingMessage.Payload?["entity_id"] as? String, let appPayload = incomingMessage.Payload?["app_payload"] as? String else { Current.Log.error("Received entity/add_to but missing entity_id or app_payload! \(incomingMessage)") return } handleEntityAddTo(entityId: entityId, appPayload: appPayload) } } else { Current.Log.error("unknown: \(incomingMessage.MessageType)") } response?.then { [self] outgoing in sendExternalBus(message: outgoing) }.cauterize() } // swiftlint:enable cyclomatic_complexity func showSettingsViewController() { if Current.sceneManager.supportsMultipleScenes, Current.isCatalyst { Current.sceneManager.activateAnyScene(for: .settings) } else { // Use SwiftUI SettingsView wrapped in hosting controller let settingsView = SettingsView().embeddedInHostingController() webViewController?.presentOverlayController(controller: settingsView, 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)") } } func handleElementFocus(elementId: String) { Current.Log.verbose("Handle element focus for element ID: \(elementId)") // JavaScript to find and focus element in both regular DOM and Shadow DOM let script = """ (function() { // Helper function to search through shadow DOM recursively function findElementInShadowDOM(elementId, root = document) { // Try to find by ID in current root let element = root.getElementById(elementId); if (element) return element; // Search through all elements with shadow roots const allElements = root.querySelectorAll('*'); for (let el of allElements) { if (el.shadowRoot) { element = findElementInShadowDOM(elementId, el.shadowRoot); if (element) return element; } } return null; } // Search for the element var element = findElementInShadowDOM('\(elementId)'); if (element) { element.focus(); return 'Element found and focused: ' + elementId; } else { return 'Element not found: ' + elementId; } })(); """ webViewController?.evaluateJavaScript(script) { result, error in if let error { Current.Log.error("Error focusing element \(elementId): \(error)") } else if let result { Current.Log.info("Focus element result: \(result)") } } } @discardableResult public func sendExternalBus(message: WebSocketMessage) -> Promise { Promise { 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 .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 .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) } @MainActor private func showToast(payload: ToastShowPayload) { if #available(iOS 18, *) { ToastManager.shared.show( id: payload.id, symbol: SFSymbol.infoCircleFill.rawValue, symbolForegroundStyle: (.white, .haPrimary), title: payload.message, message: "", duration: payload.duration ) } else { Current.Log.verbose("Not showing toast with id \(payload.id), Toast not available on this OS version.") } } @MainActor private func hideToast(id: String) { if #available(iOS 18, *) { ToastManager.shared.hide(id: id) } else { Current.Log.verbose("Not hiding toast with id \(id), Toast not available on this OS version.") } } 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?.refresh() } } func showAssist(server: Server, pipeline: String = "", autoStartRecording: Bool = false) { if AssistSession.shared.inProgress { AssistSession.shared.requestNewSession(.init( server: server, pipelineId: pipeline, autoStartRecording: autoStartRecording )) return } if Current.sceneManager.supportsMultipleScenes, Current.isCatalyst { // On macOS, open Assist in a new window/scene Current.sceneManager.activateAnyScene( for: .assist, with: [ "server": server.identifier.rawValue, "pipelineId": pipeline, "autoStartRecording": autoStartRecording, ] ) } else { // On iOS/iPad, present modally as before let assistView = UIHostingController(rootView: AssistView.build( server: server, preferredPipelineId: pipeline, autoStartRecording: autoStartRecording )) assistView.modalPresentationStyle = .fullScreen assistView.modalTransitionStyle = .crossDissolve webViewController?.presentOverlayController(controller: assistView, animated: true) } } 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, 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() } } // MARK: - Entity Add To Handlers private enum EntityAddToResponseKey: String { case actions } private func handleGetEntityAddToActions(entityId: String, messageId: Int?) -> Guarantee { Guarantee { seal in entityAddToHandler.actionsForEntity(entityId: entityId).done { actions in do { let externalActions = try actions.map { action in try ExternalEntityAddToAction.from(action: action) } seal(WebSocketMessage( id: messageId ?? -1, type: "result", result: [EntityAddToResponseKey.actions.rawValue: externalActions.map { $0.toDictionary() }] )) } catch { Current.Log.error("Failed to encode entity add to actions: \(error)") seal(WebSocketMessage( id: messageId ?? -1, type: "result", result: [EntityAddToResponseKey.actions.rawValue: []] )) } }.catch { error in Current.Log.error("Failed to get entity add to actions: \(error)") seal(WebSocketMessage( id: messageId ?? -1, type: "result", result: [EntityAddToResponseKey.actions.rawValue: []] )) } } } private func handleEntityAddTo(entityId: String, appPayload: String) { do { let action = try ExternalEntityAddToAction.toAction(from: appPayload) entityAddToHandler.execute(action: action, entityId: entityId).done { Current.Log.info("Successfully executed entity add to action for \(entityId)") }.catch { error in Current.Log.error("Failed to execute entity add to action for \(entityId): \(error)") } } catch { Current.Log.error("Failed to decode entity add to action: \(error)") } } } 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, ] )) } } } }