Files
iOS/Sources/App/Utilities/MenuManager.swift
Bruno Pantaleão Gonçalves 7cbdcb762f Remove legacy actions (#4584)
<!-- 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>
2026-06-04 15:20:24 +02:00

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() {}
}