diff --git a/Sources/App/HAApp.swift b/Sources/App/HAApp.swift index a1f1ae745..8a9553aa1 100644 --- a/Sources/App/HAApp.swift +++ b/Sources/App/HAApp.swift @@ -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 { diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 20d255a0c..ca18c2447 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -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"; diff --git a/Sources/App/Utilities/MenuManager.swift b/Sources/App/Utilities/MenuManager.swift index e63b9f603..3c54f6a09 100644 --- a/Sources/App/Utilities/MenuManager.swift +++ b/Sources/App/Utilities/MenuManager.swift @@ -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) @@ -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() } } @@ -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( diff --git a/Sources/MacBridge/MacBridgeStatusItem.swift b/Sources/MacBridge/MacBridgeStatusItem.swift index 08f9b0afb..8345440c0 100644 --- a/Sources/MacBridge/MacBridgeStatusItem.swift +++ b/Sources/MacBridge/MacBridgeStatusItem.swift @@ -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) @@ -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 @@ -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 { @@ -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() { diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 49266c5e1..5bb4b47ac 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -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$@