diff --git a/OverflowBar/Services/MenuBarItemStore.swift b/OverflowBar/Services/MenuBarItemStore.swift index 5f267df..7cc9255 100644 --- a/OverflowBar/Services/MenuBarItemStore.swift +++ b/OverflowBar/Services/MenuBarItemStore.swift @@ -13,6 +13,7 @@ import Combine @Published private(set) var selectedIDs: [String] private let preferences: PreferencesStore private let engine: MenuBarEngine + private let supportsHiding: Bool private var records: [String: EngineItemRecord] = [:] private var dividerFrame: CGRect? private var refreshTask: Task? @@ -32,11 +33,13 @@ import Combine private var observers: [NSObjectProtocol] = [] private var timer: Timer? var onLayoutStateChanged: (() -> Void)? - init(preferences: PreferencesStore = PreferencesStore(), engine: MenuBarEngine? = nil) { + init(preferences: PreferencesStore = PreferencesStore(), engine: MenuBarEngine? = nil, supportsHiding: Bool = MenuBarGroupGeometry.supportsHiding(osMajorVersion: ProcessInfo.processInfo.operatingSystemVersion.majorVersion)) { self.preferences = preferences + self.supportsHiding = supportsHiding self.engine = engine ?? NativeMenuBarEngine() selectedIDs = preferences.selectedNativeItems - layoutManagementEnabled = preferences.layoutManagementEnabled + layoutManagementEnabled = supportsHiding && preferences.layoutManagementEnabled + if !supportsHiding { preferences.layoutManagementEnabled = false } self.engine.visibility = { [weak self] hidden in self?.groupHidden = hidden; self?.onLayoutStateChanged?() } @@ -139,13 +142,20 @@ import Combine guard layoutTask == nil, operationProgress == nil else { return } selectedIDs = ids preferences.selectedNativeItems = ids - layoutManagementEnabled = hideSelectedIcons && !ids.isEmpty + layoutManagementEnabled = supportsHiding && hideSelectedIcons && !ids.isEmpty preferences.layoutManagementEnabled = layoutManagementEnabled automaticManagementSuspended = !layoutManagementEnabled if layoutManagementEnabled || groupHidden { applyLayout(automatic: false) } - else { layoutOperationMessage = "Choose the icons to place in the second row." } + else { layoutOperationMessage = supportsHiding ? "Choose the icons to place in the second row." : "Hiding icons is unavailable on this macOS version." } } func setLayoutManagementEnabled(_ enabled: Bool) { + if !supportsHiding { + layoutManagementEnabled = false + preferences.layoutManagementEnabled = false + automaticManagementSuspended = true + layoutOperationMessage = "Hiding icons is unavailable on this macOS version." + return + } if !enabled { cancelOperation() } layoutManagementEnabled = enabled && !selectedIDs.isEmpty automaticManagementSuspended = !layoutManagementEnabled @@ -153,6 +163,7 @@ import Combine applyLayout(automatic: false) } private func applyLayout(automatic: Bool) { + guard supportsHiding else { return } guard layoutTask == nil else { layoutOperationMessage = "A layout operation is already running. Cancel it before changing selection."; return } operationProgress = "Finding menu bar icons" let selection = selectedIDs diff --git a/OverflowBarTests/MenuBarItemStoreTests.swift b/OverflowBarTests/MenuBarItemStoreTests.swift index 5877148..b7d2c08 100644 --- a/OverflowBarTests/MenuBarItemStoreTests.swift +++ b/OverflowBarTests/MenuBarItemStoreTests.swift @@ -9,7 +9,7 @@ final class MenuBarItemStoreTests: XCTestCase { defer { UserDefaults.standard.removePersistentDomain(forName: suite) } preferences.layoutManagementEnabled = true let engine = StoreTestEngine(items: [item("existing")]) - let store = MenuBarItemStore(preferences: preferences, engine: engine) + let store = MenuBarItemStore(preferences: preferences, engine: engine, supportsHiding: true) store.refresh() await waitUntil { preferences.knownNativeItems != nil } @@ -25,7 +25,7 @@ final class MenuBarItemStoreTests: XCTestCase { preferences.layoutManagementEnabled = true preferences.verifiedNativeOrder = ["existing"] let engine = StoreTestEngine(items: [item("existing"), item("new")]) - let store = MenuBarItemStore(preferences: preferences, engine: engine) + let store = MenuBarItemStore(preferences: preferences, engine: engine, supportsHiding: true) store.refresh() await waitUntil { engine.appliedSelections.count == 1 } @@ -36,7 +36,7 @@ final class MenuBarItemStoreTests: XCTestCase { XCTAssertEqual(engine.appliedSelections, [["new"]]) let relaunchedEngine = StoreTestEngine(items: [item("existing"), item("new")]) - let relaunchedStore = MenuBarItemStore(preferences: preferences, engine: relaunchedEngine) + let relaunchedStore = MenuBarItemStore(preferences: preferences, engine: relaunchedEngine, supportsHiding: true) relaunchedStore.refresh() await waitUntil { relaunchedStore.items.count == 2 } XCTAssertTrue(relaunchedEngine.appliedSelections.isEmpty) @@ -46,7 +46,7 @@ final class MenuBarItemStoreTests: XCTestCase { let (preferences, suite) = makePreferences() defer { UserDefaults.standard.removePersistentDomain(forName: suite) } let engine = StoreTestEngine(items: [item("existing")]) - let store = MenuBarItemStore(preferences: preferences, engine: engine) + let store = MenuBarItemStore(preferences: preferences, engine: engine, supportsHiding: true) store.refresh() await waitUntil { preferences.knownNativeItems != nil } @@ -63,7 +63,7 @@ final class MenuBarItemStoreTests: XCTestCase { defer { UserDefaults.standard.removePersistentDomain(forName: suite) } preferences.layoutManagementEnabled = true let engine = StoreTestEngine(items: [item("existing")]) - let store = MenuBarItemStore(preferences: preferences, engine: engine) + let store = MenuBarItemStore(preferences: preferences, engine: engine, supportsHiding: true) store.refresh() await waitUntil { preferences.knownNativeItems != nil } @@ -83,7 +83,7 @@ final class MenuBarItemStoreTests: XCTestCase { preferences.layoutManagementEnabled = true let engine = StoreTestEngine(items: []) engine.waitForCancel = true - let store = MenuBarItemStore(preferences: preferences, engine: engine) + let store = MenuBarItemStore(preferences: preferences, engine: engine, supportsHiding: true) store.setLayoutManagementEnabled(true) await waitUntil { engine.applyCount == 1 } @@ -96,6 +96,26 @@ final class MenuBarItemStoreTests: XCTestCase { XCTAssertTrue(preferences.layoutManagementEnabled) } + func testUnsupportedSystemDisablesSavedLayoutWithoutReentrantCancellation() { + let (preferences, suite) = makePreferences() + defer { UserDefaults.standard.removePersistentDomain(forName: suite) } + preferences.selectedNativeItems = ["selected"] + preferences.layoutManagementEnabled = true + let engine = StoreTestEngine(items: []) + let store = MenuBarItemStore(preferences: preferences, engine: engine, supportsHiding: false) + store.onLayoutStateChanged = { store.setLayoutManagementEnabled(false) } + engine.visibility?(false) + store.setLayoutManagementEnabled(false) + store.setLayoutManagementEnabled(true) + store.configureSelection(["selected"], hideSelectedIcons: true) + XCTAssertEqual(engine.cancelCount, 0) + XCTAssertEqual(store.layoutOperationMessage, "Hiding icons is unavailable on this macOS version.") + XCTAssertFalse(store.layoutManagementEnabled) + XCTAssertFalse(preferences.layoutManagementEnabled) + XCTAssertFalse(store.groupHidden) + XCTAssertTrue(engine.appliedSelections.isEmpty) + } + private func makePreferences() -> (PreferencesStore, String) { let suite = "OverflowBarTests.\(UUID().uuidString)" return (PreferencesStore(defaults: UserDefaults(suiteName: suite)!), suite) @@ -123,6 +143,7 @@ private final class StoreTestEngine: MenuBarEngine { private(set) var maximumConcurrentApplies = 0 private var activeApplies = 0 var waitForCancel = false + private(set) var cancelCount = 0 private var applyContinuation: CheckedContinuation? var visibility: ((Bool) -> Void)? var message: ((String) -> Void)? @@ -142,5 +163,5 @@ private final class StoreTestEngine: MenuBarEngine { } func capture(_ items: [EngineItemRecord]) async -> [EngineCaptureFrame] { [] } func activate(_ id: String, button: CGMouseButton) async -> MenuBarActivationResult { .activated } - func cancel() { applyContinuation?.resume(); applyContinuation = nil; visibility?(false) } + func cancel() { cancelCount += 1; applyContinuation?.resume(); applyContinuation = nil; visibility?(false) } } diff --git a/OverflowBarTests/OnboardingFlowTests.swift b/OverflowBarTests/OnboardingFlowTests.swift index 105dd56..745aea6 100644 --- a/OverflowBarTests/OnboardingFlowTests.swift +++ b/OverflowBarTests/OnboardingFlowTests.swift @@ -43,7 +43,7 @@ final class OnboardingFlowTests: XCTestCase { let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } let preferences = PreferencesStore(defaults: defaults) - let store = MenuBarItemStore(preferences: preferences) + let store = MenuBarItemStore(preferences: preferences, supportsHiding: true) OnboardingCompletion.finish(selectedIDs: ["menu-item-1", "menu-item-2"], hideSelectedIcons: false).apply(to: store) @@ -60,7 +60,7 @@ final class OnboardingFlowTests: XCTestCase { let preferences = PreferencesStore(defaults: defaults) preferences.selectedNativeItems = ["previously-hidden"] preferences.layoutManagementEnabled = true - let store = MenuBarItemStore(preferences: preferences) + let store = MenuBarItemStore(preferences: preferences, supportsHiding: true) OnboardingCompletion.setUpLater.apply(to: store)