diff --git a/Ghostly.xcodeproj/project.pbxproj b/Ghostly.xcodeproj/project.pbxproj index 6395539..84419bd 100644 --- a/Ghostly.xcodeproj/project.pbxproj +++ b/Ghostly.xcodeproj/project.pbxproj @@ -270,7 +270,7 @@ mainGroup = C836C54C25A0171500BEB83F; packageReferences = ( KBSH000525E0000000000005 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */, - MBEA000225E0000000000002 /* XCRemoteSwiftPackageReference "MenuBarExtraAccess" */, + MBEA000225E0000000000002 /* XCLocalSwiftPackageReference "LocalPackages/MenuBarExtraAccess" */, ); productRefGroup = C836C55625A0171500BEB83F /* Products */; projectDirPath = ""; @@ -609,16 +609,15 @@ minimumVersion = 2.0.0; }; }; - MBEA000225E0000000000002 /* XCRemoteSwiftPackageReference "MenuBarExtraAccess" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/orchetect/MenuBarExtraAccess"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; - }; - }; /* End XCRemoteSwiftPackageReference section */ +/* Begin XCLocalSwiftPackageReference section */ + MBEA000225E0000000000002 /* XCLocalSwiftPackageReference "LocalPackages/MenuBarExtraAccess" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = LocalPackages/MenuBarExtraAccess; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ KBSH000025E0000000000000 /* KeyboardShortcuts */ = { isa = XCSwiftPackageProductDependency; @@ -632,7 +631,7 @@ }; MBEA000025E0000000000000 /* MenuBarExtraAccess */ = { isa = XCSwiftPackageProductDependency; - package = MBEA000225E0000000000002 /* XCRemoteSwiftPackageReference "MenuBarExtraAccess" */; + package = MBEA000225E0000000000002 /* XCLocalSwiftPackageReference "LocalPackages/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 c8e4c4e..5d57d23 100644 --- a/Ghostly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Ghostly.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,15 +9,6 @@ "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 6d93e89..3ccae24 100644 --- a/Ghostly/AppState.swift +++ b/Ghostly/AppState.swift @@ -16,6 +16,9 @@ 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 12a8f9e..33564a1 100644 --- a/Ghostly/GhostlyApp.swift +++ b/Ghostly/GhostlyApp.swift @@ -24,7 +24,7 @@ struct GhostlyApp: App { } } .menuBarExtraStyle(.window) - .menuBarExtraAccess(isPresented: $appState.isMenuPresented) { statusItem in + .menuBarExtraAccess(isPresented: $appState.isMenuPresented, staysOpen: $appState.isPinned) { statusItem in statusItemContextMenuController.configure(statusItem: statusItem, appState: appState) } } diff --git a/Ghostly/Views/ContentView.swift b/Ghostly/Views/ContentView.swift index 191c128..bc81156 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(settingsManager: settingsManager) + HeaderView(appState: appState) if tabManager.tabs.count > 1 { TabBarView(tabManager: tabManager) diff --git a/Ghostly/Views/HeaderView.swift b/Ghostly/Views/HeaderView.swift index 5ca1e32..02687ec 100644 --- a/Ghostly/Views/HeaderView.swift +++ b/Ghostly/Views/HeaderView.swift @@ -8,9 +8,11 @@ import SwiftUI struct HeaderView: View { - var settingsManager: SettingsManager + @Bindable var appState: AppState @State private var isHovered = false + private var settingsManager: SettingsManager { appState.settingsManager } + var body: some View { VStack(spacing: 0) { HStack { @@ -35,6 +37,20 @@ 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 new file mode 100644 index 0000000..aa3de8f --- /dev/null +++ b/LocalPackages/MenuBarExtraAccess/Package.swift @@ -0,0 +1,20 @@ +// 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 new file mode 100644 index 0000000..57c4a45 --- /dev/null +++ b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/MenuBarExtra Window Introspection.swift @@ -0,0 +1,35 @@ +// +// 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 new file mode 100644 index 0000000..f5ba7b9 --- /dev/null +++ b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/MenuBarExtraAccess.swift @@ -0,0 +1,288 @@ +// +// 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 new file mode 100644 index 0000000..018fa40 --- /dev/null +++ b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/MenuBarExtraUtils/MenuBarExtraUtils.swift @@ -0,0 +1,422 @@ +// +// 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 new file mode 100644 index 0000000..cf93b28 --- /dev/null +++ b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/MenuBarExtraUtils/StatusItemIdentity.swift @@ -0,0 +1,25 @@ +// +// 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 new file mode 100644 index 0000000..fe8d903 --- /dev/null +++ b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/NSControl Extensions.swift @@ -0,0 +1,27 @@ +// +// 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 new file mode 100644 index 0000000..97297a4 --- /dev/null +++ b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/NSEvent Extensions.swift @@ -0,0 +1,61 @@ +// +// 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 new file mode 100644 index 0000000..c1f7781 --- /dev/null +++ b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/NSStatusItem Extensions.swift @@ -0,0 +1,131 @@ +// +// 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 new file mode 100644 index 0000000..41198fa --- /dev/null +++ b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/NSWindow Extensions.swift @@ -0,0 +1,22 @@ +// +// 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 new file mode 100644 index 0000000..3e4056a --- /dev/null +++ b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/Unused/Unused Code.swift @@ -0,0 +1,54 @@ +// +// 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 new file mode 100644 index 0000000..eb812ae --- /dev/null +++ b/LocalPackages/MenuBarExtraAccess/Sources/MenuBarExtraAccess/Utilities.swift @@ -0,0 +1,16 @@ +// +// 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 + } +}