mirror of
https://github.com/home-assistant/iOS.git
synced 2026-02-04 21:15:17 -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 --> This PR allows adding supported domain entities to CarPlay, Widgets and Apple watch directly from the entity more info dialog ## 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. --> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
630 lines
27 KiB
Swift
630 lines
27 KiB
Swift
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<Void>
|
|
|
|
// 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<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: 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<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
|
|
)
|
|
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<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)
|
|
}
|
|
|
|
@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: 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<WebSocketMessage> {
|
|
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,
|
|
]
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|