diff --git a/Ghostly.xcodeproj/project.pbxproj b/Ghostly.xcodeproj/project.pbxproj index 84419bd..6395539 100644 --- a/Ghostly.xcodeproj/project.pbxproj +++ b/Ghostly.xcodeproj/project.pbxproj @@ -270,7 +270,7 @@ mainGroup = C836C54C25A0171500BEB83F; packageReferences = ( KBSH000525E0000000000005 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */, - MBEA000225E0000000000002 /* XCLocalSwiftPackageReference "LocalPackages/MenuBarExtraAccess" */, + MBEA000225E0000000000002 /* XCRemoteSwiftPackageReference "MenuBarExtraAccess" */, ); productRefGroup = C836C55625A0171500BEB83F /* Products */; projectDirPath = ""; @@ -609,14 +609,15 @@ minimumVersion = 2.0.0; }; }; -/* End XCRemoteSwiftPackageReference section */ - -/* Begin XCLocalSwiftPackageReference section */ - MBEA000225E0000000000002 /* XCLocalSwiftPackageReference "LocalPackages/MenuBarExtraAccess" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = LocalPackages/MenuBarExtraAccess; + MBEA000225E0000000000002 /* XCRemoteSwiftPackageReference "MenuBarExtraAccess" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/orchetect/MenuBarExtraAccess"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; }; -/* End XCLocalSwiftPackageReference section */ +/* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ KBSH000025E0000000000000 /* KeyboardShortcuts */ = { @@ -631,7 +632,7 @@ }; MBEA000025E0000000000000 /* MenuBarExtraAccess */ = { isa = XCSwiftPackageProductDependency; - package = MBEA000225E0000000000002 /* XCLocalSwiftPackageReference "LocalPackages/MenuBarExtraAccess" */; + package = MBEA000225E0000000000002 /* XCRemoteSwiftPackageReference "MenuBarExtraAccess" */; productName = MenuBarExtraAccess; }; /* End XCSwiftPackageProductDependency section */ diff --git a/Ghostly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Ghostly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5d57d23..c8e4c4e 100644 --- a/Ghostly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Ghostly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,6 +9,15 @@ "revision" : "1aef85578fdd4f9eaeeb8d53b7b4fc31bf08fe27", "version" : "2.4.0" } + }, + { + "identity" : "menubarextraaccess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/orchetect/MenuBarExtraAccess", + "state" : { + "revision" : "707dff6f55217b3ef5b6be84ced3e83511d4df5c", + "version" : "1.2.2" + } } ], "version" : 3 diff --git a/Ghostly/AppState.swift b/Ghostly/AppState.swift index 3ccae24..6d93e89 100644 --- a/Ghostly/AppState.swift +++ b/Ghostly/AppState.swift @@ -16,9 +16,6 @@ final class AppState { updateTabShortcuts(enabled: isMenuPresented) } } - var isPinned: Bool = UserDefaults.standard.bool(forKey: "isPinned") { - didSet { UserDefaults.standard.set(isPinned, forKey: "isPinned") } - } let settingsManager = SettingsManager() var isSettingsOpen: Bool { get { settingsManager.isSettingsOpen } diff --git a/Ghostly/GhostlyApp.swift b/Ghostly/GhostlyApp.swift index 33564a1..12a8f9e 100644 --- a/Ghostly/GhostlyApp.swift +++ b/Ghostly/GhostlyApp.swift @@ -24,7 +24,7 @@ struct GhostlyApp: App { } } .menuBarExtraStyle(.window) - .menuBarExtraAccess(isPresented: $appState.isMenuPresented, staysOpen: $appState.isPinned) { statusItem in + .menuBarExtraAccess(isPresented: $appState.isMenuPresented) { statusItem in statusItemContextMenuController.configure(statusItem: statusItem, appState: appState) } } diff --git a/Ghostly/Views/ContentView.swift b/Ghostly/Views/ContentView.swift index bc81156..191c128 100644 --- a/Ghostly/Views/ContentView.swift +++ b/Ghostly/Views/ContentView.swift @@ -50,7 +50,7 @@ struct ContentView: View { ).ignoresSafeArea() VStack(alignment: .leading, spacing: 0) { - HeaderView(appState: appState) + HeaderView(settingsManager: settingsManager) if tabManager.tabs.count > 1 { TabBarView(tabManager: tabManager) diff --git a/Ghostly/Views/HeaderView.swift b/Ghostly/Views/HeaderView.swift index 02687ec..5ca1e32 100644 --- a/Ghostly/Views/HeaderView.swift +++ b/Ghostly/Views/HeaderView.swift @@ -8,11 +8,9 @@ import SwiftUI struct HeaderView: View { - @Bindable var appState: AppState + var settingsManager: SettingsManager @State private var isHovered = false - private var settingsManager: SettingsManager { appState.settingsManager } - var body: some View { VStack(spacing: 0) { HStack { @@ -37,20 +35,6 @@ struct HeaderView: View { Spacer() - // Pin button — keeps window open when focus moves to another app - Button { - appState.isPinned.toggle() - } label: { - Image(systemName: appState.isPinned ? "pin.fill" : "pin") - .font(.system(size: 12)) - .foregroundStyle(appState.isPinned ? Color.catLavender : Color.catOverlay.opacity(0.7)) - .rotationEffect(.degrees(45)) - } - .buttonStyle(.plain) - .frame(width: 28, height: 28) - .help(appState.isPinned ? "Unpin (window will close when focus is lost)" : "Pin (keep window open when switching apps)") - .accessibilityIdentifier("pinButton") - // Menu button DropdownMenuView(settingsManager: settingsManager) .frame(width: 28, height: 28) diff --git a/LocalPackages/MenuBarExtraAccess/Package.swift b/LocalPackages/MenuBarExtraAccess/Package.swift deleted file mode 100644 index aa3de8f..0000000 --- a/LocalPackages/MenuBarExtraAccess/Package.swift +++ /dev/null @@ -1,20 +0,0 @@ -// swift-tools-version: 5.7 - -import PackageDescription - -let package = Package( - name: "MenuBarExtraAccess", - platforms: [.macOS(.v10_15)], - products: [ - .library(name: "MenuBarExtraAccess", targets: ["MenuBarExtraAccess"]) - ], - targets: [ - .target( - name: "MenuBarExtraAccess", - swiftSettings: [ - // un-comment to enable debug logging - // .define("MENUBAREXTRAACCESS_DEBUG_LOGGING=1") - ] - ) - ] -) diff --git a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/MenuBarExtra Window Introspection.swift b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/MenuBarExtra Window Introspection.swift deleted file mode 100644 index 57c4a45..0000000 --- a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/MenuBarExtra Window Introspection.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// MenuBarExtra Window Introspection.swift -// MenuBarExtraAccess • https://github.com/orchetect/MenuBarExtraAccess -// © 2023 Steffan Andrews • Licensed under MIT License -// - -#if os(macOS) - -import SwiftUI - -@MainActor // required for Xcode 15 builds -extension View { - /// Provides introspection on the underlying window presented by `MenuBarExtra`. - /// Add this view modifier to the top level of the View that occupies the `MenuBarExtra` content. - /// If more than one MenuBarExtra are used in the app, provide the sequential index number of the `MenuBarExtra`. - public func introspectMenuBarExtraWindow( - index: Int = 0, - _ block: @escaping (_ window: NSWindow) -> Void - ) -> some View { - self - .onAppear { - guard let window = MenuBarExtraUtils.window(for: .index(index)) else { - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("Cannot call introspection block for status item because its window could not be found.") - #endif - - return - } - - block(window) - } - } -} - -#endif diff --git a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/MenuBarExtraAccess.swift b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/MenuBarExtraAccess.swift deleted file mode 100644 index f5ba7b9..0000000 --- a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/MenuBarExtraAccess.swift +++ /dev/null @@ -1,288 +0,0 @@ -// -// MenuBarExtraAccess.swift -// MenuBarExtraAccess • https://github.com/orchetect/MenuBarExtraAccess -// © 2023 Steffan Andrews • Licensed under MIT License -// - -#if os(macOS) - -import SwiftUI -import Combine - -@available(macOS 13.0, *) -@available(iOS, unavailable) -@available(tvOS, unavailable) -@available(watchOS, unavailable) -@MainActor // required for Xcode 15 builds -extension Scene { - /// Adds a presentation state binding to `MenuBarExtra`. - /// If more than one MenuBarExtra are used in the app, provide the sequential index number of the `MenuBarExtra`. - /// - Parameter staysOpen: When `true`, the window will not auto-close when it loses key status - /// (e.g. when the user switches to another app). The window can still be dismissed explicitly - /// by toggling the status item or by setting `isPresented` to `false`. - public func menuBarExtraAccess( - index: Int = 0, - isPresented: Binding, - isEnabled: Binding = .constant(true), - staysOpen: Binding = .constant(false), - statusItem: ((_ statusItem: NSStatusItem) -> Void)? = nil - ) -> some Scene { - // FYI: SwiftUI will reinitialize the MenuBarExtra (and this view modifier) - // if its title/label content changes, which means the stored ID will always be up-to-date - - MenuBarExtraAccess( - index: index, - statusItemIntrospection: statusItem, - menuBarExtra: self, - isMenuPresented: isPresented, - isStatusItemEnabled: isEnabled, - staysOpen: staysOpen - ) - } -} - -@available(macOS 13.0, *) -@available(iOS, unavailable) -@available(tvOS, unavailable) -@available(watchOS, unavailable) -@MainActor // required for Xcode 15 builds -struct MenuBarExtraAccess: Scene { - let index: Int - let statusItemIntrospection: ((_ statusItem: NSStatusItem) -> Void)? - let menuBarExtra: Content - @Binding var isMenuPresented: Bool - @Binding var isStatusItemEnabled: Bool - @Binding var staysOpen: Bool - - init( - index: Int, - statusItemIntrospection: ((_ statusItem: NSStatusItem) -> Void)?, - menuBarExtra: Content, - isMenuPresented: Binding, - isStatusItemEnabled: Binding, - staysOpen: Binding - ) { - self.index = index - self.statusItemIntrospection = statusItemIntrospection - self.menuBarExtra = menuBarExtra - self._isMenuPresented = isMenuPresented - self._isStatusItemEnabled = isStatusItemEnabled - self._staysOpen = staysOpen - } - - var body: some Scene { - menuBarExtra - .onChange(of: observerSetup()) { newValue in - // do nothing here - the method runs setup when polled by SwiftUI - } - .onChange(of: isMenuPresented) { newValue in - setPresented(newValue) - } - .onChange(of: isStatusItemEnabled) { newValue in - setStatusItemEnabled(newValue) - } - } - - private func togglePresented() { - MenuBarExtraUtils.togglePresented(for: .index(index)) - } - - private func setPresented(_ state: Bool) { - var state = state - if state, !isStatusItemEnabled { - // prevent presenting menu/window if item is disabled - state = false - } - MenuBarExtraUtils.setPresented(for: .index(index), state: state) - } - - private func setStatusItemEnabled(_ state: Bool) { - if !state, isMenuPresented { - // close menu if it's open - isMenuPresented = false - MenuBarExtraUtils.setPresented(for: .index(index), state: false) - } - - MenuBarExtraUtils.setEnabled(for: .index(index), state: state) - } - - // MARK: Observer - - /// A workaround since `onAppear {}` is not available in a SwiftUI Scene. - /// We need to set up the observer, but it can't be set up in the scene init because it needs to - /// update scene state from an escaping closure. - /// This returns a bogus value, but because we call it in an `onChange {}` block, SwiftUI - /// is forced to evaluate the method and run our code at the appropriate time. - private func observerSetup() -> Int { - observerContainer.setupStatusItemIntrospection { - guard let statusItem = MenuBarExtraUtils.statusItem(for: .index(index)) else { return } - statusItemIntrospection?(statusItem) - - // initial setup - setStatusItemEnabled(isStatusItemEnabled) - } - - // note that we can't use the button state value itself since MenuBarExtra seems to treat it - // as a toggle and not an absolute on/off value. Its polarity can invert itself when clicking - // in an empty area of the menubar or a different app's status item in order to dismiss the window, - // for example. - observerContainer.setupStatusItemButtonStateObserver { - MenuBarExtraUtils.newStatusItemButtonStateObserver(index: index) { change in - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("Status item button state observer: called with change: \(change.newValue?.name ?? "nil")") - #endif - - // only continue if the MenuBarExtra is menu-based. - // window-based MenuBarExtras are handled with app-bound window observers instead. - guard MenuBarExtraUtils.statusItem(for: .index(index))? - .isMenuBarExtraMenuBased == true - else { return } - - guard let newVal = change.newValue else { return } - let newBool = newVal != .off - if isMenuPresented != newBool { - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("Status item button state observer: Setting isMenuPresented to \(newBool)") - #endif - - isMenuPresented = newBool - } - } - } - - // TODO: this mouse event observer is now redundant and can be deleted in the future - - // observerContainer.setupGlobalMouseDownMonitor { - // // note that this won't fire when mouse events within the app cause the window to dismiss - // MenuBarExtraUtils.newGlobalMouseDownEventsMonitor { event in - // #if MENUBAREXTRAACCESS_DEBUG_LOGGING - // print("Global mouse-down events monitor: called with event: \(event.type.name)") - // #endif - // - // // close window when user clicks outside of it - // - // MenuBarExtraUtils.setPresented(for: .index(index), state: false) - // - // #if MENUBAREXTRAACCESS_DEBUG_LOGGING - // print("Global mouse-down events monitor: Setting isMenuPresented to false") - // #endif - // - // isMenuPresented = false - // } - // } - - observerContainer.setupWindowObservers( - index: index, - didBecomeKey: { window in - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("MenuBarExtra index \(index) drop-down window did become key.") - #endif - - MenuBarExtraUtils.setKnownPresented(for: .index(index), state: true) - isMenuPresented = true - }, - didResignKey: { window in - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("MenuBarExtra index \(index) drop-down window did resign as key.") - #endif - - if staysOpen { - if window.isVisible { - // Window lost focus but pin mode is active — keep it open. - return - } else { - // Window was closed by other means (e.g. status item click) — update state. - MenuBarExtraUtils.setKnownPresented(for: .index(index), state: false) - isMenuPresented = false - return - } - } - - // it's possible for a window to resign key without actually closing, so let's - // close it as a failsafe. - if window.isVisible { - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("Closing MenuBarExtra index \(index) drop-down window as a result of it resigning as key.") - #endif - - window.close() - } - - MenuBarExtraUtils.setKnownPresented(for: .index(index), state: false) - isMenuPresented = false - } - ) - - return 0 - } - - // MARK: Observers - - private var observerContainer = ObserverContainer() - - @MainActor - private class ObserverContainer { - private var statusItemIntrospectionSetup: Bool = false - private var observer: NSStatusItem.ButtonStateObserver? - private var eventsMonitor: Any? - private var windowDidBecomeKeyObserver: AnyCancellable? - private var windowDidResignKeyObserver: AnyCancellable? - - init() { } - - func setupStatusItemIntrospection( - _ block: @MainActor @escaping @Sendable () -> Void - ) { - guard !statusItemIntrospectionSetup else { return } - // run async so that it can execute after SwiftUI sets up the NSStatusItem - Task { @MainActor in - block() - } - } - - func setupStatusItemButtonStateObserver( - _ block: @MainActor @escaping @Sendable () -> NSStatusItem.ButtonStateObserver? - ) { - // run async so that it can execute after SwiftUI sets up the NSStatusItem - Task { @MainActor [self] in - observer = block() - } - } - - func setupGlobalMouseDownMonitor( - _ block: @MainActor @escaping @Sendable () -> Any? - ) { - // run async so that it can execute after SwiftUI sets up the NSStatusItem - Task { @MainActor [self] in - // tear down old monitor, if one exists - if let eventsMonitor = eventsMonitor { - NSEvent.removeMonitor(eventsMonitor) - } - - eventsMonitor = block() - } - } - - func setupWindowObservers( - index: Int, - didBecomeKey didBecomeKeyBlock: @MainActor @escaping @Sendable (_ window: NSWindow) -> Void, - didResignKey didResignKeyBlock: @MainActor @escaping @Sendable (_ window: NSWindow) -> Void - ) { - // run async so that it can execute after SwiftUI sets up the NSStatusItem - Task { @MainActor [self] in - windowDidBecomeKeyObserver = MenuBarExtraUtils.newWindowObserver( - index: index, - for: NSWindow.didBecomeKeyNotification - ) { window in didBecomeKeyBlock(window) } - - windowDidResignKeyObserver = MenuBarExtraUtils.newWindowObserver( - index: index, - for: NSWindow.didResignKeyNotification - ) { window in didResignKeyBlock(window) } - - } - } - } -} - -#endif diff --git a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/MenuBarExtraUtils/MenuBarExtraUtils.swift b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/MenuBarExtraUtils/MenuBarExtraUtils.swift deleted file mode 100644 index 018fa40..0000000 --- a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/MenuBarExtraUtils/MenuBarExtraUtils.swift +++ /dev/null @@ -1,422 +0,0 @@ -// -// MenuBarExtraUtils.swift -// MenuBarExtraAccess • https://github.com/orchetect/MenuBarExtraAccess -// © 2023 Steffan Andrews • Licensed under MIT License -// - -#if os(macOS) - -import AppKit -import SwiftUI -import Combine - -/// Global static utility methods for interacting the app's menu bar extras (status items). -@MainActor -enum MenuBarExtraUtils { - // MARK: - Menu Extra Manipulation - - /// Toggle MenuBarExtra menu/window presentation state. - static func togglePresented(for ident: StatusItemIdentity? = nil) { - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("MenuBarExtraUtils.\(#function) called for status item \(ident?.description ?? "nil")") - #endif - - guard let item = statusItem(for: ident) else { return } - item.togglePresented() - } - - /// Set MenuBarExtra menu/window presentation state. - static func setPresented(for ident: StatusItemIdentity? = nil, state: Bool) { - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("MenuBarExtraUtils.\(#function) called for status item \(ident?.description ?? "nil") with state \(state)") - #endif - - guard let item = statusItem(for: ident) else { return } - item.setPresented(state: state) - } - - /// Set MenuBarExtra menu/window presentation state only when its state is reliably known. - static func setKnownPresented(for ident: StatusItemIdentity? = nil, state: Bool) { - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("MenuBarExtraUtils.\(#function) called for status item \(ident?.description ?? "nil") with state \(state)") - #endif - - guard let item = statusItem(for: ident) else { return } - item.setKnownPresented(state: state) - } - - static func setEnabled(for ident: StatusItemIdentity? = nil, state: Bool) { - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("MenuBarExtraUtils.\(#function) called for status item \(ident?.description ?? "nil") with state \(state)") - #endif - - guard let item = statusItem(for: ident) else { return } - item.setEnabled(state: state) - } -} - -// MARK: - Objects and Metadata - -@MainActor -extension MenuBarExtraUtils { - /// Returns the underlying status item(s) created by `MenuBarExtra` instances. - /// - /// Each `MenuBarExtra` creates one status item. - /// - /// If the `isInserted` binding on a `MenuBarExtra` is set to false, it may not return a status - /// item. This may also change its index. - static var statusItems: [NSStatusItem] { - NSApp.windows - .filter { - $0.className.contains("NSStatusBarWindow") - } - .compactMap { window -> NSStatusItem? in - // On Macs with only one display, there should only be one result. - // On Macs with two or more displays and system prefs set to "Displays have Separate - // Spaces", one NSStatusBarWindow instance per display will be returned. - // - the main/active instance has a statusItem property of type NSStatusItem - // - the other(s) have a statusItem property of type NSStatusItemReplicant - - // NSStatusItemReplicant is a replica for displaying the status item on inactive - // spaces/screens that happens to be an NSStatusItem subclass. - // both respond to the action selector being sent to them. - // We only need to interact with the main non-replica status item. - - let statusItemClassName: String - if #available(macOS 26.0, *) { - statusItemClassName = "NSSceneStatusItem" - } else { // macOS 10.15.x through 15.x - statusItemClassName = "NSStatusItem" - } - - guard let statusItem = window.fetchStatusItem(), - statusItem.className == statusItemClassName - else { return nil } - return statusItem - } - } - - /// Returns the underlying status items created by `MenuBarExtra` for the - /// `MenuBarExtra` with the specified index. - /// - /// Each `MenuBarExtra` creates one status item. - /// - /// If the `isInserted` binding on a `MenuBarExtra` is set to false, it may not return a status - /// item. This may also change its index. - static func statusItem(for ident: StatusItemIdentity? = nil) -> NSStatusItem? { - let statusItems = statusItems - - guard let ident else { return statusItems.first } - - switch ident { - case .id(let menuBarExtraID): - return statusItems.filter { $0.menuBarExtraID == menuBarExtraID }.first - case .index(let index): - guard statusItems.indices.contains(index) else { return nil } - return statusItems[index] - } - } - - /// Returns window associated with a window-based MenuBarExtra. - /// Always returns `nil` for a menu-based MenuBarExtra. - static func window(for ident: StatusItemIdentity? = nil) -> NSWindow? { - // we can't use NSStatusItem.window because it won't work - - let menuBarWindows = NSApp.windows.filter { - $0.className.contains("MenuBarExtraWindow") - } - - guard let ident else { return menuBarWindows.first } - - switch ident { - case .id(let menuBarExtraID): - guard let match = menuBarWindows.first(where: { $0.menuBarExtraID == menuBarExtraID }) else { - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("MenuBarExtraUtils.\(#function): Window could not be found for status item with ID \"\(menuBarExtraID).") - #endif - - return nil - } - return match - case .index(_): - guard let item = statusItem(for: ident) else { return nil } - - return menuBarWindows.first { window in - guard let statusItem = window.fetchStatusItem() else { return false } - return item == statusItem - } - } - } -} - -// MARK: - Observers - -@MainActor -extension MenuBarExtraUtils { - /// Call from MenuBarExtraAccess init to set up observer. - static func newStatusItemButtonStateObserver( - index: Int, - _ handler: @MainActor @escaping @Sendable (_ change: NSKeyValueObservedChange) -> Void - ) -> NSStatusItem.ButtonStateObserver? { - guard let statusItem = MenuBarExtraUtils.statusItem(for: .index(index)) else { - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("Can't register menu bar extra state observer: Can't find status item. It may not yet exist.") - #endif - - return nil - } - - guard let observer = statusItem.stateObserverMenuBased(handler) - else { - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("Can't register menu bar extra state observer: Can't generate observer.") - #endif - - return nil - } - - return observer - } - - /// Adds global event monitor to catch mouse events outside the application. - static func newGlobalMouseDownEventsMonitor( - _ handler: @escaping @Sendable (NSEvent) -> Void - ) -> Any? { - NSEvent.addGlobalMonitorForEvents( - matching: [ - .leftMouseDown, - .rightMouseDown, - .otherMouseDown - ], - handler: handler - ) - } - - /// Adds local event monitor to catch mouse events within the application. - static func newLocalMouseDownEventsMonitor( - _ handler: @escaping @Sendable (NSEvent) -> NSEvent? - ) -> Any? { - NSEvent.addLocalMonitorForEvents( - matching: [ - .leftMouseDown, - .rightMouseDown, - .otherMouseDown - ], - handler: handler - ) - } - - static func newStatusItemButtonStatePublisher( - index: Int - ) -> NSStatusItem.ButtonStatePublisher? { - guard let statusItem = MenuBarExtraUtils.statusItem(for: .index(index)) else { - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("Can't register menu bar extra state observer: Can't find status item. It may not yet exist.") - #endif - - return nil - } - - guard let publisher = statusItem.buttonStatePublisher() - else { - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("Can't register menu bar extra state observer: Can't generate publisher.") - #endif - - return nil - } - - return publisher - } - - /// Wraps `newStatusItemButtonStatePublisher` in a sink. - static func newStatusItemButtonStatePublisherSink( - index: Int, - block: @MainActor @escaping @Sendable (_ newValue: NSControl.StateValue?) -> Void - ) -> AnyCancellable? { - newStatusItemButtonStatePublisher(index: index)? - .flatMap { value in - Just(value) - .tryMap { value throws -> NSControl.StateValue in value } - .replaceError(with: nil) - } - .sink(receiveValue: { value in - block(value) - }) - } - - static func newWindowObserver( - index: Int, - for notification: Notification.Name, - block: @MainActor @escaping @Sendable (_ window: NSWindow) -> Void - ) -> AnyCancellable? { - NotificationCenter.default.publisher(for: notification) - .filter { output in - guard let window = output.object as? NSWindow else { return false } - guard let windowWithIndex = MenuBarExtraUtils.window(for: .index(index)) else { return false } - return window == windowWithIndex - } - .sink { output in - guard let window = output.object as? NSWindow else { return } - block(window) - } - } -} - -// MARK: - NSStatusItem Introspection - -@MainActor -extension NSStatusItem { - var menuBarExtraIndex: Int { - MenuBarExtraUtils.statusItems.firstIndex(of: self) ?? 0 - } - - /// Returns the ID string for the status item. - /// Returns `nil` if the status item does not contain a `MacControlCenterMenu` - fileprivate var menuBarExtraID: String? { - // Note: this is not ideal, but it's currently the ONLY way to achieve this - // until Apple adds a 1st-party solution to MenuBarExtra state - - // dump(statusItem.button!.target): - // ▿ some: SwiftUI.WindowMenuBarExtraBehavior #0 - // ▿ super: SwiftUI.MenuBarExtraBehavior - // - statusItem: #1 - // ▿ configuration: SwiftUI.MenuBarExtraConfiguration - // ▿ label: SwiftUI.AnyView ---> contains the status item label, icon - // ▿ mainContent: SwiftUI.AnyView ---> contains the view content - // - storage - // - view - // - ... ---> properties of the view will be itemized here - // - shouldQuitWhenRemoved: true - // ▿ _isInserted: SwiftUI.Binding ---> isInserted binding backing - // - isMenuBased: false - // - implicitID: "YourApp.Menu" - // - resizability: SwiftUI.WindowResizability.Role.automatic - // - defaultSize: nil - // ▿ environment: [] ---> SwiftUI environment vars/locale/openWindow method - - // this may require a less brittle solution if the child path may change, such as grabbing - // String(dump: behavior) and using RegEx to find the value - - guard let behavior = button?.target, // SwiftUI.WindowMenuBarExtraBehavior <- internal - let mirror = Mirror(reflecting: behavior).superclassMirror - else { - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("Could not find status item's target.") - #endif - - return nil - } - - return mirror.menuBarExtraID() - } - - var isMenuBarExtraMenuBased: Bool { - // if window-based, target will be the internal type SwiftUI.WindowMenuBarExtraBehavior - // if menu-based, target will be nil - guard let behavior = button?.target - else { - return true - } - - // the rest of this is probably redundant given the check above covers both scenarios. - // however, WindowMenuBarExtraBehavior does contain an explicit `isMenuBased` Bool we can read - guard let mirror = Mirror(reflecting: behavior).superclassMirror - else { - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("Could not find status item's target.") - #endif - - return false - } - - return mirror.isMenuBarExtraMenuBased() - } -} - -// MARK: - NSWindow Introspection - -@MainActor -extension NSWindow { - fileprivate var menuBarExtraID: String? { - // Note: this is not ideal, but it's currently the ONLY way to achieve this - // until Apple adds a 1st-party solution to MenuBarExtra state - - let mirror = Mirror(reflecting: self) - return mirror.menuBarExtraID() - } -} - -@MainActor -extension Mirror { - fileprivate func menuBarExtraID() -> String? { - // Note: this is not ideal, but it's currently the ONLY way to achieve this - // until Apple adds a 1st-party solution to MenuBarExtra state - - // this may require a less brittle solution if the child path may change, such as grabbing - // String(dump: behavior) and using RegEx to find the value - - // when using MenuBarExtra(title string, content) this is the mirror path: - if let id = descendant( - "configuration", - "label", - "storage", - "view", - "content", - "title", - "storage", - "anyTextStorage", - "key", - "key" - ) as? String { - return id - } - - // this won't work. it differs when checked from MenuBarExtraAccess / menuBarExtraAccess(isPresented:) - // internals. MenuBarExtra wraps the label in additional modifiers/AnyView here. - // - // otherwise, when using a MenuBarExtra initializer that produces Label view content: - // we'll basically grab the hashed contents of the label - if let anyView = descendant( - "configuration", - "label" - ) as? any View { - let hashed = MenuBarExtraUtils.hash(anyView: anyView) - print("hash:", hashed) - return hashed - } - - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("Could not determine MenuBarExtra ID") - #endif - - return nil - } - - fileprivate func isMenuBarExtraMenuBased() -> Bool { - descendant( - "configuration", - "isMenuBased" - ) as? Bool ?? false - } -} - -// MARK: - Misc. - -@MainActor -extension MenuBarExtraUtils { - static func hash(anyView: any View) -> String { - // can't hash `any View` - // - // var h = Hasher() - // let ah = AnyHashable(anyView) - // h.combine(anyView) - // let i = h.finalize() - // return String(i) - - // return "\(anyView)" - return String("\(anyView)".hashValue) - } -} - -#endif diff --git a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/MenuBarExtraUtils/StatusItemIdentity.swift b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/MenuBarExtraUtils/StatusItemIdentity.swift deleted file mode 100644 index cf93b28..0000000 --- a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/MenuBarExtraUtils/StatusItemIdentity.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// MenuBarExtraUtils.swift -// MenuBarExtraAccess • https://github.com/orchetect/MenuBarExtraAccess -// © 2023 Steffan Andrews • Licensed under MIT License -// - -#if os(macOS) - -enum StatusItemIdentity: Equatable, Hashable { - case index(Int) - case id(String) -} - -extension StatusItemIdentity: CustomStringConvertible { - var description: String { - switch self { - case .index(let int): - return "index \(int)" - case .id(let string): - return "ID \"\(string)\"" - } - } -} - -#endif diff --git a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/NSControl Extensions.swift b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/NSControl Extensions.swift deleted file mode 100644 index fe8d903..0000000 --- a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/NSControl Extensions.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// NSControl Extensions.swift -// MenuBarExtraAccess • https://github.com/orchetect/MenuBarExtraAccess -// © 2023 Steffan Andrews • Licensed under MIT License -// - -#if os(macOS) - -import AppKit - -extension NSControl.StateValue { - @_disfavoredOverload - public var name: String { - switch self { - case .on: - return "on" - case .off: - return "off" - case .mixed: - return "mixed" - default: - return "unknown" - } - } -} - -#endif diff --git a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/NSEvent Extensions.swift b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/NSEvent Extensions.swift deleted file mode 100644 index 97297a4..0000000 --- a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/NSEvent Extensions.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// NSEvent Extensions.swift -// MenuBarExtraAccess • https://github.com/orchetect/MenuBarExtraAccess -// © 2023 Steffan Andrews • Licensed under MIT License -// - -#if os(macOS) - -import AppKit - -extension NSEvent.EventType { - @_disfavoredOverload - public var name: String { - switch self { - case .leftMouseDown: return "leftMouseDown" - case .leftMouseUp: return "leftMouseUp" - case .rightMouseDown: return "rightMouseDown" - case .rightMouseUp: return "rightMouseUp" - case .mouseMoved: return "mouseMoved" - case .leftMouseDragged: return "leftMouseDragged" - case .rightMouseDragged: return "rightMouseDragged" - case .mouseEntered: return "mouseEntered" - case .mouseExited: return "mouseExited" - case .keyDown: return "keyDown" - case .keyUp: return "keyUp" - case .flagsChanged: return "flagsChanged" - case .appKitDefined: return "appKitDefined" - case .systemDefined: return "systemDefined" - case .applicationDefined: return "applicationDefined" - case .periodic: return "periodic" - case .cursorUpdate: return "cursorUpdate" - case .scrollWheel: return "scrollWheel" - case .tabletPoint: return "tabletPoint" - case .tabletProximity: return "tabletProximity" - case .otherMouseDown: return "otherMouseDown" - case .otherMouseUp: return "otherMouseUp" - case .otherMouseDragged: return "otherMouseDragged" - case .gesture: return "gesture" - case .magnify: return "magnify" - case .swipe: return "swipe" - case .rotate: return "rotate" - case .beginGesture: return "beginGesture" - case .endGesture: return "endGesture" - case .smartMagnify: return "smartMagnify" - case .quickLook: return "quickLook" - case .pressure: return "pressure" - case .directTouch: return "directTouch" - case .changeMode: return "changeMode" - - #if compiler(>=6.2) // handle cases only known to the SDKs that ship with Xcode 26 - case .mouseCancelled: return "mouseCancelled" - #endif - - @unknown default: - assertionFailure("Unhandled `NSEvent.EventType` case with raw value: \(rawValue)") - return "\(rawValue)" - } - } -} - -#endif diff --git a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/NSStatusItem Extensions.swift b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/NSStatusItem Extensions.swift deleted file mode 100644 index c1f7781..0000000 --- a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/NSStatusItem Extensions.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// NSStatusItem Extensions.swift -// MenuBarExtraAccess • https://github.com/orchetect/MenuBarExtraAccess -// © 2023 Steffan Andrews • Licensed under MIT License -// - -#if os(macOS) - -import AppKit -import SwiftUI - -@MainActor -extension NSStatusItem { - /// Toggles the menu/window state by mimicking a menu item button press. - @_disfavoredOverload - public func togglePresented() { - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("NSStatusItem.\(#function) called") - #endif - - // this also works but only for window-based MenuBarExtra - // (button.target and button.action are nil when menu-based): - // - mimic user pressing the menu item button - // which convinces MenuBarExtra to close the window and properly reset its state - // let actionSelector = button?.action // "toggleWindow:" selector - // button?.sendAction(actionSelector, to: button?.target) - - button?.performClick(button) - updateHighlight() - } - - /// Toggles the menu/window state by mimicking a menu item button press. - @_disfavoredOverload - internal func setPresented(state: Bool) { - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("NSStatusItem.\(#function) called with state: \(state)") - #endif - - // read current state and selectively call toggle if state differs - let currentState = button?.state != .off - guard state != currentState else { - updateHighlight() - return - } - - // don't allow presenting the menu if the status item is disabled - if state { guard button?.isEnabled == true else { return } } - - togglePresented() - } - - @_disfavoredOverload - internal func updateHighlight() { - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("NSStatusItem.\(#function) called") - #endif - - let s = button?.state != .off - - #if MENUBAREXTRAACCESS_DEBUG_LOGGING - print("NSStatusItem.\(#function): State detected as \(s)") - #endif - - button?.isHighlighted = s - } - - /// Only call this when the state of the drop-down window is known. - internal func setKnownPresented(state: Bool) { - switch state { - case true: - button?.state = .on - case false: - button?.state = .off - } - } - - internal func setEnabled(state: Bool) { - button?.isEnabled = state - } -} - -// MARK: - KVO Observer - -@MainActor -extension NSStatusItem { - @MainActor - internal class ButtonStateObserver: NSObject { - private weak var objectToObserve: NSStatusBarButton? - private var observation: NSKeyValueObservation? - - init( - object: NSStatusBarButton, - _ handler: @MainActor @escaping @Sendable (_ change: NSKeyValueObservedChange) -> Void - ) { - objectToObserve = object - super.init() - - observation = object.observe( - \.cell!.state, - options: [.initial, .new] - ) { ob, change in - Task { @MainActor in handler(change) } - } - } - - deinit { - observation?.invalidate() - } - } - - internal func stateObserverMenuBased( - _ handler: @MainActor @escaping @Sendable (_ change: NSKeyValueObservedChange) -> Void - ) -> ButtonStateObserver? { - guard let button else { return nil } - let newStatusItemButtonStateObserver = ButtonStateObserver(object: button, handler) - return newStatusItemButtonStateObserver - } -} - -// MARK: - KVO Publisher - -@MainActor -extension NSStatusItem { - typealias ButtonStatePublisher = KeyValueObservingPublisher - - internal func buttonStatePublisher() -> ButtonStatePublisher? { - button?.publisher(for: \.cell!.state, options: [.initial, .new]) - } -} - -#endif diff --git a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/NSWindow Extensions.swift b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/NSWindow Extensions.swift deleted file mode 100644 index 41198fa..0000000 --- a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/NSWindow Extensions.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// NSWindow Extensions.swift -// MenuBarExtraAccess • https://github.com/orchetect/MenuBarExtraAccess -// © 2023 Steffan Andrews • Licensed under MIT License -// - -#if os(macOS) - -import AppKit - -extension NSWindow /* actually NSStatusBarWindow but it's a private AppKit type */ { - /// When called on an `NSStatusBarWindow` instance, returns the associated `NSStatusItem`. - /// Always returns `nil` for any other `NSWindow` subclass. - @_disfavoredOverload - func fetchStatusItem() -> NSStatusItem? { - // statusItem is a private key not exposed to Swift but we can get it using Key-Value coding - value(forKey: "statusItem") as? NSStatusItem - ?? Mirror(reflecting: self).descendant("statusItem") as? NSStatusItem - } -} - -#endif diff --git a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/Unused/Unused Code.swift b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/Unused/Unused Code.swift deleted file mode 100644 index 3e4056a..0000000 --- a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/Unused/Unused Code.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// Unused Code.swift -// MenuBarExtraAccess • https://github.com/orchetect/MenuBarExtraAccess -// © 2023 Steffan Andrews • Licensed under MIT License -// - -#if os(macOS) - -// import SwiftUI -// import Combine -// -// @available(macOS 11.0, *) -// extension Scene { -// fileprivate func menuBarExtraID() -> String? { -// // Note: this is not ideal, but it's currently the ONLY way to achieve this -// // until Apple adds a 1st-party solution to MenuBarExtra state -// -// // this may require a less brittle solution if the child path may change, such as grabbing -// // String(dump: behavior) and using RegEx to find the value -// -// let m = Mirror(reflecting: self) -// -// // TODO: detect if style is .menu or .window -// -// // when using MenuBarExtra(title string, content) this is the mirror path: -// if let id = m.descendant( -// "label", -// "title", -// "storage", -// "anyTextStorage", -// "key", -// "key" -// ) as? String { -// return id -// } -// -// // this won't work. it differs when checked from NSStatusItem.menuBarExtraID -// // -// // otherwise, when using a MenuBarExtra initializer that produces Label view content: -// // we'll basically grab the hashed contents of the label -// if let anyView = m.descendant( -// "label" -// ) as? any View { -// let hashed = MenuBarExtraUtils.hash(anyView: anyView) -// return hashed -// } -// -// print("Could not determine MenuBarExtra ID") -// -// return nil -// } -// } - -#endif diff --git a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/Utilities.swift b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/Utilities.swift deleted file mode 100644 index eb812ae..0000000 --- a/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/Utilities.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Utilities.swift -// MenuBarExtraAccess • https://github.com/orchetect/MenuBarExtraAccess -// © 2023 Steffan Andrews • Licensed under MIT License -// - -import Foundation - -extension String { - /// Captures the output of `dump()` for the passed object instance. - init(dump object: Any) { - var dumpOutput = String() - dump(object, to: &dumpOutput) - self = dumpOutput - } -}