Skip to content

Commit 525dc0b

Browse files
authored
Fix "Open in browser" logic from menu bar and dock (#4799)
<!-- 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. -->
1 parent 899392a commit 525dc0b

5 files changed

Lines changed: 92 additions & 0 deletions

File tree

Sources/App/AppDelegate.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
142142

143143
#if targetEnvironment(macCatalyst)
144144
statusItemManager.configure()
145+
// Dock icon: when "Open Home Assistant UI in browser" is on there is no in-app web view, so a
146+
// reopen with no windows (Dock icon click) should open the browser instead of creating a window.
147+
Current.macBridge.setReopenHandler { StatusItemPrimaryAction.openInBrowserIfNeeded() }
145148
#endif
146149

147150
checkForUpdate()

Sources/App/Utilities/MenuManager.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,27 @@ enum StatusItemTitleRenderer {
119119
}
120120
}
121121

122+
/// Shared decision for what the macOS status item should do when "activated" (icon click or the
123+
/// "Toggle" menu item). When the user enabled "Open Home Assistant UI in browser"
124+
/// (`macNativeFeaturesOnly`) there is no in-app web view to toggle — it is destroyed at launch by
125+
/// `QuickActionWindowSceneDelegate` — so any "show me Home Assistant" affordance must open the default
126+
/// browser instead of spawning a web-view window. The preference is read live on every call because
127+
/// flipping it does not reconfigure the status item (it posts no `menuRelatedSettingDidChange`).
128+
enum StatusItemPrimaryAction {
129+
/// Opens Home Assistant in the default browser when the browser preference is on.
130+
/// - Returns: `true` if it handled the action (browser opened), `false` to fall through to the
131+
/// normal toggle/activate behaviour.
132+
static func openInBrowserIfNeeded() -> Bool {
133+
guard Current.settingsStore.macNativeFeaturesOnly else { return false }
134+
// Prefer the server shown in the menu-bar title; its getter already falls back to the first
135+
// server, so this also covers users without a configured menu-bar template.
136+
let server = Current.settingsStore.menuItemTemplate?.server ?? Current.servers.all.first
137+
guard let url = server?.info.connection.activeURL() else { return false }
138+
URLOpener.shared.open(url, options: [:], completionHandler: nil)
139+
return true
140+
}
141+
}
142+
122143
class MenuManager {
123144
let builder: UIMenuBuilder
124145

@@ -369,6 +390,7 @@ class MenuManager {
369390

370391
private func toggleMenu() -> AppMacBridgeStatusItemMenuItem {
371392
.init(name: L10n.Menu.StatusItem.toggle(appName)) { callbackInfo in
393+
if StatusItemPrimaryAction.openInBrowserIfNeeded() { return }
372394
if callbackInfo.isActive {
373395
callbackInfo.deactivate()
374396
} else {
@@ -410,6 +432,7 @@ class MenuManager {
410432
accessibilityLabel: appName,
411433
items: menuItems,
412434
primaryActionHandler: { callbackInfo in
435+
if StatusItemPrimaryAction.openInBrowserIfNeeded() { return }
413436
if callbackInfo.isActive {
414437
callbackInfo.deactivate()
415438
} else if callbackInfo.hasWindows {
@@ -539,6 +562,7 @@ final class StatusItemManager {
539562

540563
private func toggleMenu() -> AppMacBridgeStatusItemMenuItem {
541564
.init(name: L10n.Menu.StatusItem.toggle(appName)) { callbackInfo in
565+
if StatusItemPrimaryAction.openInBrowserIfNeeded() { return }
542566
if callbackInfo.isActive {
543567
callbackInfo.deactivate()
544568
} else {
@@ -579,6 +603,7 @@ final class StatusItemManager {
579603
accessibilityLabel: appName,
580604
items: menuItems,
581605
primaryActionHandler: { callbackInfo in
606+
if StatusItemPrimaryAction.openInBrowserIfNeeded() { return }
582607
if callbackInfo.isActive {
583608
callbackInfo.deactivate()
584609
} else if callbackInfo.hasWindows {

Sources/MacBridge/MacBridgeAppDelegateHandler.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import ObjectiveC.runtime
55
enum MacBridgeAppDelegateHandler {
66
static let terminationWillBeginNotification: Notification.Name = .init("ha_terminationWillBegin")
77

8+
/// Invoked when the app is reopened (e.g. the Dock icon is clicked) with no visible windows.
9+
/// Returns `true` if it handled the reopen, which suppresses AppKit's default window creation.
10+
static var reopenHandler: (() -> Bool)?
11+
12+
/// Implementation captured from the original `applicationShouldHandleReopen(_:hasVisibleWindows:)`
13+
/// (if the delegate had one, directly or inherited) so the swizzled method can call through to it.
14+
static var originalReopenIMP: IMP?
15+
816
static func swizzleAppDelegate() {
917
guard !Bundle.main.isRunningInExtension else {
1018
// Don't try and swizzle the App Delegate in an extension; there won't be one.
@@ -41,6 +49,30 @@ enum MacBridgeAppDelegateHandler {
4149

4250
method_exchangeImplementations(original, replacement)
4351
}
52+
53+
installReopenSwizzle(on: klass)
54+
}
55+
56+
/// `applicationShouldHandleReopen(_:hasVisibleWindows:)` is an *optional* `NSApplicationDelegate`
57+
/// method, so the Catalyst delegate may not implement it. Install our implementation safely rather
58+
/// than with `method_exchangeImplementations` (which `fatalError`s when the method is missing and
59+
/// would recurse if we added it with no original): capture any existing/inherited implementation to
60+
/// call through to, then add ours if absent or replace it if present.
61+
private static func installReopenSwizzle(on klass: AnyClass) {
62+
let originalSelector = #selector(NSApplicationDelegate.applicationShouldHandleReopen(_:hasVisibleWindows:))
63+
let replacementSelector = #selector(NSObject.ha_applicationShouldHandleReopen(_:hasVisibleWindows:))
64+
65+
guard let replacement = class_getInstanceMethod(klass, replacementSelector) else { return }
66+
let replacementIMP = method_getImplementation(replacement)
67+
let typeEncoding = method_getTypeEncoding(replacement)
68+
69+
// Capture any existing (possibly inherited) implementation up front so we can call through to it.
70+
originalReopenIMP = class_getInstanceMethod(klass, originalSelector).map(method_getImplementation)
71+
72+
if !class_addMethod(klass, originalSelector, replacementIMP, typeEncoding) {
73+
// The class defines the method itself; replace it and capture its own implementation.
74+
originalReopenIMP = class_replaceMethod(klass, originalSelector, replacementIMP, typeEncoding)
75+
}
4476
}
4577
}
4678

@@ -54,4 +86,27 @@ private extension NSObject {
5486
// refers to the non-swizzled method
5587
return ha_applicationShouldTerminate(sender)
5688
}
89+
90+
@objc func ha_applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
91+
// Reopen with no visible windows (e.g. Dock icon click) — let the app decide what to do. This is
92+
// used to open Home Assistant in the browser when that preference is on; if the handler reports it
93+
// took over, suppress AppKit's default window creation by returning false.
94+
if !hasVisibleWindows, MacBridgeAppDelegateHandler.reopenHandler?() == true {
95+
return false
96+
}
97+
98+
// Otherwise fall back to the delegate's original behaviour (if any), else allow the reopen.
99+
if let originalIMP = MacBridgeAppDelegateHandler.originalReopenIMP {
100+
typealias ReopenFunction = @convention(c) (NSObject, Selector, NSApplication, Bool) -> Bool
101+
let original = unsafeBitCast(originalIMP, to: ReopenFunction.self)
102+
return original(
103+
self,
104+
#selector(NSApplicationDelegate.applicationShouldHandleReopen(_:hasVisibleWindows:)),
105+
sender,
106+
hasVisibleWindows
107+
)
108+
}
109+
110+
return true
111+
}
57112
}

Sources/MacBridge/MacBridgeImpl.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ import SystemConfiguration
9797
NSApplication.shared.activate(ignoringOtherApps: true)
9898
}
9999

100+
func setReopenHandler(_ handler: @escaping () -> Bool) {
101+
MacBridgeAppDelegateHandler.reopenHandler = handler
102+
}
103+
100104
func configureStatusItem(using configuration: MacBridgeStatusItemConfiguration) {
101105
statusItem?.configure(using: configuration)
102106
}

Sources/Shared/Environment/MacBridgeProtocol.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ import Foundation
2828
/// Brings app to the foreground
2929
func activateApp()
3030

31+
/// Registers a handler invoked when the app is reopened with no visible windows (e.g. the user
32+
/// clicks the Dock icon). Return `true` from the handler if it fully handled the reopen, which
33+
/// suppresses AppKit's default window creation.
34+
func setReopenHandler(_ handler: @escaping () -> Bool)
35+
3136
func setLoginItem(forBundleIdentifier: String, enabled: Bool) -> Bool
3237
func isLoginItemEnabled(forBundleIdentifier identifier: String) -> Bool
3338
}

0 commit comments

Comments
 (0)