Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Sources/App/HAApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ struct HAApp: App {
.onOpenURL { handleIncoming(url: $0) }
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { handleIncoming(userActivity: $0) }
}
// Without this, `activateAnyScene(for: .webView)` (e.g. tapping the macOS menu-bar item) requests a
// new scene carrying `targetContentIdentifier == "ha.webview"`; with no `WindowGroup` advertising that
// identifier SwiftUI can't bind the scene to a group and no window appears. Matching it here — like the
// other groups below — routes the activation to this group so the window becomes visible.
.handlesExternalEvents(matching: [SceneActivity.webView.activityIdentifier])

// Mac Settings
WindowGroup {
Expand Down
2 changes: 2 additions & 0 deletions Sources/App/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,8 @@ This server requires a client certificate (mTLS) but the operation was cancelled
"menu.application.preferences" = "Preferences…";
"menu.file.update_sensors" = "Update Sensors";
"menu.help.help" = "%@ Help";
"menu.status_item.open" = "Open %1$@";
"menu.status_item.open_settings" = "Open Settings…";
"menu.status_item.quit" = "Quit";
"menu.status_item.toggle" = "Toggle %1$@";
"menu.view.find" = "Find";
Expand Down
38 changes: 17 additions & 21 deletions Sources/App/Utilities/MenuManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -286,17 +286,6 @@ class MenuManager {
)
}

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)

Expand Down Expand Up @@ -367,14 +356,21 @@ class MenuManager {
)
}

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 openAppMenu() -> AppMacBridgeStatusItemMenuItem {
.init(name: L10n.Menu.StatusItem.open(appName)) { callbackInfo in
Current.sceneManager.activateAnyScene(for: .webView)
callbackInfo.activate()
}
}

private func openSettingsMenu() -> AppMacBridgeStatusItemMenuItem {
.init(
name: L10n.Menu.StatusItem.openSettings,
keyEquivalentModifier: [.command],
keyEquivalent: ","
) { callbackInfo in
Current.sceneManager.activateAnyScene(for: .settings)
callbackInfo.activate()
}
}

Expand All @@ -397,10 +393,10 @@ class MenuManager {
}

var menuItems = [AppMacBridgeStatusItemMenuItem]()
menuItems.append(toggleMenu())
menuItems.append(openAppMenu())
menuItems.append(openSettingsMenu())
menuItems.append(.separator())
menuItems.append(contentsOf: aboutMenu())
menuItems.append(preferencesMenu())
menuItems.append(quitMenu())

