iOS/Sources/App/Utilities/MenuManager.swift

427 lines
14 KiB
Swift

import Foundation
import HAKit
import PromiseKit
import RealmSwift
import Shared
import UIKit
private extension UIMenu.Identifier {
static var haActions: Self { .init(rawValue: "ha.actions") }
static var haActionsConfigure: Self { .init(rawValue: "ha.actions.configure") }
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
}
}
class MenuManager {
let builder: UIMenuBuilder
let actionsWithImages: [(Action, UIImage)]
// remember: this class is short-lived. it only exists for the duration of creating the menu.
init(builder: UIMenuBuilder) {
self.builder = builder
self.actionsWithImages = Self.actionsWithImages()
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
}
// if we know it's going to change, reset it for now so it doesn't show the old value
update("")
guard let connection = Current.api(for: server)?.connection else {
Current.Log.error("No API available to update status item title")
return nil
}
return .init(server: server, template: template, token: connection.subscribe(
to: .renderTemplate(template),
initiated: { result in
switch result {
case .success: break
case .failure: update(L10n.errorLabel)
}
}, handler: { _, response in
update(String(describing: response.result))
}
))
}
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: .haActions) == nil {
builder.insertSibling(actionsMenu(), beforeMenu: .window)
} else {
builder.replace(menu: .haActions, with: actionsMenu())
}
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 static func actionsWithImages() -> [(Action, UIImage)] {
// Action+Observation calls reload, so when they change this all gets run again
Current.realm()
.objects(Action.self)
.sorted(byKeyPath: #keyPath(Action.Position))
.map { action -> (Action, UIImage) in
let iconRect = CGRect(x: 0, y: 0, width: 28, height: 28)
let image = UIKit.UIGraphicsImageRenderer(size: iconRect.size).image { _ in
let imageRect = iconRect.insetBy(dx: 3, dy: 3)
UIColor(hex: action.BackgroundColor).set()
UIBezierPath(roundedRect: iconRect, cornerRadius: 6.0).fill()
MaterialDesignIcons(named: action.IconName)
.image(ofSize: imageRect.size, color: UIColor(hex: action.IconColor))
.draw(in: imageRect)
}
return (action, image)
}
}
private func actionsMenu() -> UIMenu {
let children = actionsWithImages.map { action, image in
UICommand(
title: action.Text,
image: image,
action: #selector(AppDelegate.openMenuUrl(_:)),
propertyList: Self.propertyList(for: action.widgetLinkURL)
)
} + [
UIMenu(title: "", image: nil, identifier: .haActionsConfigure, options: [.displayInline], children: [
UICommand(
title: L10n.Menu.Actions.configure,
image: nil,
action: #selector(AppDelegate.openActionsPreferences),
propertyList: nil
),
]),
]
return UIMenu(
title: L10n.Menu.Actions.title,
image: nil,
identifier: .haActions,
children: Array(children)
)
}
private func actionsMenu() -> AppMacBridgeStatusItemMenuItem {
var items = [AppMacBridgeStatusItemMenuItem]()
items.append(contentsOf: actionsWithImages.compactMap { action, image in
let url = action.widgetLinkURL
return .init(
name: action.Name,
image: image
) { callbackInfo in
callbackInfo.activate()
let delegate: Guarantee<WebViewSceneDelegate> = Current.sceneManager.scene(
for: .init(activity: .webView)
)
delegate.done {
$0.urlHandler?.handle(url: url)
}
}
})
if !items.isEmpty {
items.append(.separator())
}
items.append(.init(name: L10n.Menu.Actions.configure) { callbackInfo in
callbackInfo.activate()
UIApplication.shared.sendAction(
#selector(AppDelegate.openActionsPreferences),
to: UIApplication.shared.delegate,
from: nil,
for: nil
)
})
return AppMacBridgeStatusItemMenuItem(name: L10n.Menu.Actions.title, subitems: items)
}
private func webViewActionsMenu() -> UIMenu {
UIMenu(
title: "",
image: nil,
identifier: .haWebViewActions,
options: .displayInline,
children: [
UIKeyCommand(
title: L10n.Menu.View.reloadPage,
image: nil,
action: #selector(refresh),
input: "R",
modifierFlags: [.command]
),
]
)
}
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(actionsMenu())
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.SharedAssets.statusItemIcon.image.cgImage!,
imageSize: Asset.SharedAssets.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() {}
}