427 lines
14 KiB
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() {}
|
|
}
|