mirror of
https://github.com/home-assistant/iOS.git
synced 2026-06-16 04:16:39 -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 --> ## 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-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
428 lines
13 KiB
Swift
428 lines
13 KiB
Swift
import Foundation
|
|
import HAKit
|
|
import PromiseKit
|
|
import RealmSwift
|
|
import Shared
|
|
import UIKit
|
|
|
|
private extension UIMenu.Identifier {
|
|
static var haHelp: Self { .init(rawValue: "ha.help") }
|
|
static var haWebViewActions: Self { .init(rawValue: "ha.webViewActions") }
|
|
static var haFile: Self { .init(rawValue: "ha.file") }
|
|
}
|
|
|
|
public struct MenuManagerTitleSubscription: Equatable {
|
|
private var uuid = UUID()
|
|
var server: Server
|
|
var template: String
|
|
var token: HACancellable
|
|
|
|
init(server: Server, template: String, token: HACancellable) {
|
|
self.server = server
|
|
self.template = template
|
|
self.token = token
|
|
}
|
|
|
|
func cancel() {
|
|
token.cancel()
|
|
}
|
|
|
|
public static func == (lhs: MenuManagerTitleSubscription, rhs: MenuManagerTitleSubscription) -> Bool {
|
|
lhs.uuid == rhs.uuid
|
|
}
|
|
}
|
|
|
|
private final class CompositeHACancellable: HACancellable {
|
|
private var cancellables: [HACancellable]
|
|
|
|
init(_ cancellables: [HACancellable] = []) {
|
|
self.cancellables = cancellables
|
|
}
|
|
|
|
func append(_ cancellable: HACancellable) {
|
|
cancellables.append(cancellable)
|
|
}
|
|
|
|
func cancel() {
|
|
let activeCancellables = cancellables
|
|
cancellables.removeAll()
|
|
activeCancellables.forEach { $0.cancel() }
|
|
}
|
|
}
|
|
|
|
private final class StatusItemTitleState {
|
|
var hasRenderedTitle = false
|
|
var hasReceivedLiveUpdate = false
|
|
}
|
|
|
|
enum StatusItemTitleRenderer {
|
|
static func subscribe(
|
|
api: HomeAssistantAPI,
|
|
template: String,
|
|
update: @escaping (String) -> Void
|
|
) -> HACancellable {
|
|
let state = StatusItemTitleState()
|
|
let cancellable = CompositeHACancellable()
|
|
|
|
cancellable.append(api.connection.send(.init(
|
|
type: .rest(.post, "template"),
|
|
data: ["template": template],
|
|
shouldRetry: true
|
|
)) { result in
|
|
switch result {
|
|
case let .success(data):
|
|
guard !state.hasReceivedLiveUpdate else {
|
|
return
|
|
}
|
|
|
|
state.hasRenderedTitle = true
|
|
update(renderedTitle(from: data))
|
|
case let .failure(error):
|
|
Current.Log.error("Failed to render status item title via REST fallback: \(error)")
|
|
}
|
|
})
|
|
|
|
cancellable.append(api.connection.subscribe(
|
|
to: .renderTemplate(template),
|
|
initiated: { result in
|
|
guard case let .failure(error) = result else {
|
|
return
|
|
}
|
|
|
|
Current.Log.error("Failed to subscribe to status item title updates: \(error)")
|
|
|
|
if !state.hasRenderedTitle {
|
|
update(L10n.errorLabel)
|
|
}
|
|
},
|
|
handler: { _, response in
|
|
state.hasRenderedTitle = true
|
|
state.hasReceivedLiveUpdate = true
|
|
update(String(describing: response.result))
|
|
}
|
|
))
|
|
|
|
return cancellable
|
|
}
|
|
|
|
private static func renderedTitle(from data: HAData) -> String {
|
|
switch data {
|
|
case let .primitive(value):
|
|
return String(describing: value)
|
|
case let .dictionary(value):
|
|
return String(describing: value)
|
|
case let .array(value):
|
|
return String(describing: value)
|
|
case .empty:
|
|
return ""
|
|
}
|
|
}
|
|
}
|
|
|
|
class MenuManager {
|
|
let builder: UIMenuBuilder
|
|
|
|
// remember: this class is short-lived. it only exists for the duration of creating the menu.
|
|
|
|
init(builder: UIMenuBuilder) {
|
|
self.builder = builder
|
|
update()
|
|
}
|
|
|
|
static func url(from command: UICommand) -> URL? {
|
|
guard let propertyList = command.propertyList as? [String: Any] else {
|
|
return nil
|
|
}
|
|
|
|
guard let urlString = propertyList["url"] as? String else {
|
|
return nil
|
|
}
|
|
|
|
return URL(string: urlString)
|
|
}
|
|
|
|
private static func propertyList(for url: URL) -> Any {
|
|
["url": url.absoluteString]
|
|
}
|
|
|
|
private var appName: String {
|
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "Home Assistant"
|
|
}
|
|
|
|
public func subscribeStatusItemTitle(
|
|
existing: MenuManagerTitleSubscription?,
|
|
update: @escaping (String) -> Void
|
|
) -> MenuManagerTitleSubscription? {
|
|
guard let (server, template) = Current.settingsStore.menuItemTemplate,
|
|
Current.settingsStore.locationVisibility.isStatusItemVisible,
|
|
!template.isEmpty else {
|
|
update("")
|
|
return nil
|
|
}
|
|
|
|
guard existing == nil || existing?.template != template || existing?.server != server else {
|
|
return existing
|
|
}
|
|
|
|
// Cancel the old subscription before creating a new one
|
|
existing?.cancel()
|
|
|
|
// if we know it's going to change, reset it for now so it doesn't show the old value
|
|
update("")
|
|
|
|
guard let api = Current.api(for: server) else {
|
|
Current.Log.error("No API available to update status item title")
|
|
return nil
|
|
}
|
|
|
|
return .init(
|
|
server: server,
|
|
template: template,
|
|
token: StatusItemTitleRenderer.subscribe(api: api, template: template, update: update)
|
|
)
|
|
}
|
|
|
|
public func update() {
|
|
builder.remove(menu: .format)
|
|
|
|
builder.replace(menu: .about, with: aboutMenu())
|
|
|
|
if builder.menu(for: .preferences) == nil {
|
|
// macOS prior to 11.3 doesn't have the preferences menu already and 11.3+ doesn't like it being inserted
|
|
builder.insertSibling(preferencesMenu(), afterMenu: .about)
|
|
} else {
|
|
builder.replace(menu: .preferences, with: preferencesMenu())
|
|
}
|
|
|
|
builder.replaceChildren(ofMenu: .help) { _ in helpMenus() }
|
|
|
|
if builder.menu(for: .haWebViewActions) == nil {
|
|
builder.insertSibling(webViewActionsMenu(), beforeMenu: .fullscreen)
|
|
} else {
|
|
builder.replace(menu: .haWebViewActions, with: webViewActionsMenu())
|
|
}
|
|
|
|
if builder.menu(for: .haFile) == nil {
|
|
builder.insertChild(fileMenu(), atStartOfMenu: .file)
|
|
} else {
|
|
builder.replace(menu: .haFile, with: fileMenu())
|
|
}
|
|
|
|
configureStatusItem()
|
|
}
|
|
|
|
private func aboutMenu() -> UIMenu {
|
|
let title = L10n.Menu.Application.about(appName)
|
|
|
|
let about = UICommand(
|
|
title: title,
|
|
image: nil,
|
|
action: #selector(AppDelegate.openAbout),
|
|
propertyList: nil
|
|
)
|
|
|
|
let checkForUpdates = UICommand(
|
|
title: L10n.Updater.CheckForUpdatesMenu.title,
|
|
image: nil,
|
|
action: #selector(AppDelegate.checkForUpdate(_:)),
|
|
propertyList: nil
|
|
)
|
|
|
|
var children: [UICommand] = [
|
|
about,
|
|
]
|
|
|
|
if Current.updater.isSupported {
|
|
children.append(checkForUpdates)
|
|
}
|
|
|
|
return UIMenu(
|
|
title: title,
|
|
image: nil,
|
|
identifier: .about,
|
|
options: .displayInline,
|
|
children: children
|
|
)
|
|
}
|
|
|
|
private func aboutMenu() -> [AppMacBridgeStatusItemMenuItem] {
|
|
[
|
|
.init(name: L10n.About.title) { callbackInfo in
|
|
Current.sceneManager.activateAnyScene(for: .about)
|
|
callbackInfo.activate()
|
|
},
|
|
.init(name: L10n.Updater.CheckForUpdatesMenu.title) { callbackInfo in
|
|
Current.sceneManager.activateAnyScene(for: .webView)
|
|
callbackInfo.activate()
|
|
|
|
UIApplication.shared.sendAction(
|
|
#selector(AppDelegate.checkForUpdate(_:)),
|
|
to: UIApplication.shared.delegate,
|
|
from: callbackInfo,
|
|
for: nil
|
|
)
|
|
},
|
|
]
|
|
}
|
|
|
|
private func preferencesMenu() -> UIMenu {
|
|
let command = UIKeyCommand(
|
|
title: L10n.Menu.Application.preferences,
|
|
image: nil,
|
|
action: #selector(AppDelegate.openPreferences),
|
|
input: ",",
|
|
modifierFlags: .command,
|
|
propertyList: nil
|
|
)
|
|
|
|
return UIMenu(
|
|
title: L10n.Menu.Application.preferences,
|
|
image: nil,
|
|
identifier: .preferences,
|
|
options: .displayInline,
|
|
children: [command]
|
|
)
|
|
}
|
|
|
|
private func preferencesMenu() -> AppMacBridgeStatusItemMenuItem {
|
|
.init(
|
|
name: L10n.Menu.Application.preferences,
|
|
keyEquivalentModifier: [.command],
|
|
keyEquivalent: ","
|
|
) { callbackInfo in
|
|
Current.sceneManager.activateAnyScene(for: .settings)
|
|
callbackInfo.activate()
|
|
}
|
|
}
|
|
|
|
private func helpMenus() -> [UIMenu] {
|
|
let title = L10n.Menu.Help.help(appName)
|
|
|
|
let helpCommand = UICommand(
|
|
title: title,
|
|
image: nil,
|
|
action: #selector(AppDelegate.openHelp),
|
|
propertyList: nil
|
|
)
|
|
|
|
return [
|
|
UIMenu(
|
|
title: title,
|
|
image: nil,
|
|
identifier: .haHelp,
|
|
options: .displayInline,
|
|
children: [helpCommand]
|
|
),
|
|
]
|
|
}
|
|
|
|
private func webViewActionsMenu() -> UIMenu {
|
|
var commands: [UIMenuElement] = [
|
|
UIKeyCommand(
|
|
title: L10n.Menu.View.reloadPage,
|
|
image: nil,
|
|
action: #selector(refresh),
|
|
input: "R",
|
|
modifierFlags: [.command]
|
|
),
|
|
]
|
|
|
|
// Add find menu item for iOS 16+
|
|
if #available(iOS 16.0, *) {
|
|
commands.append(UIKeyCommand(
|
|
title: L10n.Menu.View.find,
|
|
image: nil,
|
|
action: #selector(showFindInteraction),
|
|
input: "f",
|
|
modifierFlags: [.command]
|
|
))
|
|
}
|
|
|
|
return UIMenu(
|
|
title: "",
|
|
image: nil,
|
|
identifier: .haWebViewActions,
|
|
options: .displayInline,
|
|
children: commands
|
|
)
|
|
}
|
|
|
|
private func fileMenu() -> UIMenu {
|
|
UIMenu(
|
|
title: "",
|
|
image: nil,
|
|
identifier: .haFile,
|
|
options: .displayInline,
|
|
children: [
|
|
UIKeyCommand(
|
|
title: L10n.Menu.File.updateSensors,
|
|
image: nil,
|
|
action: #selector(updateSensors),
|
|
input: "R",
|
|
modifierFlags: [.command, .shift]
|
|
),
|
|
]
|
|
)
|
|
}
|
|
|
|
private func toggleMenu() -> AppMacBridgeStatusItemMenuItem {
|
|
.init(name: L10n.Menu.StatusItem.toggle(appName)) { callbackInfo in
|
|
if callbackInfo.isActive {
|
|
callbackInfo.deactivate()
|
|
} else {
|
|
Current.sceneManager.activateAnyScene(for: .webView)
|
|
callbackInfo.activate()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func quitMenu() -> AppMacBridgeStatusItemMenuItem {
|
|
.init(
|
|
name: L10n.Menu.StatusItem.quit,
|
|
keyEquivalentModifier: [.command],
|
|
keyEquivalent: "q"
|
|
) { callbackInfo in
|
|
callbackInfo.terminate()
|
|
}
|
|
}
|
|
|
|
private func configureStatusItem() {
|
|
#if targetEnvironment(macCatalyst)
|
|
if Current.settingsStore.locationVisibility.isDockVisible {
|
|
Current.macBridge.activationPolicy = .regular
|
|
} else {
|
|
Current.macBridge.activationPolicy = .accessory
|
|
}
|
|
|
|
var menuItems = [AppMacBridgeStatusItemMenuItem]()
|
|
menuItems.append(toggleMenu())
|
|
menuItems.append(.separator())
|
|
menuItems.append(contentsOf: aboutMenu())
|
|
menuItems.append(preferencesMenu())
|
|
menuItems.append(quitMenu())
|
|
|
|
Current.macBridge.configureStatusItem(using: AppMacBridgeStatusItemConfiguration(
|
|
isVisible: Current.settingsStore.locationVisibility.isStatusItemVisible,
|
|
image: Asset.statusItemIcon.image.cgImage!,
|
|
imageSize: Asset.statusItemIcon.image.size,
|
|
accessibilityLabel: appName,
|
|
items: menuItems,
|
|
primaryActionHandler: { callbackInfo in
|
|
if callbackInfo.isActive {
|
|
callbackInfo.deactivate()
|
|
} else {
|
|
Current.sceneManager.activateAnyScene(for: .webView)
|
|
callbackInfo.activate()
|
|
}
|
|
}
|
|
))
|
|
#endif
|
|
}
|
|
|
|
// selectors that use responder chain
|
|
@objc private func refresh() {}
|
|
@objc private func updateSensors() {}
|
|
@available(iOS 16.0, *)
|
|
@objc private func showFindInteraction() {}
|
|
}
|