diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 62ceb6211b..a6b8a2dddc 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -657,6 +657,7 @@ 425573D12B5576E600145217 /* CarPlayDomainsListTemplate+Build.swift in Sources */ = {isa = PBXBuildFile; fileRef = 425573D02B5576E600145217 /* CarPlayDomainsListTemplate+Build.swift */; }; 425573D32B55770D00145217 /* CarPlayDomainsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 425573D22B55770D00145217 /* CarPlayDomainsListViewModel.swift */; }; 425573DA2B57DDE000145217 /* WindowScenesManager.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 425573D92B57DDE000145217 /* WindowScenesManager.test.swift */; }; + 52386BFCFAFCBE783D0BCDD9 /* MacBrowserSceneLauncher.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BE2F311BDB2F205F7432FF /* MacBrowserSceneLauncher.test.swift */; }; 425573EB2B588FFB00145217 /* CarPlayListItemProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 425573EA2B588FFB00145217 /* CarPlayListItemProvider.swift */; }; 425573ED2B58904000145217 /* CarPlayEntityListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 425573EC2B58904000145217 /* CarPlayEntityListItem.swift */; }; 4256A70D2F5ECBB3003D464F /* SpeechTranscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4256A70C2F5ECBB3003D464F /* SpeechTranscriber.swift */; }; @@ -1529,6 +1530,7 @@ DE51600000000000000000B2 /* HADesignSystemExports.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE51600000000000000000B1 /* HADesignSystemExports.swift */; }; DE51600000000000000000B3 /* HADesignSystemExports.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE51600000000000000000B1 /* HADesignSystemExports.swift */; }; DE8048842D9FB6C90459E229 /* QuickActionWindowSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1948705C52B43ACD36FD0B32 /* QuickActionWindowSceneDelegate.swift */; }; + 1A693E34211ADFAB76155607 /* MacBrowserSceneLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 730D7B17F8A2B19BCA181F91 /* MacBrowserSceneLauncher.swift */; }; E33F645682064B22AC4CDFBB /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = C6ACD1840F0E4F5FBFDDE88B /* SFSafeSymbols */; }; E60778133EC44B10A61D551C /* TestFlightCommunicationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 212E30CB76DE4ED1B6E317CD /* TestFlightCommunicationModels.swift */; }; E8EEF4E584B6F9F24A3F24C8 /* WatchConnectivityAliases.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81244555FB7D99C165A57AFA /* WatchConnectivityAliases.swift */; }; @@ -2174,6 +2176,7 @@ 14EE842480AD406D8305F377 /* TestFlightCommunicationEngine.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestFlightCommunicationEngine.test.swift; sourceTree = ""; }; 1945E8E386664E5A88668E77 /* WebViewController+ReAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebViewController+ReAuth.swift"; sourceTree = ""; }; 1948705C52B43ACD36FD0B32 /* QuickActionWindowSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickActionWindowSceneDelegate.swift; sourceTree = ""; }; + 730D7B17F8A2B19BCA181F91 /* MacBrowserSceneLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacBrowserSceneLauncher.swift; sourceTree = ""; }; 1CCF41D72CA895DB501333EF /* KioskModeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskModeManager.swift; sourceTree = ""; }; 212E30CB76DE4ED1B6E317CD /* TestFlightCommunicationModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestFlightCommunicationModels.swift; sourceTree = ""; }; 28B40938BEB944969AAB2895 /* CarPlayPreferredServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayPreferredServer.swift; sourceTree = ""; }; @@ -2367,6 +2370,7 @@ 425573D02B5576E600145217 /* CarPlayDomainsListTemplate+Build.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CarPlayDomainsListTemplate+Build.swift"; sourceTree = ""; }; 425573D22B55770D00145217 /* CarPlayDomainsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayDomainsListViewModel.swift; sourceTree = ""; }; 425573D92B57DDE000145217 /* WindowScenesManager.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowScenesManager.test.swift; sourceTree = ""; }; + 26BE2F311BDB2F205F7432FF /* MacBrowserSceneLauncher.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacBrowserSceneLauncher.test.swift; sourceTree = ""; }; 425573E42B58380D00145217 /* CarPlay.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CarPlay.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX14.0.sdk/System/iOSSupport/System/Library/Frameworks/CarPlay.framework; sourceTree = DEVELOPER_DIR; }; 425573EA2B588FFB00145217 /* CarPlayListItemProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayListItemProvider.swift; sourceTree = ""; }; 425573EC2B58904000145217 /* CarPlayEntityListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayEntityListItem.swift; sourceTree = ""; }; @@ -4171,6 +4175,7 @@ 118261F424F8C7C1000795C6 /* SceneManager.swift */, FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */, 1948705C52B43ACD36FD0B32 /* QuickActionWindowSceneDelegate.swift */, + 730D7B17F8A2B19BCA181F91 /* MacBrowserSceneLauncher.swift */, 42F1DA5A2B4BF7DF002729BC /* WindowSizeObserver.swift */, 42F1DA5C2B4BF85F002729BC /* WindowScenesManager.swift */, ); @@ -4282,6 +4287,7 @@ isa = PBXGroup; children = ( 425573D92B57DDE000145217 /* WindowScenesManager.test.swift */, + 26BE2F311BDB2F205F7432FF /* MacBrowserSceneLauncher.test.swift */, ); path = Scenes; sourceTree = ""; @@ -8472,6 +8478,7 @@ 11195F6F267EFC8E003DF674 /* NotificationManagerLocalPushInterfaceDirect.swift in Sources */, FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */, DE8048842D9FB6C90459E229 /* QuickActionWindowSceneDelegate.swift in Sources */, + 1A693E34211ADFAB76155607 /* MacBrowserSceneLauncher.swift in Sources */, 11A71C6B24A463FC00D9565F /* ZoneManagerState.swift in Sources */, 42106F562F447C6B009351FC /* MTLSLabsLabel.swift in Sources */, FD3BC66E29BA010A00B19FBE /* CarPlayDomainsListTemplate.swift in Sources */, @@ -8717,6 +8724,7 @@ 42ACC2AF2E9FA1D60045A3FD /* DiscoveredHomeAssistant.test.swift in Sources */, 117D8A0A24A9381F00580913 /* UIColor+CSSRGB.test.swift in Sources */, 425573DA2B57DDE000145217 /* WindowScenesManager.test.swift in Sources */, + 52386BFCFAFCBE783D0BCDD9 /* MacBrowserSceneLauncher.test.swift in Sources */, 11ED43A027279AFA00B5FD45 /* OnboardingAuthLoginImpl.test.swift in Sources */, 11EFD3BE27253504000AF78B /* OnboardingAuthStepConnectivity.test.swift in Sources */, 42E3B8C02D8ACE0400F5D084 /* OptionalsDefaultTests.swift in Sources */, diff --git a/Sources/App/Scenes/MacBrowserSceneLauncher.swift b/Sources/App/Scenes/MacBrowserSceneLauncher.swift new file mode 100644 index 0000000000..624322b970 --- /dev/null +++ b/Sources/App/Scenes/MacBrowserSceneLauncher.swift @@ -0,0 +1,49 @@ +import Shared + +/// Gates the Mac "Open Home Assistant UI in browser" (`macNativeFeaturesOnly`) auto-launch that runs when a +/// `"WebView"` scene connects (see `QuickActionWindowSceneDelegate`). +/// +/// The empty webview window is destroyed on every `"WebView"` scene connection — under `macNativeFeaturesOnly` +/// there is never an in-app window — but Home Assistant is opened in the browser only on the *first* connection +/// of the process, the user's cold launch. macOS reconnects this scene in the background throughout the app's +/// lifetime (for state restoration and other background work), and every reconnection used to reopen the +/// browser, so the dashboard reappeared every few minutes even though the user never asked for it (#4985). +/// Later, genuinely user-initiated opens while the app is already running are handled elsewhere — the Dock +/// reopen handler and the status-item menu both go through `StatusItemPrimaryAction.openInBrowserIfNeeded()`. +enum MacBrowserSceneLauncher { + /// Whether the initial (cold-launch) `"WebView"` scene connection has already been observed this process. + /// Reset only implicitly, by the process being relaunched. + private(set) static var didHandleInitialSceneConnection = false + + /// Records that a `"WebView"` scene has connected and reports whether this was the first connection of the + /// process (i.e. the user's cold launch). Subsequent calls return `false`. + @discardableResult + static func markSceneConnected() -> Bool { + let isInitialConnection = !didHandleInitialSceneConnection + didHandleInitialSceneConnection = true + return isInitialConnection + } + + /// Whether the "Open Home Assistant UI in browser" behaviour applies on this platform/configuration. + static var isBrowserLaunchEnabled: Bool { + Current.isCatalyst && Current.settingsStore.macNativeFeaturesOnly + } + + struct SceneConnectionActions { + let opensBrowser: Bool + let destroysEmptyWindow: Bool + } + + static func actions(isInitialConnection: Bool) -> SceneConnectionActions { + guard isBrowserLaunchEnabled else { + return SceneConnectionActions(opensBrowser: false, destroysEmptyWindow: false) + } + return SceneConnectionActions(opensBrowser: isInitialConnection, destroysEmptyWindow: true) + } + + #if DEBUG + static func resetInitialSceneConnectionForTesting() { + didHandleInitialSceneConnection = false + } + #endif +} diff --git a/Sources/App/Scenes/QuickActionWindowSceneDelegate.swift b/Sources/App/Scenes/QuickActionWindowSceneDelegate.swift index d0cb7b05c4..98b288e441 100644 --- a/Sources/App/Scenes/QuickActionWindowSceneDelegate.swift +++ b/Sources/App/Scenes/QuickActionWindowSceneDelegate.swift @@ -27,6 +27,11 @@ final class QuickActionWindowSceneDelegate: UIResponder, UIWindowSceneDelegate { ) { guard let windowScene = scene as? UIWindowScene else { return } + // Record this connection and remember whether it was the process's first (cold-launch) one. macOS + // reconnects this scene in the background throughout the app's lifetime, so only the first connection + // represents a user-initiated launch (see `MacBrowserSceneLauncher`). + let isInitialSceneConnection = MacBrowserSceneLauncher.markSceneConnected() + // Cold launch via a quick action: the app was launched by tapping an app-icon shortcut. // This getter does not exist on macOS 10.15, so check that it responds before accessing it. if connectionOptions.responds(to: #selector(getter: UIScene.ConnectionOptions.shortcutItem)), @@ -35,13 +40,15 @@ final class QuickActionWindowSceneDelegate: UIResponder, UIWindowSceneDelegate { return } - // "Open Home Assistant UI in browser" (Mac): when the app is opened by tapping its icon, open - // Home Assistant in the default browser and destroy the empty webview window so none is left behind. - // This only runs on macOS, where the last-known network information is read live from macBridge, - // so the synchronous evaluation is current. - if Current.isCatalyst, Current.settingsStore.macNativeFeaturesOnly, + // "Open Home Assistant UI in browser" (Mac): the last-known network information is read live from + // macBridge, so the synchronous evaluation is current. See `MacBrowserSceneLauncher` for why the empty + // window is destroyed on every connection but the browser opens only on cold launch (#4985). + let browserActions = MacBrowserSceneLauncher.actions(isInitialConnection: isInitialSceneConnection) + if browserActions.destroysEmptyWindow, let url = Current.servers.all.first?.activeURLUsingLastKnownNetworkState() { - URLOpener.shared.open(url, options: [:], completionHandler: nil) + if browserActions.opensBrowser { + URLOpener.shared.open(url, options: [:], completionHandler: nil) + } UIApplication.shared.requestSceneSessionDestruction(session, options: nil, errorHandler: nil) } } diff --git a/Tests/App/Scenes/MacBrowserSceneLauncher.test.swift b/Tests/App/Scenes/MacBrowserSceneLauncher.test.swift new file mode 100644 index 0000000000..4db591226c --- /dev/null +++ b/Tests/App/Scenes/MacBrowserSceneLauncher.test.swift @@ -0,0 +1,85 @@ +@testable import HomeAssistant +@testable import Shared +import XCTest + +final class MacBrowserSceneLauncherTests: XCTestCase { + private let originalIsCatalyst = Current.isCatalyst + private let originalMacNativeFeaturesOnly = Current.settingsStore.macNativeFeaturesOnly + + override func setUpWithError() throws { + try super.setUpWithError() + MacBrowserSceneLauncher.resetInitialSceneConnectionForTesting() + } + + override func tearDownWithError() throws { + Current.isCatalyst = originalIsCatalyst + Current.settingsStore.macNativeFeaturesOnly = originalMacNativeFeaturesOnly + MacBrowserSceneLauncher.resetInitialSceneConnectionForTesting() + try super.tearDownWithError() + } + + func testMarkSceneConnectedReturnsTrueOnlyForInitialConnection() { + XCTAssertTrue(MacBrowserSceneLauncher.markSceneConnected()) + XCTAssertFalse(MacBrowserSceneLauncher.markSceneConnected()) + XCTAssertFalse(MacBrowserSceneLauncher.markSceneConnected()) + } + + func testMarkSceneConnectedSetsHandledFlag() { + XCTAssertFalse(MacBrowserSceneLauncher.didHandleInitialSceneConnection) + MacBrowserSceneLauncher.markSceneConnected() + XCTAssertTrue(MacBrowserSceneLauncher.didHandleInitialSceneConnection) + } + + func testBrowserLaunchEnabledRequiresCatalystAndPreference() { + Current.isCatalyst = true + Current.settingsStore.macNativeFeaturesOnly = true + XCTAssertTrue(MacBrowserSceneLauncher.isBrowserLaunchEnabled) + } + + func testBrowserLaunchDisabledWhenPreferenceOff() { + Current.isCatalyst = true + Current.settingsStore.macNativeFeaturesOnly = false + XCTAssertFalse(MacBrowserSceneLauncher.isBrowserLaunchEnabled) + } + + func testBrowserLaunchDisabledWhenNotCatalyst() { + Current.isCatalyst = false + Current.settingsStore.macNativeFeaturesOnly = true + XCTAssertFalse(MacBrowserSceneLauncher.isBrowserLaunchEnabled) + } + + func testActionsOnInitialConnectionOpenBrowserAndDestroyWindow() { + Current.isCatalyst = true + Current.settingsStore.macNativeFeaturesOnly = true + let actions = MacBrowserSceneLauncher.actions(isInitialConnection: true) + XCTAssertTrue(actions.opensBrowser) + XCTAssertTrue(actions.destroysEmptyWindow) + } + + func testActionsOnReconnectionDestroyWindowWithoutReopeningBrowser() { + Current.isCatalyst = true + Current.settingsStore.macNativeFeaturesOnly = true + let actions = MacBrowserSceneLauncher.actions(isInitialConnection: false) + XCTAssertFalse(actions.opensBrowser) + XCTAssertTrue(actions.destroysEmptyWindow) + } + + func testActionsAreNoOpWhenPreferenceOff() { + Current.isCatalyst = true + Current.settingsStore.macNativeFeaturesOnly = false + let initial = MacBrowserSceneLauncher.actions(isInitialConnection: true) + XCTAssertFalse(initial.opensBrowser) + XCTAssertFalse(initial.destroysEmptyWindow) + let reconnection = MacBrowserSceneLauncher.actions(isInitialConnection: false) + XCTAssertFalse(reconnection.opensBrowser) + XCTAssertFalse(reconnection.destroysEmptyWindow) + } + + func testActionsAreNoOpWhenNotCatalyst() { + Current.isCatalyst = false + Current.settingsStore.macNativeFeaturesOnly = true + let actions = MacBrowserSceneLauncher.actions(isInitialConnection: true) + XCTAssertFalse(actions.opensBrowser) + XCTAssertFalse(actions.destroysEmptyWindow) + } +}