Current.macBridge.configureStatusItem(using: AppMacBridgeStatusItemConfiguration(
Expand Down
52 changes: 40 additions & 12 deletions Sources/MacBridge/MacBridgeStatusItem.swift
Original file line number Diff line number Diff line change
@@ -1,24 +1,39 @@
import AppKit

class MacBridgeStatusItem: NSObject, NSMenuDelegate {
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
private var statusItem: NSStatusItem?
private var lastConfiguration: MacBridgeStatusItemConfiguration?

override init() {
super.init()
statusItem.button?.imagePosition = .imageLeading
statusItem.button?.target = self
statusItem.button?.action = #selector(statusItemTapped(_:))
statusItem.button?.sendAction(on: [.leftMouseUp, .leftMouseDown, .rightMouseDown])
/// Returns the status item, creating and wiring it on first use.
///
/// `NSStatusBar`/`NSStatusItem` are main-thread-only. This bridge is owned by `Current`, whose backing
/// `AppEnvironment` is created lazily on the first touch of `Current` anywhere — which, under the SwiftUI
/// app lifecycle, can be a background thread (e.g. an early `Current.Log` call). Creating the item and
/// wiring its button's `target`/`action` off the main thread leaves the button rendering its image (set
/// from `buildMenu`, which runs on main) yet never receiving clicks, so the icon looks inert. Deferring
/// creation to here — first reached via `configure(using:)` on the main-thread `buildMenu` path — keeps
/// all of it on the main thread without the deadlock risk of a synchronous main-thread hop during the
/// `Current` initialiser.
@discardableResult
private func ensureStatusItem() -> NSStatusItem {
if let statusItem { return statusItem }
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
item.button?.imagePosition = .imageLeading
item.button?.target = self
item.button?.action = #selector(statusItemTapped(_:))
item.button?.sendAction(on: [.leftMouseUp, .leftMouseDown, .rightMouseDown])
statusItem = item
return item
}

func configure(title: String) {
statusItem.button?.title = title
ensureStatusItem().button?.title = title
}

func configure(using configuration: MacBridgeStatusItemConfiguration) {
lastConfiguration = configuration

let statusItem = ensureStatusItem()
statusItem.isVisible = configuration.isVisible
statusItem.button?.setAccessibilityLabel(configuration.accessibilityLabel)

Expand All @@ -28,12 +43,15 @@ class MacBridgeStatusItem: NSObject, NSMenuDelegate {
}

@objc private func statusItemTapped(_ sender: NSStatusBarButton) {
guard let configuration = lastConfiguration, let event = NSApp.currentEvent else { return }
// `NSApp.currentEvent` is the click event during this action, but fall back to the button window's
// current event so a configured item never silently drops a click if it's momentarily nil.
guard let configuration = lastConfiguration,
let event = NSApp.currentEvent ?? sender.window?.currentEvent else { return }

if event.isRightClickEquivalentEvent {
let mainMenu = menu(for: configuration.items)
mainMenu.delegate = self
statusItem.menu = mainMenu
ensureStatusItem().menu = mainMenu
sender.performClick(sender)
} else if event.type == .leftMouseUp {
// leftMouseDown also fires, but we only want to do that for ctrl-clicks
Expand All @@ -42,7 +60,7 @@ class MacBridgeStatusItem: NSObject, NSMenuDelegate {
}

func menuDidClose(_ menu: NSMenu) {
statusItem.menu = nil
statusItem?.menu = nil
}

private func modifierKeys(for uglyMask: Int) -> NSEvent.ModifierFlags {
Expand Down Expand Up @@ -111,11 +129,21 @@ class MacBridgeStatusItemCallbackInfoImpl: MacBridgeStatusItemCallbackInfo {
}

var isActive: Bool {
NSApp.isActive
// Only treat the app as "active" (and therefore something to hide on the next click) when it
// actually has a visible standard window. In menu-bar (`.accessory`) mode `NSApp.isActive` can be
// true with no visible window — which previously made the status-item click hide the app instead of
// showing it, so the icon appeared to do nothing.
NSApp.isActive && NSApp.windows.contains { $0.isVisible && !$0.isMiniaturized && $0.level == .normal }
}

func activate() {
NSApp.activate(ignoringOtherApps: true)
// `NSApp.activate` un-hides the app but doesn't reliably bring a closed/ordered-out window back in
// accessory mode, so surface an existing standard window here. A brand-new window (when none exists)
// is created by the scene-activation request in `SceneManager.activateAnyScene`.
NSApp.windows
.first { $0.level == .normal && !$0.isMiniaturized }?
.makeKeyAndOrderFront(nil)
}

func deactivate() {
Expand Down
6 changes: 6 additions & 0 deletions Sources/Shared/Resources/Swiftgen/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2412,6 +2412,12 @@ public enum L10n {
}
}
public enum StatusItem {
/// Open %1$@
public static func open(_ p1: Any) -> String {
return L10n.tr("Localizable", "menu.status_item.open", String(describing: p1))
}
/// Open Settings…
public static var openSettings: String { return L10n.tr("Localizable", "menu.status_item.open_settings") }
/// Quit
public static var quit: String { return L10n.tr("Localizable", "menu.status_item.quit") }
/// Toggle %1$@
Expand Down
Loading