From de6e3af0cc82506d4fd9dcae361185e835a74ee1 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Wed, 11 Jun 2025 01:59:30 -0600 Subject: [PATCH 01/80] macOS 26: Fix menu bar item retrieval --- Ice/Bridging/Bridging.swift | 200 +++++++++++------- .../{Shims/Private.swift => Shims.swift} | 26 ++- Ice/Bridging/Shims/Deprecated.swift | 13 -- Ice/Events/EventManager.swift | 4 +- Ice/Main/AppState.swift | 4 +- Ice/MenuBar/ControlItem/ControlItem.swift | 14 +- Ice/MenuBar/MenuBarItems/MenuBarItem.swift | 196 +++++++++-------- .../MenuBarItems/MenuBarItemInfo.swift | 178 +++++++++------- .../MenuBarItems/MenuBarItemManager.swift | 2 +- Ice/MenuBar/MenuBarSection.swift | 9 + Ice/UI/IceBar/IceBar.swift | 5 +- Ice/UI/LayoutBar/LayoutBar.swift | 3 +- Ice/Utilities/Extensions.swift | 5 + Ice/Utilities/WindowInfo.swift | 11 - 14 files changed, 394 insertions(+), 276 deletions(-) rename Ice/Bridging/{Shims/Private.swift => Shims.swift} (85%) delete mode 100644 Ice/Bridging/Shims/Deprecated.swift diff --git a/Ice/Bridging/Bridging.swift b/Ice/Bridging/Bridging.swift index d35d7a796..6c1c27748 100644 --- a/Ice/Bridging/Bridging.swift +++ b/Ice/Bridging/Bridging.swift @@ -6,42 +6,48 @@ import Cocoa /// A namespace for bridged functionality. -enum Bridging { } +enum Bridging { + private static let mainConnectionID = CGSMainConnectionID() + private static let logger = Logger(category: "Bridging") +} // MARK: - CGSConnection extension Bridging { - /// Sets a value for the given key in the current connection to the window server. + /// Sets a value for the given key in the app's connection to + /// the window server. /// /// - Parameters: /// - value: The value to set for `key`. - /// - key: A key associated with the current connection to the window server. + /// - key: A key associated with the app's connection to the + /// window server. static func setConnectionProperty(_ value: Any?, forKey key: String) { let result = CGSSetConnectionProperty( - CGSMainConnectionID(), - CGSMainConnectionID(), + mainConnectionID, + mainConnectionID, key as CFString, value as CFTypeRef ) if result != .success { - Logger.bridging.error("CGSSetConnectionProperty failed with error \(result.logString)") + logger.error("CGSSetConnectionProperty failed with error \(result.logString)") } } - /// Returns the value for the given key in the current connection to the window server. + /// Returns the value for the given key in the app's connection + /// to the window server. /// - /// - Parameter key: A key associated with the current connection to the window server. - /// - Returns: The value associated with `key` in the current connection to the window server. + /// - Parameter key: A key associated with the app's connection + /// to the window server. static func getConnectionProperty(forKey key: String) -> Any? { var value: Unmanaged? let result = CGSCopyConnectionProperty( - CGSMainConnectionID(), - CGSMainConnectionID(), + mainConnectionID, + mainConnectionID, key as CFString, &value ) if result != .success { - Logger.bridging.error("CGSCopyConnectionProperty failed with error \(result.logString)") + logger.error("CGSCopyConnectionProperty failed with error \(result.logString)") } return value?.takeRetainedValue() } @@ -50,38 +56,50 @@ extension Bridging { // MARK: - CGSWindow extension Bridging { - /// Returns the frame for the window with the specified identifier. + /// Returns the frame, specified in screen coordinates, for the + /// window with the specified identifier. /// /// - Parameter windowID: An identifier for a window. - /// - Returns: The frame -- specified in screen coordinates -- of the window associated - /// with `windowID`, or `nil` if the operation failed. static func getWindowFrame(for windowID: CGWindowID) -> CGRect? { var rect = CGRect.zero - let result = CGSGetScreenRectForWindow(CGSMainConnectionID(), windowID, &rect) + let result = CGSGetScreenRectForWindow(mainConnectionID, windowID, &rect) guard result == .success else { - Logger.bridging.error("CGSGetScreenRectForWindow failed with error \(result.logString)") + logger.error("CGSGetScreenRectForWindow failed with error \(result.logString)") return nil } return rect } + + /// Returns the level for the window with the specified identifier. + /// + /// - Parameter windowID: An identifier for a window. + static func getWindowLevel(for windowID: CGWindowID) -> CGWindowLevel? { + var level: CGWindowLevel = 0 + let result = CGSGetWindowLevel(mainConnectionID, windowID, &level) + guard result == .success else { + logger.error("CGSGetWindowLevel failed with error \(result.logString)") + return nil + } + return level + } } // MARK: Private Window List Helpers extension Bridging { private static func getWindowCount() -> Int { var count: Int32 = 0 - let result = CGSGetWindowCount(CGSMainConnectionID(), 0, &count) + let result = CGSGetWindowCount(mainConnectionID, 0, &count) if result != .success { - Logger.bridging.error("CGSGetWindowCount failed with error \(result.logString)") + logger.error("CGSGetWindowCount failed with error \(result.logString)") } return Int(count) } private static func getOnScreenWindowCount() -> Int { var count: Int32 = 0 - let result = CGSGetOnScreenWindowCount(CGSMainConnectionID(), 0, &count) + let result = CGSGetOnScreenWindowCount(mainConnectionID, 0, &count) if result != .success { - Logger.bridging.error("CGSGetOnScreenWindowCount failed with error \(result.logString)") + logger.error("CGSGetOnScreenWindowCount failed with error \(result.logString)") } return Int(count) } @@ -91,14 +109,14 @@ extension Bridging { var list = [CGWindowID](repeating: 0, count: windowCount) var realCount: Int32 = 0 let result = CGSGetWindowList( - CGSMainConnectionID(), + mainConnectionID, 0, Int32(windowCount), &list, &realCount ) guard result == .success else { - Logger.bridging.error("CGSGetWindowList failed with error \(result.logString)") + logger.error("CGSGetWindowList failed with error \(result.logString)") return [] } return [CGWindowID](list[.. [CGWindowID] { + private static func getMenuBarItemWindowList() -> [CGWindowID] { let windowCount = getWindowCount() var list = [CGWindowID](repeating: 0, count: windowCount) var realCount: Int32 = 0 let result = CGSGetProcessMenuBarWindowList( - CGSMainConnectionID(), + mainConnectionID, 0, Int32(windowCount), &list, &realCount ) guard result == .success else { - Logger.bridging.error("CGSGetProcessMenuBarWindowList failed with error \(result.logString)") + logger.error("CGSGetProcessMenuBarWindowList failed with error \(result.logString)") return [] } - return [CGWindowID](list[.. [CGWindowID] { + private static func getOnScreenMenuBarItemWindowList() -> [CGWindowID] { let onScreenList = Set(getOnScreenWindowList()) - return getMenuBarWindowList().filter(onScreenList.contains) + return getMenuBarItemWindowList().filter(onScreenList.contains) } } // MARK: Public Window List API extension Bridging { - /// Options that determine the window identifiers to return in a window list. + /// Options that specify the identifiers in a window list. struct WindowListOption: OptionSet { let rawValue: Int @@ -162,25 +183,16 @@ extension Bridging { static let activeSpace = WindowListOption(rawValue: 1 << 2) } - /// The total number of windows. - static var windowCount: Int { - getWindowCount() - } - - /// The number of windows currently on-screen. - static var onScreenWindowCount: Int { - getOnScreenWindowCount() - } - /// Returns a list of window identifiers using the given options. /// /// - Parameter option: Options that filter the returned list. + /// Pass an empty option set to return all available windows. static func getWindowList(option: WindowListOption = []) -> [CGWindowID] { let list = if option.contains(.menuBarItems) { if option.contains(.onScreen) { - getOnScreenMenuBarWindowList() + getOnScreenMenuBarItemWindowList() } else { - getMenuBarWindowList() + getMenuBarItemWindowList() } } else if option.contains(.onScreen) { getOnScreenWindowList() @@ -198,80 +210,122 @@ extension Bridging { // MARK: - CGSSpace extension Bridging { - /// Options that determine the space identifiers to return in a space list. + /// Options that specify the identifiers in a space list. enum SpaceListOption { - case allSpaces, visibleSpaces + /// Specifies all available spaces. + case allSpaces + + /// Specifies visible spaces. + case visibleSpaces } - /// The identifier of the active space. - static var activeSpaceID: CGSSpaceID { - CGSGetActiveSpace(CGSMainConnectionID()) + /// Returns the identifier for the current active space. + static func getActiveSpaceID() -> CGSSpaceID { + return CGSGetActiveSpace(mainConnectionID) } - /// Returns an array of identifiers for the spaces containing the window with - /// the given identifier. + /// Returns the identifier for the current space on the given + /// display. /// - /// - Parameter windowID: An identifier for a window. + /// - Parameter displayID: An identifier for a display. + static func getCurrentSpaceID(for displayID: CGDirectDisplayID) -> CGSSpaceID? { + guard + let uuid = CGDisplayCreateUUIDFromDisplayID(displayID), + let uuidString = CFUUIDCreateString(nil, uuid.takeRetainedValue()) + else { + return nil + } + return CGSManagedDisplayGetCurrentSpace(mainConnectionID, uuidString) + } + + /// Returns a list of identifiers for the spaces that contain + /// the given window. + /// + /// - Parameters: + /// - windowID: An identifier for a window. + /// - option: An option that filters the spaces included in + /// the returned list. static func getSpaceList(for windowID: CGWindowID, option: SpaceListOption) -> [CGSSpaceID] { let mask: CGSSpaceMask = switch option { case .allSpaces: .allSpaces case .visibleSpaces: .allVisibleSpaces } - guard let spaces = CGSCopySpacesForWindows(CGSMainConnectionID(), mask, [windowID] as CFArray) else { - Logger.bridging.error("CGSCopySpacesForWindows failed") + guard let spaces = CGSCopySpacesForWindows(mainConnectionID, mask, [windowID] as CFArray) else { + logger.error("CGSCopySpacesForWindows returned nil value") return [] } - guard let spaceIDs = spaces.takeRetainedValue() as? [CGSSpaceID] else { - Logger.bridging.error("CGSCopySpacesForWindows returned array of unexpected type") + guard let list = spaces.takeRetainedValue() as? [CGSSpaceID] else { + logger.error("CGSCopySpacesForWindows returned array of unexpected type") return [] } - return spaceIDs + return list + } + + /// Returns a Boolean value that indicates whether the window + /// with the given identifier is on the specified space. + /// + /// - Parameters: + /// - windowID: An identifier for a window. + /// - spaceID: An identifier for a space. + static func isWindowOnSpace(_ windowID: CGWindowID, _ spaceID: CGSSpaceID) -> Bool { + let list = getSpaceList(for: windowID, option: .allSpaces) + return list.contains(spaceID) } - /// Returns a Boolean value that indicates whether the window with the - /// given identifier is on the active space. + /// Returns a Boolean value that indicates whether the window + /// with the given identifier is on the current active space. /// /// - Parameter windowID: An identifier for a window. static func isWindowOnActiveSpace(_ windowID: CGWindowID) -> Bool { - getSpaceList(for: windowID, option: .allSpaces).contains(activeSpaceID) + let spaceID = getActiveSpaceID() + return isWindowOnSpace(windowID, spaceID) } - /// Returns a Boolean value that indicates whether the space with the given - /// identifier is a fullscreen space. + /// Returns a Boolean value that indicates whether the space + /// with the given identifier is fullscreen. /// /// - Parameter spaceID: An identifier for a space. static func isSpaceFullscreen(_ spaceID: CGSSpaceID) -> Bool { - let type = CGSSpaceGetType(CGSMainConnectionID(), spaceID) + let type = CGSSpaceGetType(mainConnectionID, spaceID) return type == .fullscreen } + + /// Returns a Boolean value that indicates whether the current + /// active space is fullscreen. + static func isActiveSpaceFullscreen() -> Bool { + let spaceID = getActiveSpaceID() + return isSpaceFullscreen(spaceID) + } } // MARK: - Process Responsivity extension Bridging { - /// Constants that indicate the responsivity of an app. + /// Constants that indicate the responsivity of a process. enum Responsivity { - case responsive, unresponsive, unknown + /// The process is known to be responsive. + case responsive + + /// The process is known to be unresponsive. + case unresponsive + + /// The responsivity of the process is unknown. + case unknown } /// Returns the responsivity of the given process. /// - /// - Parameter pid: The Unix process identifier of the process to check. + /// - Parameter pid: An identifier for a process. static func responsivity(for pid: pid_t) -> Responsivity { var psn = ProcessSerialNumber() let result = GetProcessForPID(pid, &psn) guard result == noErr else { - Logger.bridging.error("GetProcessForPID failed with error \(result)") + logger.error("GetProcessForPID failed with error \(result)") return .unknown } - if CGSEventIsAppUnresponsive(CGSMainConnectionID(), &psn) { + if CGSEventIsAppUnresponsive(mainConnectionID, &psn) { return .unresponsive } return .responsive } } - -// MARK: - Logger -private extension Logger { - static let bridging = Logger(category: "Bridging") -} diff --git a/Ice/Bridging/Shims/Private.swift b/Ice/Bridging/Shims.swift similarity index 85% rename from Ice/Bridging/Shims/Private.swift rename to Ice/Bridging/Shims.swift index e03528adb..0b8694991 100644 --- a/Ice/Bridging/Shims/Private.swift +++ b/Ice/Bridging/Shims.swift @@ -1,14 +1,15 @@ // -// Private.swift +// Shims.swift // Ice // +import ApplicationServices import CoreGraphics // MARK: - Bridged Types typealias CGSConnectionID = Int32 -typealias CGSSpaceID = size_t +typealias CGSSpaceID = Int enum CGSSpaceType: UInt32 { case user = 0 @@ -72,6 +73,12 @@ func CGSCopySpacesForWindows( _ windowIDs: CFArray ) -> Unmanaged? +@_silgen_name("CGSManagedDisplayGetCurrentSpace") +func CGSManagedDisplayGetCurrentSpace( + _ cid: CGSConnectionID, + _ displayUUID: CFString +) -> CGSSpaceID + @_silgen_name("CGSSpaceGetType") func CGSSpaceGetType( _ cid: CGSConnectionID, @@ -127,3 +134,18 @@ func CGSGetScreenRectForWindow( _ wid: CGWindowID, _ outRect: inout CGRect ) -> CGError + +@_silgen_name("CGSGetWindowLevel") +func CGSGetWindowLevel( + _ cid: CGSConnectionID, + _ wid: CGWindowID, + _ outLevel: inout CGWindowLevel +) -> CGError + +// MARK: - PSN/PID Functions + +@_silgen_name("GetProcessForPID") +func GetProcessForPID( + _ pid: pid_t, + _ psn: inout ProcessSerialNumber +) -> OSStatus diff --git a/Ice/Bridging/Shims/Deprecated.swift b/Ice/Bridging/Shims/Deprecated.swift deleted file mode 100644 index aaac42ea8..000000000 --- a/Ice/Bridging/Shims/Deprecated.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// Deprecated.swift -// Ice -// - -import ApplicationServices - -/// Returns a PSN for a given PID. -@_silgen_name("GetProcessForPID") -func GetProcessForPID( - _ pid: pid_t, - _ psn: inout ProcessSerialNumber -) -> OSStatus diff --git a/Ice/Events/EventManager.swift b/Ice/Events/EventManager.swift index b1c154c8c..ee8f00006 100644 --- a/Ice/Events/EventManager.swift +++ b/Ice/Events/EventManager.swift @@ -208,13 +208,13 @@ extension EventManager { } Task { - let initialSpaceID = Bridging.activeSpaceID + let initialSpaceID = Bridging.getActiveSpaceID() // Sleep for a bit to give the window under the mouse a chance to focus. try? await Task.sleep(for: .seconds(0.25)) // If clicking caused a space change, don't bother with the window check. - if Bridging.activeSpaceID != initialSpaceID { + if Bridging.getActiveSpaceID() != initialSpaceID { for section in appState.menuBarManager.sections { section.hide() } diff --git a/Ice/Main/AppState.swift b/Ice/Main/AppState.swift index d06d8efa3..12a2f8d42 100644 --- a/Ice/Main/AppState.swift +++ b/Ice/Main/AppState.swift @@ -10,7 +10,7 @@ import SwiftUI @MainActor final class AppState: ObservableObject { /// A Boolean value that indicates whether the active space is fullscreen. - @Published private(set) var isActiveSpaceFullscreen = Bridging.isSpaceFullscreen(Bridging.activeSpaceID) + @Published private(set) var isActiveSpaceFullscreen = Bridging.isActiveSpaceFullscreen() /// Manager for the menu bar's appearance. private(set) lazy var appearanceManager = MenuBarAppearanceManager(appState: self) @@ -105,7 +105,7 @@ final class AppState: ObservableObject { guard let self else { return } - isActiveSpaceFullscreen = Bridging.isSpaceFullscreen(Bridging.activeSpaceID) + isActiveSpaceFullscreen = Bridging.isActiveSpaceFullscreen() } .store(in: &c) diff --git a/Ice/MenuBar/ControlItem/ControlItem.swift b/Ice/MenuBar/ControlItem/ControlItem.swift index d35f356c9..3f1be0f6a 100644 --- a/Ice/MenuBar/ControlItem/ControlItem.swift +++ b/Ice/MenuBar/ControlItem/ControlItem.swift @@ -61,13 +61,13 @@ final class ControlItem { statusItem.button?.window } - /// The identifier of the control item's window. - var windowID: CGWindowID? { - guard let window else { - return nil - } - return CGWindowID(window.windowNumber) - } +// /// The identifier of the control item's window. +// var windowID: CGWindowID? { +// guard let window else { +// return nil +// } +// return CGWindowID(window.windowNumber) +// } /// A Boolean value that indicates whether the control item serves as /// a divider between sections. diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift index 4d68bd44b..83086c14c 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift @@ -37,14 +37,12 @@ struct MenuBarItem { /// A Boolean value that indicates whether the item can be moved. var isMovable: Bool { - let immovableItems = Set(MenuBarItemInfo.immovableItems) - return !immovableItems.contains(info) + info.isMovable } /// A Boolean value that indicates whether the item can be hidden. var canBeHidden: Bool { - let nonHideableItems = Set(MenuBarItemInfo.nonHideableItems) - return !nonHideableItems.contains(info) + info.canBeHidden } /// The process identifier of the application that owns the item. @@ -65,44 +63,63 @@ struct MenuBarItem { window.owningApplication } - /// A name associated with the item that is suited for display to - /// the user. + /// A name associated with the item that is suited for display. var displayName: String { - var fallback: String { "Unknown" } - guard let owningApplication else { - return ownerName ?? title ?? fallback + /// Converts "UpperCamelCase" to "Title Case". + func toTitleCase(_ s: S) -> String { + String(s).replacing(/([a-z])([A-Z])/) { $0.output.1 + " " + $0.output.2 } } + var bestName: String { - owningApplication.localizedName ?? - ownerName ?? - owningApplication.bundleIdentifier ?? - fallback + var fallback: String { "Unknown" } + return if #available(macOS 26.0, *) { + title ?? ownerName ?? fallback + } else if let owningApplication { + owningApplication.localizedName ?? + ownerName ?? + owningApplication.bundleIdentifier ?? + title ?? + fallback + } else { + ownerName ?? title ?? fallback + } } - guard let title else { + + guard #unavailable(macOS 26.0), let title else { return bestName } - // by default, use the application name, but handle a few special cases - return switch MenuBarItemInfo.Namespace(owningApplication.bundleIdentifier) { - case .controlCenter: - switch title { - case "AccessibilityShortcuts": "Accessibility Shortcuts" - case "BentoBox": bestName // Control Center - case "FocusModes": "Focus" - case "KeyboardBrightness": "Keyboard Brightness" - case "MusicRecognition": "Music Recognition" - case "NowPlaying": "Now Playing" - case "ScreenMirroring": "Screen Mirroring" - case "StageManager": "Stage Manager" - case "UserSwitcher": "Fast User Switching" - case "WiFi": "Wi-Fi" - default: title - } - case .systemUIServer: - switch title { - case "TimeMachine.TMMenuExtraHost"/*Sonoma*/, "TimeMachineMenuExtra.TMMenuExtraHost"/*Sequoia*/: "Time Machine" - default: title - } - case MenuBarItemInfo.Namespace("com.apple.Passwords.MenuBarExtra"): "Passwords" + + // Most items will use their computed "best name", but we need + // to handle a few special cases. + return switch info.namespace { + case .passwords, .weather: + // These need more searchable names. + // + // "PasswordsMenuBarExtra" -> "Passwords" + // "WeatherMenu" -> "Weather" + // + // Convert to "Title Case" and take the first word. + String(toTitleCase(bestName).prefix { !$0.isWhitespace }) + case .controlCenter where title == "BentoBox": + bestName // "BentoBox" -> "Control Center" + case .controlCenter where title == "WiFi": + title // Keep "UpperCamelCase". + case .controlCenter where title.hasPrefix("Hearing"): + // Title of this item was changed to "Hearing_GlowE" in macOS 15.4. + String(toTitleCase(title).prefix { $0.isLetter || $0.isNumber }) + case .systemUIServer where title.contains("TimeMachine"): + // Title of this item depends on the macOS version. + // + // Sonoma: "TimeMachine.TMMenuExtraHost" + // Sequoia: "TimeMachineMenuExtra.TMMenuExtraHost" + // + // Keep things consistent and replace it. + "Time Machine" + case .controlCenter, .systemUIServer: + // Most system items are owned by the same couple of apps, so use the + // title instead of the app name. Some are "UpperCamelCase", some are + // dot-separated. Prefix to the first dot and convert to "Title Case". + toTitleCase(title.prefix { $0 != "." }) default: bestName } @@ -120,6 +137,15 @@ struct MenuBarItem { String(describing: info) } + /// The latest version of the menu bar item, or `nil` if the item + /// no longer exists. + var latest: MenuBarItem? { + guard let window = WindowInfo(windowID: windowID) else { + return nil + } + return MenuBarItem(uncheckedItemWindow: window) + } + /// Creates a menu bar item from the given window. /// /// This initializer does not perform any checks on the window to ensure that @@ -130,33 +156,9 @@ struct MenuBarItem { self.info = MenuBarItemInfo(uncheckedItemWindow: itemWindow) } - /// Creates a menu bar item. - /// - /// The parameters passed into this initializer are verified during the menu - /// bar item's creation. If `itemWindow` does not represent a menu bar item, - /// the initializer will fail. - /// - /// - Parameter itemWindow: A window that contains information about the item. - init?(itemWindow: WindowInfo) { - guard itemWindow.isMenuBarItem else { - return nil - } - self.init(uncheckedItemWindow: itemWindow) - } - - /// Creates a menu bar item with the given window identifier. - /// - /// The parameters passed into this initializer are verified during the menu - /// bar item's creation. If `windowID` does not represent a menu bar item, - /// the initializer will fail. - /// - /// - Parameter windowID: An identifier for a window that contains information - /// about the item. - init?(windowID: CGWindowID) { - guard let window = WindowInfo(windowID: windowID) else { - return nil - } - self.init(itemWindow: window) + /// Returns the current frame for the item. + func getCurrentFrame() -> CGRect? { + return Bridging.getWindowFrame(for: windowID) } } @@ -174,32 +176,41 @@ extension MenuBarItem { static func getMenuBarItems(on display: CGDirectDisplayID? = nil, onScreenOnly: Bool, activeSpaceOnly: Bool) -> [MenuBarItem] { var option: Bridging.WindowListOption = [.menuBarItems] - var titlePredicate: (MenuBarItem) -> Bool = { _ in true } var boundsPredicate: (CGWindowID) -> Bool = { _ in true } + var spacePredicate: (CGWindowID) -> Bool = { _ in true } if onScreenOnly { option.insert(.onScreen) + if let display { + let displayBounds = CGDisplayBounds(display) + boundsPredicate = { windowID in + if let frame = Bridging.getWindowFrame(for: windowID) { + return displayBounds.intersects(frame) + } + return false + } + } } if activeSpaceOnly { option.insert(.activeSpace) - titlePredicate = { $0.title != "" } - } - if let display { - let displayBounds = CGDisplayBounds(display) - boundsPredicate = { windowID in - guard let windowFrame = Bridging.getWindowFrame(for: windowID) else { - return false + if let spaceID = display.flatMap(Bridging.getCurrentSpaceID) { + spacePredicate = { windowID in + Bridging.isWindowOnSpace(windowID, spaceID) } - return displayBounds.intersects(windowFrame) } } return Bridging.getWindowList(option: option).lazy - .filter(boundsPredicate) .compactMap { windowID in - MenuBarItem(windowID: windowID) + guard + boundsPredicate(windowID), + spacePredicate(windowID), + let window = WindowInfo(windowID: windowID) + else { + return nil + } + return MenuBarItem(uncheckedItemWindow: window) } - .filter(titlePredicate) .sortedByOrderInMenuBar() } } @@ -218,7 +229,8 @@ extension MenuBarItem: Hashable { } } -// MARK: MenuBarItemInfo Unchecked Item Window Initializer +// MARK: - MenuBarItemInfo Unchecked Item Window Initializer + private extension MenuBarItemInfo { /// Creates a simplified item from the given window. /// @@ -226,15 +238,31 @@ private extension MenuBarItemInfo { /// it is a valid menu bar item window. Only call this initializer if you are /// certain that the window is valid. init(uncheckedItemWindow itemWindow: WindowInfo) { - if let bundleIdentifier = itemWindow.owningApplication?.bundleIdentifier { - self.namespace = Namespace(bundleIdentifier) - } else { - self.namespace = .null - } - if let title = itemWindow.title { - self.title = title + self.namespace = Namespace(uncheckedItemWindow: itemWindow) + self.title = itemWindow.title ?? "" + } +} + +// MARK: - MenuBarItemInfo.Namespace Unchecked Item Window Initializer + +private extension MenuBarItemInfo.Namespace { + /// Creates a namespace from the given window. + /// + /// This initializer does not perform any checks on the window to ensure that + /// it is a valid menu bar item window. Only call this initializer if you are + /// certain that the window is valid. + init(uncheckedItemWindow itemWindow: WindowInfo) { + // Most apps have a bundle ID, but we should be able to handle apps + // that don't. We should also be able to handle daemons and helpers, + // which are more likely not to have a bundle ID. + // + // Use the name of the owning process as a fallback. The non-localized + // name seems less likely to change, so let's prefer it as a (somewhat) + // stable identifier. + if let app = itemWindow.owningApplication { + self.init(app.bundleIdentifier ?? itemWindow.ownerName ?? app.localizedName) } else { - self.title = "" + self.init(itemWindow.ownerName) } } } diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemInfo.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemInfo.swift index 405a35cae..59f93472b 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemInfo.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemInfo.swift @@ -11,95 +11,112 @@ struct MenuBarItemInfo: Hashable, CustomStringConvertible { /// The title of the item. let title: String - /// A Boolean value that indicates whether the item is within the - /// "Special" namespace. - var isSpecial: Bool { - namespace == .special + /// A Boolean value that indicates whether the item can be moved. + var isMovable: Bool { + !MenuBarItemInfo.immovableItems.contains(self) } + /// A Boolean value that indicates whether the item can be hidden. + var canBeHidden: Bool { + !MenuBarItemInfo.nonHideableItems.contains(self) + } + + /// A string representation of the item. + var stringValue: String { + var result = namespace.rawValue + if !title.isEmpty { + result.append(":\(title)") + } + return result + } + + /// A textual representation of the item. var description: String { - namespace.rawValue + ":" + title + stringValue } - /// Creates a simplified item with the given namespace and title. + /// Creates an item with the given namespace and title. init(namespace: Namespace, title: String) { self.namespace = namespace self.title = title } + + /// Creates an item for the control item with the given identifier. + private init(controlItem identifier: ControlItem.Identifier) { + if #available(macOS 26.0, *) { + self.init(namespace: .controlCenter, title: identifier.rawValue) + } else { + self.init(namespace: .ice, title: identifier.rawValue) + } + } } // MARK: MenuBarItemInfo Constants + extension MenuBarItemInfo { + + // MARK: Special Item Lists + /// An array of items whose movement is prevented by macOS. static let immovableItems = [clock, siri, controlCenter] + // FIXME: At some point, Apple made the "MusicRecognition" item hideable. + // We need to determine which version of macOS first had this change, and + // conditionally exclude the item from this list based on that. + // /// An array of items that can be moved, but cannot be hidden. - static let nonHideableItems = [audioVideoModule, faceTime, musicRecognition] - - /// Information for an item that represents the Ice icon, a.k.a. the - /// control item for the visible section. - static let iceIcon = MenuBarItemInfo( - namespace: .ice, - title: ControlItem.Identifier.iceIcon.rawValue - ) - - /// Information for an item that represents the control item for the - /// hidden section. - static let hiddenControlItem = MenuBarItemInfo( - namespace: .ice, - title: ControlItem.Identifier.hidden.rawValue - ) - - /// Information for an item that represents the control item for the - /// always-hidden section. - static let alwaysHiddenControlItem = MenuBarItemInfo( - namespace: .ice, - title: ControlItem.Identifier.alwaysHidden.rawValue - ) - - /// Information for the "Clock" item. - static let clock = MenuBarItemInfo( - namespace: .controlCenter, - title: "Clock" - ) - - /// Information for the "Siri" item. - static let siri = MenuBarItemInfo( - namespace: .systemUIServer, - title: "Siri" - ) - - /// Information for the "BentoBox" (a.k.a. "Control Center") item. - static let controlCenter = MenuBarItemInfo( - namespace: .controlCenter, - title: "BentoBox" - ) - - /// Information for the item that appears in the menu bar while the - /// screen or system audio is being recorded. - static let audioVideoModule = MenuBarItemInfo( - namespace: .controlCenter, - title: "AudioVideoModule" - ) - - /// Information for the "FaceTime" item. - static let faceTime = MenuBarItemInfo( - namespace: .controlCenter, - title: "FaceTime" - ) - - /// Information for the "MusicRecognition" (a.k.a. "Shazam") item. - static let musicRecognition = MenuBarItemInfo( - namespace: .controlCenter, - title: "MusicRecognition" - ) - - /// Information for a special item that indicates the location where - /// new menu bar items should appear. - static let newItems = MenuBarItemInfo( - namespace: .special, - title: "NewItems" - ) + static let nonHideableItems = [audioVideoModule, faceTime, musicRecognition, screenCaptureUI] + + /// An array of items representing the control items for all sections. + static let controlItems = MenuBarSection.Name.allCases.map { $0.controlItemInfo } + + // MARK: Control Items + + /// The control item for the visible section. + static let iceIcon = MenuBarItemInfo(controlItem: .iceIcon) + + /// The control item for the hidden section. + static let hiddenControlItem = MenuBarItemInfo(controlItem: .hidden) + + /// The control item for the always-hidden section. + static let alwaysHiddenControlItem = MenuBarItemInfo(controlItem: .alwaysHidden) + + // MARK: Other Items + + /// The "Clock" item. + static let clock = MenuBarItemInfo(namespace: .controlCenter, title: "Clock") + + /// The "Siri" item. + static let siri: MenuBarItemInfo = { + if #available(macOS 26.0, *) { + MenuBarItemInfo(namespace: .controlCenter, title: "Siri") + } else { + MenuBarItemInfo(namespace: .systemUIServer, title: "Siri") + } + }() + + /// The "Control Center" item. + static let controlCenter: MenuBarItemInfo = { + if #available(macOS 26.0, *) { + MenuBarItemInfo(namespace: .controlCenter, title: "BentoBox-0") + } else { + MenuBarItemInfo(namespace: .controlCenter, title: "BentoBox") + } + }() + + /// The item that appears in the menu bar while the screen or system + /// audio is being recorded. + static let audioVideoModule = MenuBarItemInfo(namespace: .controlCenter, title: "AudioVideoModule") + + /// The "FaceTime" item. + static let faceTime = MenuBarItemInfo(namespace: .controlCenter, title: "FaceTime") + + /// The "MusicRecognition" (a.k.a. "Shazam") item. + static let musicRecognition = MenuBarItemInfo(namespace: .controlCenter, title: "MusicRecognition") + + /// The "stop recording" item that appears in the menu bar during screen + /// recordings started by the macOS "Screenshot" tool. + static let screenCaptureUI = MenuBarItemInfo(namespace: .screenCaptureUI, title: "Item-0") } // MARK: MenuBarItemInfo: Codable @@ -130,7 +147,7 @@ extension MenuBarItemInfo: Codable { func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() - try container.encode([namespace.rawValue, title].joined(separator: ":")) + try container.encode(stringValue) } } @@ -206,14 +223,21 @@ extension MenuBarItemInfo.Namespace { /// The namespace for menu bar items owned by Ice. static let ice = Self(Constants.bundleIdentifier) - /// The namespace for menu bar items owned by Control Center. + /// The namespace for menu bar items owned by "Control Center". static let controlCenter = Self("com.apple.controlcenter") - /// The namespace for menu bar items owned by the System UI Server. + /// The namespace for menu bar items owned by "System UI Server". static let systemUIServer = Self("com.apple.systemuiserver") - /// The namespace for special items. - static let special = Self("Special") + /// The namespace for the "stop recording" menu bar item that appears + /// during screen recordings started by the macOS "Screenshot" tool. + static let screenCaptureUI = Self("com.apple.screencaptureui") + + /// The namespace for the "Passwords" menu bar item. + static let passwords = Self("com.apple.Passwords.MenuBarExtra") + + /// The namespace for the "Weather" menu bar item. + static let weather = Self("com.apple.weather.menu") /// The null namespace. static let null = Self(kind: .null) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index 68694d0dc..902cbb5be 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -1299,7 +1299,7 @@ extension MenuBarItemManager { /// - mouseButton: The mouse button of the click. func tempShowItem(_ item: MenuBarItem, clickWhenFinished: Bool, mouseButton: CGMouseButton) { if - let latest = MenuBarItem(windowID: item.windowID), + let latest = item.latest, latest.isOnScreen { if clickWhenFinished { diff --git a/Ice/MenuBar/MenuBarSection.swift b/Ice/MenuBar/MenuBarSection.swift index b1157d377..0bacc192a 100644 --- a/Ice/MenuBar/MenuBarSection.swift +++ b/Ice/MenuBar/MenuBarSection.swift @@ -31,6 +31,15 @@ final class MenuBarSection { case .alwaysHidden: "always-hidden section" } } + + /// Information for the section's corresponding control item. + var controlItemInfo: MenuBarItemInfo { + switch self { + case .visible: .iceIcon + case .hidden: .hiddenControlItem + case .alwaysHidden: .alwaysHiddenControlItem + } + } } /// The name of the section. diff --git a/Ice/UI/IceBar/IceBar.swift b/Ice/UI/IceBar/IceBar.swift index 40c689829..318bf3c08 100644 --- a/Ice/UI/IceBar/IceBar.swift +++ b/Ice/UI/IceBar/IceBar.swift @@ -135,11 +135,10 @@ final class IceBarPanel: NSPanel { guard lowerBound <= upperBound, - let section = appState.menuBarManager.section(withName: .visible), - let windowID = section.controlItem.windowID, + let iceIcon = appState.itemManager.itemCache.allItems.first(matching: .iceIcon), // Bridging.getWindowFrame is more reliable than ControlItem.windowFrame, // i.e. if the control item is offscreen. - let itemFrame = Bridging.getWindowFrame(for: windowID) + let itemFrame = Bridging.getWindowFrame(for: iceIcon.windowID) else { return originForRightOfScreen } diff --git a/Ice/UI/LayoutBar/LayoutBar.swift b/Ice/UI/LayoutBar/LayoutBar.swift index 01ac6219f..751b4bd80 100644 --- a/Ice/UI/LayoutBar/LayoutBar.swift +++ b/Ice/UI/LayoutBar/LayoutBar.swift @@ -43,7 +43,8 @@ struct LayoutBar: View { conditionalBody .frame(height: 50) .frame(maxWidth: .infinity) - .layoutBarStyle(appState: appState, averageColorInfo: menuBarManager.averageColorInfo) + .background(Color(white: 0.25)) +// .layoutBarStyle(appState: appState, averageColorInfo: menuBarManager.averageColorInfo) .clipShape(backgroundShape) .overlay { backgroundShape diff --git a/Ice/Utilities/Extensions.swift b/Ice/Utilities/Extensions.swift index fb824db2b..86bfa9916 100644 --- a/Ice/Utilities/Extensions.swift +++ b/Ice/Utilities/Extensions.swift @@ -492,4 +492,9 @@ extension Sequence where Element == MenuBarItem { lhs.frame.maxX < rhs.frame.maxX } } + + /// Returns the first menu bar item that matches the specified info. + func first(matching info: MenuBarItemInfo) -> MenuBarItem? { + first { $0.info == info } + } } diff --git a/Ice/Utilities/WindowInfo.swift b/Ice/Utilities/WindowInfo.swift index 224bcfbbc..31276cba5 100644 --- a/Ice/Utilities/WindowInfo.swift +++ b/Ice/Utilities/WindowInfo.swift @@ -56,23 +56,12 @@ struct WindowInfo { NSRunningApplication(processIdentifier: ownerPID) } - /// A Boolean value that indicates whether the window represents a - /// menu bar item. - var isMenuBarItem: Bool { - layer == kCGStatusWindowLevel - } - /// A Boolean value that indicates whether the window belongs to the /// window server. var isWindowServerWindow: Bool { ownerName == "Window Server" } - /// A Boolean value that indicates whether the window is on the active space. - var isOnActiveSpace: Bool { - Bridging.isWindowOnActiveSpace(windowID) - } - /// Creates a window with the given dictionary. private init?(dictionary: CFDictionary) { guard From 6295f734c16cf8004f147dc6124a3ba0a3af75dd Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Wed, 11 Jun 2025 04:36:14 -0600 Subject: [PATCH 02/80] macOS 26: Fix menu bar color averaging --- Ice/MenuBar/MenuBarManager.swift | 48 +++++++++++++++--------- Ice/UI/IceBar/IceBarColorManager.swift | 51 ++++++++++++++++++-------- Ice/UI/LayoutBar/LayoutBar.swift | 3 +- 3 files changed, 68 insertions(+), 34 deletions(-) diff --git a/Ice/MenuBar/MenuBarManager.swift b/Ice/MenuBar/MenuBarManager.swift index 5feed0ac5..ae72236d8 100644 --- a/Ice/MenuBar/MenuBarManager.swift +++ b/Ice/MenuBar/MenuBarManager.swift @@ -239,24 +239,38 @@ final class MenuBarManager: ObservableObject { let windows = WindowInfo.getOnScreenWindows(excludeDesktopWindows: false) let displayID = screen.displayID - if let window = WindowInfo.getMenuBarWindow(from: windows, for: displayID) { - var bounds = window.frame - bounds.size.height = 1 - bounds.origin.x = bounds.maxX - (bounds.width / 4) - bounds.size.width /= 4 - - image = ScreenCapture.captureWindow(window.windowID, screenBounds: bounds, option: .nominalResolution) - source = .menuBarWindow - } else if let window = WindowInfo.getWallpaperWindow(from: windows, for: displayID) { - var bounds = window.frame - bounds.size.height = 1 - bounds.origin.x = bounds.midX - bounds.size.width /= 2 - - image = ScreenCapture.captureWindow(window.windowID, screenBounds: bounds, option: .nominalResolution) - source = .desktopWallpaper + if #available(macOS 26.0, *) { + if let window = WindowInfo.getWallpaperWindow(from: windows, for: displayID) { + var bounds = window.frame + bounds.size.height = 1 + bounds.origin.x = bounds.midX + bounds.size.width /= 2 + + image = ScreenCapture.captureWindow(window.windowID, screenBounds: bounds, option: .nominalResolution) + source = .desktopWallpaper + } else { + return + } } else { - return + if let window = WindowInfo.getMenuBarWindow(from: windows, for: displayID) { + var bounds = window.frame + bounds.size.height = 1 + bounds.origin.x = bounds.maxX - (bounds.width / 4) + bounds.size.width /= 4 + + image = ScreenCapture.captureWindow(window.windowID, screenBounds: bounds, option: .nominalResolution) + source = .menuBarWindow + } else if let window = WindowInfo.getWallpaperWindow(from: windows, for: displayID) { + var bounds = window.frame + bounds.size.height = 1 + bounds.origin.x = bounds.midX + bounds.size.width /= 2 + + image = ScreenCapture.captureWindow(window.windowID, screenBounds: bounds, option: .nominalResolution) + source = .desktopWallpaper + } else { + return + } } guard diff --git a/Ice/UI/IceBar/IceBarColorManager.swift b/Ice/UI/IceBar/IceBarColorManager.swift index da77cd19a..fd0d58422 100644 --- a/Ice/UI/IceBar/IceBarColorManager.swift +++ b/Ice/UI/IceBar/IceBarColorManager.swift @@ -7,11 +7,16 @@ import Cocoa import Combine final class IceBarColorManager: ObservableObject { + private struct WindowImageInfo { + let image: CGImage + let source: MenuBarAverageColorInfo.Source + } + @Published private(set) var colorInfo: MenuBarAverageColorInfo? private weak var iceBarPanel: IceBarPanel? - private var windowImage: CGImage? + private var windowImageInfo: WindowImageInfo? private var cancellables = Set() @@ -34,7 +39,7 @@ final class IceBarColorManager: ObservableObject { else { return } - updateWindowImage(for: screen) + updateWindowImageInfo(for: screen) } .store(in: &c) @@ -80,7 +85,7 @@ final class IceBarColorManager: ObservableObject { else { return } - updateWindowImage(for: screen) + updateWindowImageInfo(for: screen) if iceBarPanel.isVisible { updateColorInfo(with: iceBarPanel.frame, screen: screen) } @@ -91,44 +96,60 @@ final class IceBarColorManager: ObservableObject { cancellables = c } - private func updateWindowImage(for screen: NSScreen) { + private func updateWindowImageInfo(for screen: NSScreen) { + let windows = WindowInfo.getOnScreenWindows(excludeDesktopWindows: false) let displayID = screen.displayID - if - let window = WindowInfo.getMenuBarWindow(for: displayID), - let image = ScreenCapture.captureWindow(window.windowID, option: .nominalResolution) - { - windowImage = image + + if #available(macOS 26.0, *) { + if let window = WindowInfo.getWallpaperWindow(from: windows, for: displayID) { + let bounds = with(window.frame) { $0.size.height = 1 } + if let image = ScreenCapture.captureWindow(window.windowID, screenBounds: bounds, option: .nominalResolution) { + windowImageInfo = WindowImageInfo(image: image, source: .desktopWallpaper) + } else { + windowImageInfo = nil + } + } } else { - windowImage = nil + if + let window = WindowInfo.getMenuBarWindow(from: windows, for: displayID), + let image = ScreenCapture.captureWindow(window.windowID, option: .nominalResolution) + { + windowImageInfo = WindowImageInfo(image: image, source: .menuBarWindow) + } else { + windowImageInfo = nil + } } } private func updateColorInfo(with frame: CGRect, screen: NSScreen) { - guard let windowImage else { + guard let windowImageInfo else { colorInfo = nil return } - let imageBounds = CGRect(x: 0, y: 0, width: windowImage.width, height: windowImage.height) + let image = windowImageInfo.image + let imageBounds = CGRect(x: 0, y: 0, width: image.width, height: image.height) + let insetScreenFrame = screen.frame.insetBy(dx: frame.width / 2, dy: 0) let percentage = ((frame.midX - insetScreenFrame.minX) / insetScreenFrame.width).clamped(to: 0...1) + let cropRect = CGRect(x: imageBounds.width * percentage, y: 0, width: 0, height: 1) .insetBy(dx: -50, dy: 0) .intersection(imageBounds) guard - let croppedImage = windowImage.cropping(to: cropRect), + let croppedImage = image.cropping(to: cropRect), let averageColor = croppedImage.averageColor() else { colorInfo = nil return } - colorInfo = MenuBarAverageColorInfo(color: averageColor, source: .menuBarWindow) + colorInfo = MenuBarAverageColorInfo(color: averageColor, source: windowImageInfo.source) } func updateAllProperties(with frame: CGRect, screen: NSScreen) { - updateWindowImage(for: screen) + updateWindowImageInfo(for: screen) updateColorInfo(with: frame, screen: screen) } } diff --git a/Ice/UI/LayoutBar/LayoutBar.swift b/Ice/UI/LayoutBar/LayoutBar.swift index 751b4bd80..01ac6219f 100644 --- a/Ice/UI/LayoutBar/LayoutBar.swift +++ b/Ice/UI/LayoutBar/LayoutBar.swift @@ -43,8 +43,7 @@ struct LayoutBar: View { conditionalBody .frame(height: 50) .frame(maxWidth: .infinity) - .background(Color(white: 0.25)) -// .layoutBarStyle(appState: appState, averageColorInfo: menuBarManager.averageColorInfo) + .layoutBarStyle(appState: appState, averageColorInfo: menuBarManager.averageColorInfo) .clipShape(backgroundShape) .overlay { backgroundShape From f17729ebfac8654496778125dc3131885e72b648 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Sat, 14 Jun 2025 05:49:08 -0600 Subject: [PATCH 03/80] macOS 26: Rework EventManager Misc additional changes and cleanup --- Ice/Events/EventManager.swift | 316 +++++++++++----------- Ice/Hotkeys/HotkeyAction.swift | 8 +- Ice/Main/AppState.swift | 97 +++---- Ice/MenuBar/ControlItem/ControlItem.swift | 25 +- Ice/MenuBar/MenuBarManager.swift | 17 +- Ice/MenuBar/MenuBarSection.swift | 125 +++++---- Ice/UI/IceBar/IceBar.swift | 2 +- 7 files changed, 293 insertions(+), 297 deletions(-) diff --git a/Ice/Events/EventManager.swift b/Ice/Events/EventManager.swift index ee8f00006..d2f92b962 100644 --- a/Ice/Events/EventManager.swift +++ b/Ice/Events/EventManager.swift @@ -21,19 +21,19 @@ final class EventManager { private(set) lazy var mouseDownMonitor = UniversalEventMonitor( mask: [.leftMouseDown, .rightMouseDown] ) { [weak self] event in - guard let self else { + guard let self, let appState, let screen = bestScreen(appState: appState) else { return event } switch event.type { case .leftMouseDown: - handleShowOnClick() - handleSmartRehide(with: event) + handleShowOnClick(appState: appState, screen: screen) + handleSmartRehide(with: event, appState: appState, screen: screen) case .rightMouseDown: - handleShowRightClickMenu() + handleShowRightClickMenu(appState: appState, screen: screen) default: - break + return event } - handlePreventShowOnHover(with: event) + handlePreventShowOnHover(with: event, appState: appState, screen: screen) return event } @@ -41,7 +41,9 @@ final class EventManager { private(set) lazy var mouseUpMonitor = UniversalEventMonitor( mask: .leftMouseUp ) { [weak self] event in - self?.handleLeftMouseUp() + if let self, let appState { + handleLeftMouseUp(appState: appState) + } return event } @@ -49,7 +51,9 @@ final class EventManager { private(set) lazy var mouseDraggedMonitor = UniversalEventMonitor( mask: .leftMouseDragged ) { [weak self] event in - self?.handleLeftMouseDragged(with: event) + if let self, let appState, let screen = bestScreen(appState: appState) { + handleLeftMouseDragged(with: event, appState: appState, screen: screen) + } return event } @@ -57,7 +61,9 @@ final class EventManager { private(set) lazy var mouseMovedMonitor = UniversalEventMonitor( mask: .mouseMoved ) { [weak self] event in - self?.handleShowOnHover() + if let self, let appState, let screen = bestScreen(appState: appState) { + handleShowOnHover(appState: appState, screen: screen) + } return event } @@ -65,7 +71,9 @@ final class EventManager { private(set) lazy var scrollWheelMonitor = UniversalEventMonitor( mask: .scrollWheel ) { [weak self] event in - self?.handleShowOnScroll(with: event) + if let self, let appState, let screen = bestScreen(appState: appState) { + handleShowOnScroll(with: event, appState: appState, screen: screen) + } return event } @@ -97,26 +105,25 @@ final class EventManager { private func configureCancellables() { var c = Set() - if let appState { - if let hiddenSection = appState.menuBarManager.section(withName: .hidden) { - // In fullscreen mode, the menu bar slides down from the top on hover. Observe - // the frame of the hidden section's control item, which we know will always be - // in the menu bar, and run the show-on-hover check when it changes. - Publishers.CombineLatest( - hiddenSection.controlItem.$windowFrame, - appState.$isActiveSpaceFullscreen - ) - .sink { [weak self] _, isFullscreen in - guard - let self, - isFullscreen - else { - return - } - handleShowOnHover() + if let appState, let hiddenSection = appState.menuBarManager.section(withName: .hidden) { + // In fullscreen mode, the menu bar slides down from the top on hover. Observe the + // frame of the hidden section's control item, which we know will always be in the + // menu bar, and run the show-on-hover check when it changes. + Publishers.CombineLatest3( + hiddenSection.controlItem.$windowFrame, + appState.$isActiveSpaceFullscreen, + appState.menuBarManager.$isMenuBarHiddenBySystem + ) + .receive(on: DispatchQueue.main) + .sink { [weak self, weak appState] _, isFullscreen, isMenuBarHiddenBySystem in + guard let self, let appState, isFullscreen || isMenuBarHiddenBySystem else { + return + } + if let screen = bestScreen(appState: appState) { + handleShowOnHover(appState: appState, screen: screen) } - .store(in: &c) } + .store(in: &c) } cancellables = c @@ -145,31 +152,29 @@ extension EventManager { // MARK: Handle Show On Click - private func handleShowOnClick() { + private func handleShowOnClick(appState: AppState, screen: NSScreen) { guard - let appState, appState.settingsManager.generalSettingsManager.showOnClick, - isMouseInsideEmptyMenuBarSpace + isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen) else { return } Task { // Short delay helps the toggle action feel more natural. - try? await Task.sleep(for: .milliseconds(50)) - + try await Task.sleep(for: .milliseconds(50)) if NSEvent.modifierFlags == .control { - handleShowRightClickMenu() + handleShowRightClickMenu(appState: appState, screen: screen) } else if NSEvent.modifierFlags == .option, appState.settingsManager.advancedSettingsManager.canToggleAlwaysHiddenSection { if let alwaysHiddenSection = appState.menuBarManager.section(withName: .alwaysHidden) { - alwaysHiddenSection.toggle() + await alwaysHiddenSection.toggle() } } else { if let hiddenSection = appState.menuBarManager.section(withName: .hidden) { - hiddenSection.toggle() + await hiddenSection.toggle() } } } @@ -177,9 +182,8 @@ extension EventManager { // MARK: Handle Smart Rehide - private func handleSmartRehide(with event: NSEvent) { + private func handleSmartRehide(with event: NSEvent, appState: AppState, screen: NSScreen) { guard - let appState, appState.settingsManager.generalSettingsManager.autoRehide, case .smart = appState.settingsManager.generalSettingsManager.rehideStrategy else { @@ -197,21 +201,21 @@ extension EventManager { return } - // Only continue if a section is currently visible. - guard appState.menuBarManager.sections.contains(where: { !$0.isHidden }) else { + // Only continue if at least one section is visible. + guard appState.menuBarManager.hasVisibleSection else { return } // Make sure the mouse is not in the menu bar. - guard !isMouseInsideMenuBar else { + guard !isMouseInsideMenuBar(appState: appState, screen: screen) else { return } - Task { - let initialSpaceID = Bridging.getActiveSpaceID() + let initialSpaceID = Bridging.getActiveSpaceID() - // Sleep for a bit to give the window under the mouse a chance to focus. - try? await Task.sleep(for: .seconds(0.25)) + Task { + // Wait for a bit to give the window under the mouse a chance to focus. + try await Task.sleep(for: .milliseconds(250)) // If clicking caused a space change, don't bother with the window check. if Bridging.getActiveSpaceID() != initialSpaceID { @@ -244,7 +248,7 @@ extension EventManager { } } - // If all the above checks have passed, hide all sections. + // All checks have passed, so hide the sections. for section in appState.menuBarManager.sections { section.hide() } @@ -253,11 +257,10 @@ extension EventManager { // MARK: Handle Show Right Click Menu - private func handleShowRightClickMenu() { + private func handleShowRightClickMenu(appState: AppState, screen: NSScreen) { guard - let appState, appState.settingsManager.advancedSettingsManager.showContextMenuOnRightClick, - isMouseInsideEmptyMenuBarSpace, + isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen), let mouseLocation = MouseCursor.locationAppKit else { return @@ -267,57 +270,57 @@ extension EventManager { // MARK: Handle Prevent Show On Hover - private func handlePreventShowOnHover(with event: NSEvent) { + private func handlePreventShowOnHover(with event: NSEvent, appState: AppState, screen: NSScreen) { guard - let appState, appState.settingsManager.generalSettingsManager.showOnHover, - !appState.settingsManager.generalSettingsManager.useIceBar, - isMouseInsideMenuBar + !appState.settingsManager.generalSettingsManager.useIceBar else { return } - if isMouseInsideMenuBarItem { + guard isMouseInsideMenuBar(appState: appState, screen: screen) else { + return + } + + if isMouseInsideMenuBarItem(appState: appState, screen: screen) { switch event.type { case .leftMouseDown: - if appState.menuBarManager.sections.contains(where: { !$0.isHidden }) || isMouseInsideIceIcon { - // We have a left click that is inside the menu bar while at least one - // section is visible or the mouse is inside the Ice icon. - appState.preventShowOnHover() + if appState.menuBarManager.hasVisibleSection { + break + } + if isMouseInsideIceIcon(appState: appState) { + break } + return case .rightMouseDown: - if appState.menuBarManager.sections.contains(where: { !$0.isHidden }) { - // We have a right click that is inside the menu bar while at least one - // section is visible. - appState.preventShowOnHover() + if appState.menuBarManager.hasVisibleSection { + break } + return default: - break + return } - } else if !isMouseInsideApplicationMenu { - // We have a left or right click that is inside the menu bar, outside - // a menu bar item, and outside the application menu, so it _must_ be - // inside an empty menu bar space. - appState.preventShowOnHover() + } else if isMouseInsideApplicationMenu(appState: appState, screen: screen) { + return } + + // Mouse is inside the menu bar, outside an item or application + // menu, so it must be inside an empty menu bar space. + appState.menuBarManager.showOnHoverAllowed = false } // MARK: Handle Left Mouse Up - private func handleLeftMouseUp() { - guard let appearanceManager = appState?.appearanceManager else { - return - } - appearanceManager.setIsDraggingMenuBarItem(false) + private func handleLeftMouseUp(appState: AppState) { + appState.appearanceManager.setIsDraggingMenuBarItem(false) } // MARK: Handle Left Mouse Dragged - private func handleLeftMouseDragged(with event: NSEvent) { + private func handleLeftMouseDragged(with event: NSEvent, appState: AppState, screen: NSScreen) { guard - let appState, event.modifierFlags.contains(.command), - isMouseInsideMenuBar + isMouseInsideMenuBar(appState: appState, screen: screen) else { return } @@ -345,15 +348,11 @@ extension EventManager { // MARK: Handle Show On Hover - private func handleShowOnHover() { - guard let appState else { - return - } - - // Make sure the "ShowOnHover" feature is enabled and not prevented. + private func handleShowOnHover(appState: AppState, screen: NSScreen) { + // Make sure the "ShowOnHover" feature is enabled and allowed. guard appState.settingsManager.generalSettingsManager.showOnHover, - !appState.isShowOnHoverPrevented + appState.menuBarManager.showOnHoverAllowed else { return } @@ -365,29 +364,31 @@ extension EventManager { let delay = appState.settingsManager.advancedSettingsManager.showOnHoverDelay - Task { - if hiddenSection.isHidden { - guard self.isMouseInsideEmptyMenuBarSpace else { - return - } - try? await Task.sleep(for: .seconds(delay)) + if hiddenSection.isHidden { + guard isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen) else { + return + } + Task { + try await Task.sleep(for: .seconds(delay)) // Make sure the mouse is still inside. - guard self.isMouseInsideEmptyMenuBarSpace else { - return - } - hiddenSection.show() - } else { - guard - !self.isMouseInsideMenuBar, - !self.isMouseInsideIceBar - else { + guard isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen) else { return } - try? await Task.sleep(for: .seconds(delay)) + await hiddenSection.show() + } + } else { + guard + !isMouseInsideMenuBar(appState: appState, screen: screen), + !isMouseInsideIceBar(appState: appState) + else { + return + } + Task { + try await Task.sleep(for: .seconds(delay)) // Make sure the mouse is still outside. guard - !self.isMouseInsideMenuBar, - !self.isMouseInsideIceBar + !isMouseInsideMenuBar(appState: appState, screen: screen), + !isMouseInsideIceBar(appState: appState) else { return } @@ -398,18 +399,14 @@ extension EventManager { // MARK: Handle Show On Scroll - private func handleShowOnScroll(with event: NSEvent) { - guard let appState else { - return - } - + private func handleShowOnScroll(with event: NSEvent, appState: AppState, screen: NSScreen) { // Make sure the "ShowOnScroll" feature is enabled. guard appState.settingsManager.generalSettingsManager.showOnScroll else { return } // Make sure the mouse is inside the menu bar. - guard isMouseInsideMenuBar else { + guard isMouseInsideMenuBar(appState: appState, screen: screen) else { return } @@ -420,10 +417,12 @@ extension EventManager { let averageDelta = (event.scrollingDeltaX + event.scrollingDeltaY) / 2 - if averageDelta > 5 { - hiddenSection.show() - } else if averageDelta < -5 { - hiddenSection.hide() + Task { + if averageDelta > 5 { + await hiddenSection.show() + } else if averageDelta < -5 { + hiddenSection.hide() + } } } } @@ -432,46 +431,42 @@ extension EventManager { extension EventManager { /// Returns the best screen to use for event manager calculations. - var bestScreen: NSScreen? { - guard let appState else { - return nil - } - if appState.isActiveSpaceFullscreen { - return NSScreen.screenWithMouse ?? NSScreen.main - } else { + func bestScreen(appState: AppState) -> NSScreen? { + guard + appState.isActiveSpaceFullscreen, + let screen = NSScreen.screenWithMouse + else { return NSScreen.main } + return screen } /// A Boolean value that indicates whether the mouse pointer is within /// the bounds of the menu bar. - var isMouseInsideMenuBar: Bool { + func isMouseInsideMenuBar(appState: AppState, screen: NSScreen) -> Bool { + // Ice icon must be vertically visible. Otherwise, we can infer + // that the menu bar is hidden and the mouse is not inside. guard - let screen = bestScreen, - let appState + let iceIcon = appState.menuBarManager.controlItem(withName: .visible), + let iceIconFrame = iceIcon.windowFrame, + iceIconFrame.maxY <= screen.frame.maxY, + let mouseLocation = MouseCursor.locationAppKit else { return false } - if appState.menuBarManager.isMenuBarHiddenBySystem || appState.isActiveSpaceFullscreen { - if - let mouseLocation = MouseCursor.locationCoreGraphics, - let menuBarWindow = WindowInfo.getMenuBarWindow(for: screen.displayID) - { - return menuBarWindow.frame.contains(mouseLocation) - } - } else if let mouseLocation = MouseCursor.locationAppKit { - return mouseLocation.y > screen.visibleFrame.maxY && mouseLocation.y <= screen.frame.maxY - } - return false + + // Infer the menu bar frame from the screen frame. + return mouseLocation.x >= screen.frame.minX && + mouseLocation.x <= screen.frame.maxX && + mouseLocation.y <= screen.frame.maxY && + mouseLocation.y >= screen.visibleFrame.maxY } /// A Boolean value that indicates whether the mouse pointer is within /// the bounds of the current application menu. - var isMouseInsideApplicationMenu: Bool { + func isMouseInsideApplicationMenu(appState: AppState, screen: NSScreen) -> Bool { guard let mouseLocation = MouseCursor.locationCoreGraphics, - let screen = bestScreen, - let appState, var applicationMenuFrame = appState.menuBarManager.getApplicationMenuFrame(for: screen.displayID) else { return false @@ -483,49 +478,48 @@ extension EventManager { /// A Boolean value that indicates whether the mouse pointer is within /// the bounds of a menu bar item. - var isMouseInsideMenuBarItem: Bool { - guard - let screen = bestScreen, - let mouseLocation = MouseCursor.locationCoreGraphics - else { + func isMouseInsideMenuBarItem(appState: AppState, screen: NSScreen) -> Bool { + guard let mouseLocation = MouseCursor.locationCoreGraphics else { return false } - let menuBarItems = MenuBarItem.getMenuBarItems(on: screen.displayID, onScreenOnly: true, activeSpaceOnly: true) - return menuBarItems.contains { $0.frame.contains(mouseLocation) } + let menuBarItems = MenuBarItem.getMenuBarItems( + on: screen.displayID, + onScreenOnly: true, + activeSpaceOnly: true + ) + return menuBarItems.contains { item in + item.frame.contains(mouseLocation) + } } /// A Boolean value that indicates whether the mouse pointer is within /// the bounds of the screen's notch, if it has one. /// - /// If the screen returned from ``bestScreen`` does not have a notch, - /// this property returns `false`. - var isMouseInsideNotch: Bool { + /// If the screen does not have a notch, this property returns `false`. + func isMouseInsideNotch(appState: AppState, screen: NSScreen) -> Bool { guard - let screen = bestScreen, let mouseLocation = MouseCursor.locationAppKit, - let frameOfNotch = screen.frameOfNotch + var frameOfNotch = screen.frameOfNotch else { return false } + frameOfNotch.size.height += 1 return frameOfNotch.contains(mouseLocation) } /// A Boolean value that indicates whether the mouse pointer is within /// the bounds of an empty space in the menu bar. - var isMouseInsideEmptyMenuBarSpace: Bool { - isMouseInsideMenuBar && - !isMouseInsideApplicationMenu && - !isMouseInsideMenuBarItem && - !isMouseInsideNotch + func isMouseInsideEmptyMenuBarSpace(appState: AppState, screen: NSScreen) -> Bool { + isMouseInsideMenuBar(appState: appState, screen: screen) && + !isMouseInsideApplicationMenu(appState: appState, screen: screen) && + !isMouseInsideMenuBarItem(appState: appState, screen: screen) && + !isMouseInsideNotch(appState: appState, screen: screen) } /// A Boolean value that indicates whether the mouse pointer is within /// the bounds of the Ice Bar panel. - var isMouseInsideIceBar: Bool { - guard - let appState, - let mouseLocation = MouseCursor.locationAppKit - else { + func isMouseInsideIceBar(appState: AppState) -> Bool { + guard let mouseLocation = MouseCursor.locationAppKit else { return false } let panel = appState.menuBarManager.iceBarPanel @@ -537,9 +531,8 @@ extension EventManager { /// A Boolean value that indicates whether the mouse pointer is within /// the bounds of the Ice icon. - var isMouseInsideIceIcon: Bool { + func isMouseInsideIceIcon(appState: AppState) -> Bool { guard - let appState, let visibleSection = appState.menuBarManager.section(withName: .visible), let iceIconFrame = visibleSection.controlItem.windowFrame, let mouseLocation = MouseCursor.locationAppKit @@ -549,8 +542,3 @@ extension EventManager { return iceIconFrame.contains(mouseLocation) } } - -// MARK: - Logger -private extension Logger { - static let eventManager = Logger(category: "EventManager") -} diff --git a/Ice/Hotkeys/HotkeyAction.swift b/Ice/Hotkeys/HotkeyAction.swift index 51a717984..029067623 100644 --- a/Ice/Hotkeys/HotkeyAction.swift +++ b/Ice/Hotkeys/HotkeyAction.swift @@ -23,19 +23,19 @@ enum HotkeyAction: String, Codable, CaseIterable { guard let section = appState.menuBarManager.section(withName: .hidden) else { return } - section.toggle() + await section.toggle() // Prevent the section from automatically rehiding after mouse movement. if !section.isHidden { - appState.preventShowOnHover() + appState.menuBarManager.showOnHoverAllowed = false } case .toggleAlwaysHiddenSection: guard let section = appState.menuBarManager.section(withName: .alwaysHidden) else { return } - section.toggle() + await section.toggle() // Prevent the section from automatically rehiding after mouse movement. if !section.isHidden { - appState.preventShowOnHover() + appState.menuBarManager.showOnHoverAllowed = false } case .searchMenuBarItems: await appState.menuBarManager.searchPanel.toggle() diff --git a/Ice/Main/AppState.swift b/Ice/Main/AppState.swift index 12a2f8d42..24e81482a 100644 --- a/Ice/Main/AppState.swift +++ b/Ice/Main/AppState.swift @@ -57,9 +57,6 @@ final class AppState: ObservableObject { /// The window that contains the permissions interface. private(set) weak var permissionsWindow: NSWindow? - /// A Boolean value that indicates whether the "ShowOnHover" feature is prevented. - private(set) var isShowOnHoverPrevented = false - /// Storage for internal observers. private var cancellables = Set() @@ -220,83 +217,79 @@ final class AppState: ObservableObject { configureCancellables() } + /// Opens the window with the given identifier. + func openWindow(id: String) { + // Defer to the next run loop to prevent conflicts with SwiftUI. + DispatchQueue.main.async { + Logger.appState.debug("Opening window with id: \(id)") + EnvironmentValues().openWindow(id: id) + } + } + + /// Dismisses the window with the given identifier. + func dismissWindow(id: String) { + // Defer to the next run loop to prevent conflicts with SwiftUI. + DispatchQueue.main.async { + Logger.appState.debug("Dismissing window with id: \(id)") + EnvironmentValues().dismissWindow(id: id) + } + } + /// Opens the settings window. func openSettingsWindow() { - with(EnvironmentValues()) { environment in - environment.openWindow(id: Constants.settingsWindowID) - } + openWindow(id: Constants.settingsWindowID) } /// Dismisses the settings window. func dismissSettingsWindow() { - with(EnvironmentValues()) { environment in - environment.dismissWindow(id: Constants.settingsWindowID) - } + dismissWindow(id: Constants.settingsWindowID) } /// Opens the permissions window. func openPermissionsWindow() { - with(EnvironmentValues()) { environment in - environment.openWindow(id: Constants.permissionsWindowID) - } + openWindow(id: Constants.permissionsWindowID) } /// Dismisses the permissions window. func dismissPermissionsWindow() { - with(EnvironmentValues()) { environment in - environment.dismissWindow(id: Constants.permissionsWindowID) - } + dismissWindow(id: Constants.permissionsWindowID) } /// Activates the app and sets its activation policy to the given value. func activate(withPolicy policy: NSApplication.ActivationPolicy) { - // Store whether the app has previously activated inside an internal - // context to keep it isolated. - enum Context { - static let hasActivated = ObjectStorage() - } - func activate() { - if let frontApp = NSWorkspace.shared.frontmostApplication { - NSRunningApplication.current.activate(from: frontApp) - } else { - NSApp.activate() - } - NSApp.setActivationPolicy(policy) + // What follows is NOT at all straightforward, but this seems to + // be about the only way to make app activation (mostly) reliable + // after activation changes made in macOS 14. + + let current = NSRunningApplication.current + let workspace = NSWorkspace.shared + + NSApp.setActivationPolicy(policy) + NSApp.yieldActivation(to: current) + + guard var frontmost = workspace.frontmostApplication else { + current.activate() + return } - if Context.hasActivated.value(for: self) == true { - activate() - } else { - Context.hasActivated.set(true, for: self) - Logger.appState.debug("First time activating app, so going through Dock") - // Hack to make sure the app properly activates for the first time. - NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.dock").first?.activate() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - activate() - } + if + current.isActive, + let next = workspace.menuBarOwningApplication, + !next.isActive + { + next.activate(from: frontmost) + frontmost = next } + + current.activate(from: frontmost) } /// Deactivates the app and sets its activation policy to the given value. func deactivate(withPolicy policy: NSApplication.ActivationPolicy) { - if let nextApp = NSWorkspace.shared.runningApplications.first(where: { $0 != .current }) { - NSApp.yieldActivation(to: nextApp) - } else { - NSApp.deactivate() - } + NSApp.deactivate() NSApp.setActivationPolicy(policy) } - - /// Prevents the "ShowOnHover" feature. - func preventShowOnHover() { - isShowOnHoverPrevented = true - } - - /// Allows the "ShowOnHover" feature. - func allowShowOnHover() { - isShowOnHoverPrevented = false - } } // MARK: AppState: BindingExposable diff --git a/Ice/MenuBar/ControlItem/ControlItem.swift b/Ice/MenuBar/ControlItem/ControlItem.swift index 3f1be0f6a..f75156cd9 100644 --- a/Ice/MenuBar/ControlItem/ControlItem.swift +++ b/Ice/MenuBar/ControlItem/ControlItem.swift @@ -403,10 +403,14 @@ final class ControlItem { appState.settingsManager.advancedSettingsManager.canToggleAlwaysHiddenSection { if let alwaysHiddenSection = appState.menuBarManager.section(withName: .alwaysHidden) { - alwaysHiddenSection.toggle() + Task { + await alwaysHiddenSection.toggle() + } } } else { - section?.toggle() + Task { + await section?.toggle() + } } case .rightMouseUp: statusItem.showMenu(createMenu(with: appState)) @@ -467,7 +471,7 @@ final class ControlItem { keyEquivalent: "" ) item.target = self - Self.sectionStorage.weakSet(section, for: item) + item.representedObject = section switch name { case .visible: break @@ -516,7 +520,12 @@ final class ControlItem { /// Toggles the menu bar section associated with the given menu item. @objc private func toggleMenuBarSection(for menuItem: NSMenuItem) { - Self.sectionStorage.value(for: menuItem)?.toggle() + guard let section = menuItem.representedObject as? MenuBarSection else { + return + } + Task { + await section.toggle() + } } /// Opens the menu bar search panel. @@ -562,14 +571,6 @@ final class ControlItem { } } -private extension ControlItem { - /// Storage for menu items that toggle a menu bar section. - /// - /// When one of these menu items is created, its section is stored here. - /// When its action is invoked, the section is retrieved from storage. - static let sectionStorage = ObjectStorage() -} - // MARK: - Logger private extension Logger { /// The logger to use for control items. diff --git a/Ice/MenuBar/MenuBarManager.swift b/Ice/MenuBar/MenuBarManager.swift index ae72236d8..e950a9a5d 100644 --- a/Ice/MenuBar/MenuBarManager.swift +++ b/Ice/MenuBar/MenuBarManager.swift @@ -22,6 +22,9 @@ final class MenuBarManager: ObservableObject { /// according to a value stored in UserDefaults. @Published private(set) var isMenuBarHiddenBySystemUserDefaults = false + /// A Boolean value that indicates whether the "ShowOnHover" feature is allowed. + @Published var showOnHoverAllowed = true + /// The shared app state. private weak var appState: AppState? @@ -46,6 +49,12 @@ final class MenuBarManager: ObservableObject { appState?.settingsWindow?.isVisible == true } + /// A Boolean value that indicates whether at least one of the manager's + /// sections is visible. + var hasVisibleSection: Bool { + sections.contains { !$0.isHidden } + } + /// Initializes a new menu bar manager instance. init(appState: AppState) { self.iceBarPanel = IceBarPanel(appState: appState) @@ -124,7 +133,8 @@ final class MenuBarManager: ObservableObject { let appState, case .focusedApp = appState.settingsManager.generalSettingsManager.rehideStrategy, let hiddenSection = section(withName: .hidden), - !appState.eventManager.isMouseInsideMenuBar + let screen = appState.eventManager.bestScreen(appState: appState), + !appState.eventManager.isMouseInsideMenuBar(appState: appState, screen: screen) { Task { try await Task.sleep(for: .seconds(0.1)) @@ -409,6 +419,11 @@ final class MenuBarManager: ObservableObject { func section(withName name: MenuBarSection.Name) -> MenuBarSection? { sections.first { $0.name == name } } + + /// Returns the control item for the menu bar section with the given name. + func controlItem(withName name: MenuBarSection.Name) -> ControlItem? { + section(withName: name)?.controlItem + } } // MARK: MenuBarManager: BindingExposable diff --git a/Ice/MenuBar/MenuBarSection.swift b/Ice/MenuBar/MenuBarSection.swift index 0bacc192a..58bc061d5 100644 --- a/Ice/MenuBar/MenuBarSection.swift +++ b/Ice/MenuBar/MenuBarSection.swift @@ -63,9 +63,9 @@ final class MenuBarSection { appState?.settingsManager.generalSettingsManager.useIceBar ?? false } - /// A weak reference to the menu bar manager's Ice Bar panel. - private weak var iceBarPanel: IceBarPanel? { - appState?.menuBarManager.iceBarPanel + /// A weak reference to the menu bar manager. + private weak var menuBarManager: MenuBarManager? { + appState?.menuBarManager } /// The best screen to show the Ice Bar on. @@ -88,19 +88,19 @@ final class MenuBarSection { } switch name { case .visible, .hidden: - return iceBarPanel?.currentSection != .hidden + return menuBarManager?.iceBarPanel.currentSection != .hidden case .alwaysHidden: - return iceBarPanel?.currentSection != .alwaysHidden + return menuBarManager?.iceBarPanel.currentSection != .alwaysHidden } } switch name { case .visible, .hidden: - if iceBarPanel?.currentSection == .hidden { + if menuBarManager?.iceBarPanel.currentSection == .hidden { return false } return controlItem.state == .hideItems case .alwaysHidden: - if iceBarPanel?.currentSection == .alwaysHidden { + if menuBarManager?.iceBarPanel.currentSection == .alwaysHidden { return false } return controlItem.state == .hideItems @@ -137,84 +137,83 @@ final class MenuBarSection { } /// Shows the section. - func show() { - guard - let appState, - isHidden - else { + func show() async { + guard let menuBarManager, isHidden else { return } + guard controlItem.isAddedToMenuBar else { // The section is disabled. // TODO: Can we use isEnabled for this check? return } - switch name { - case .visible where useIceBar, .hidden where useIceBar: - Task { - if let screenForIceBar { - await iceBarPanel?.show(section: .hidden, on: screenForIceBar) + + defer { + startRehideChecks() + } + + if useIceBar { + for section in menuBarManager.sections { + section.controlItem.state = switch section.name { + case .visible: .showItems + default: .hideItems } - for section in appState.menuBarManager.sections { - section.controlItem.state = .hideItems + } + if let screen = screenForIceBar { + switch name { + case .visible, .hidden: + await menuBarManager.iceBarPanel.show(section: .hidden, on: screen) + case .alwaysHidden: + await menuBarManager.iceBarPanel.show(section: .alwaysHidden, on: screen) } } - case .alwaysHidden where useIceBar: - Task { - if let screenForIceBar { - await iceBarPanel?.show(section: .alwaysHidden, on: screenForIceBar) + } else { + // Make sure the Ice bar is closed. + menuBarManager.iceBarPanel.close() + var controlItems = [ControlItem]() + switch name { + case .visible: + if let hiddenControlItem = menuBarManager.controlItem(withName: .hidden) { + controlItems.append(controlItem) + controlItems.append(hiddenControlItem) } - for section in appState.menuBarManager.sections { - section.controlItem.state = .hideItems + case .hidden: + if let visibleControlItem = menuBarManager.controlItem(withName: .visible) { + controlItems.append(controlItem) + controlItems.append(visibleControlItem) + } + case .alwaysHidden: + if + let hiddenControlItem = menuBarManager.controlItem(withName: .hidden), + let visibleControlItem = menuBarManager.controlItem(withName: .visible) + { + controlItems.append(controlItem) + controlItems.append(hiddenControlItem) + controlItems.append(visibleControlItem) } } - case .visible: - iceBarPanel?.close() - guard let hiddenSection = appState.menuBarManager.section(withName: .hidden) else { - return - } - controlItem.state = .showItems - hiddenSection.controlItem.state = .showItems - case .hidden: - iceBarPanel?.close() - guard let visibleSection = appState.menuBarManager.section(withName: .visible) else { - return + for controlItem in controlItems { + controlItem.state = .showItems } - controlItem.state = .showItems - visibleSection.controlItem.state = .showItems - case .alwaysHidden: - iceBarPanel?.close() - guard - let hiddenSection = appState.menuBarManager.section(withName: .hidden), - let visibleSection = appState.menuBarManager.section(withName: .visible) - else { - return - } - controlItem.state = .showItems - hiddenSection.controlItem.state = .showItems - visibleSection.controlItem.state = .showItems } - startRehideChecks() } /// Hides the section. func hide() { - guard - let appState, - !isHidden - else { + guard let menuBarManager, !isHidden else { return } - iceBarPanel?.close() + // Make sure the Ice bar is always closed. + menuBarManager.iceBarPanel.close() switch name { case _ where useIceBar: - for section in appState.menuBarManager.sections { + for section in menuBarManager.sections { section.controlItem.state = .hideItems } case .visible: guard - let hiddenSection = appState.menuBarManager.section(withName: .hidden), - let alwaysHiddenSection = appState.menuBarManager.section(withName: .alwaysHidden) + let hiddenSection = menuBarManager.section(withName: .hidden), + let alwaysHiddenSection = menuBarManager.section(withName: .alwaysHidden) else { return } @@ -223,8 +222,8 @@ final class MenuBarSection { alwaysHiddenSection.controlItem.state = .hideItems case .hidden: guard - let visibleSection = appState.menuBarManager.section(withName: .visible), - let alwaysHiddenSection = appState.menuBarManager.section(withName: .alwaysHidden) + let visibleSection = menuBarManager.section(withName: .visible), + let alwaysHiddenSection = menuBarManager.section(withName: .alwaysHidden) else { return } @@ -234,14 +233,14 @@ final class MenuBarSection { case .alwaysHidden: controlItem.state = .hideItems } - appState.allowShowOnHover() + menuBarManager.showOnHoverAllowed = true stopRehideChecks() } /// Toggles the visibility of the section. - func toggle() { + func toggle() async { if isHidden { - show() + await show() } else { hide() } diff --git a/Ice/UI/IceBar/IceBar.swift b/Ice/UI/IceBar/IceBar.swift index 318bf3c08..0bdad4748 100644 --- a/Ice/UI/IceBar/IceBar.swift +++ b/Ice/UI/IceBar/IceBar.swift @@ -112,7 +112,7 @@ final class IceBarPanel: NSPanel { switch iceBarLocation { case .dynamic: - if appState.eventManager.isMouseInsideEmptyMenuBarSpace { + if appState.eventManager.isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen) { return getOrigin(for: .mousePointer) } return getOrigin(for: .iceIcon) From 075581cdaeb8a9a620e82accec09920eb95ed176 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Mon, 16 Jun 2025 07:16:09 -0600 Subject: [PATCH 04/80] macOS 26: Fix mouse state check `RunLoopLocalEventMonitor` seems to prevent certain buttons from receiving events in macOS 26 Developer Beta 1. This might be a bug in the beta, or `RunLoopLocalEventMonitor` itself. For now, let's just move to a better mouse check implementation that doesn't use continuous event monitoring. Note the `FIXME` (line 1057). The previous implementation had this problem too, but it was never documented. --- .../MenuBarItems/MenuBarItemManager.swift | 143 ++++++++---------- .../{MouseCursor.swift => MouseHelpers.swift} | 40 ++++- 2 files changed, 102 insertions(+), 81 deletions(-) rename Ice/Utilities/{MouseCursor.swift => MouseHelpers.swift} (55%) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index 902cbb5be..a5b7b288b 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -115,27 +115,10 @@ final class MenuBarItemManager: ObservableObject { /// The last time a menu bar item was moved. private var lastItemMoveStartDate: Date? - /// The last time the mouse was moved. - private var lastMouseMoveStartDate: Date? - /// Counter to determine if a menu bar item, or group of menu bar /// items is being moved. private var itemMoveCount = 0 - /// A Boolean value that indicates whether a mouse button is down. - private var isMouseButtonDown = false - - /// Event type mask for tracking mouse events. - private let mouseTrackingMask: NSEvent.EventTypeMask = [ - .mouseMoved, - .leftMouseDown, - .rightMouseDown, - .otherMouseDown, - .leftMouseUp, - .rightMouseUp, - .otherMouseUp, - ] - /// A Boolean value that indicates whether a menu bar item, or /// group of menu bar items is being moved. var isMovingItem: Bool { @@ -151,14 +134,6 @@ final class MenuBarItemManager: ObservableObject { return Date.now.timeIntervalSince(lastItemMoveStartDate) <= 1 } - /// A Boolean value that indicates whether the mouse has recently moved. - var mouseHasRecentlyMoved: Bool { - guard let lastMouseMoveStartDate else { - return false - } - return Date.now.timeIntervalSince(lastMouseMoveStartDate) <= 1 - } - /// Creates a manager with the given app state. init(appState: AppState) { self.appState = appState @@ -198,28 +173,6 @@ final class MenuBarItemManager: ObservableObject { } .store(in: &c) - Publishers.Merge( - UniversalEventMonitor.publisher(for: mouseTrackingMask), - RunLoopLocalEventMonitor.publisher(for: mouseTrackingMask, mode: .eventTracking) - ) - .removeDuplicates() - .sink { [weak self] event in - guard let self else { - return - } - switch event.type { - case .mouseMoved: - lastMouseMoveStartDate = .now - case .leftMouseDown, .rightMouseDown, .otherMouseDown: - isMouseButtonDown = true - case .leftMouseUp, .rightMouseUp, .otherMouseUp: - isMouseButtonDown = false - default: - break - } - } - .store(in: &c) - cancellables = c } } @@ -480,7 +433,7 @@ extension MenuBarItemManager { extension MenuBarItemManager { /// Waits asynchronously for the given operation to complete. - /// + /// /// - Parameters: /// - timeout: Amount of time to wait before throwing an error. /// - operation: The operation to perform. @@ -510,51 +463,73 @@ extension MenuBarItemManager { /// Waits asynchronously for the mouse to stop moving. /// - /// - Parameters: - /// - threshold: A threshold to use to determine whether the mouse has stopped moving. - /// - timeout: Amount of time to wait before throwing an error. - func waitForMouseToStopMoving(threshold: TimeInterval = 0.1, timeout: Duration? = nil) async throws { - try await waitWithTask(timeout: timeout) { [weak self] in - guard let self else { - return - } + /// - Parameter timeout: Amount of time to wait before throwing an error. + private func waitForMouseToStopMoving(timeout: Duration? = nil) async throws { + let duration = Duration.milliseconds(100) + guard MouseEvents.lastMovementOccurred(within: duration) else { + return + } + try await waitWithTask(timeout: timeout) { while true { try Task.checkCancellation() - guard let date = await lastMouseMoveStartDate else { - break - } - if Date.now.timeIntervalSince(date) > threshold { + if !MouseEvents.lastMovementOccurred(within: duration) { break } - try await Task.sleep(for: .milliseconds(10)) + try await Task.sleep(for: duration) } } } - /// Waits asynchronously until no modifier keys are pressed. + /// Waits asynchronously until all mouse buttons are up. /// /// - Parameter timeout: Amount of time to wait before throwing an error. - func waitForNoModifiersPressed(timeout: Duration? = nil) async throws { + private func waitForAllMouseButtonsUp(timeout: Duration? = nil) async throws { + guard MouseEvents.isButtonPressed() else { + return + } try await waitWithTask(timeout: timeout) { - // Return early if no flags are pressed. - if NSEvent.modifierFlags.isEmpty { - return + var cancellable: AnyCancellable? + + await withCheckedContinuation { continuation in + let mask: NSEvent.EventTypeMask = [.leftMouseUp, .rightMouseUp, .otherMouseUp] + cancellable = RunLoopLocalEventMonitor.publisher(for: mask, mode: .eventTracking) + .merge(with: UniversalEventMonitor.publisher(for: mask)) + .removeDuplicates() + .combineLatest(Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()) + .sink { _ in + if MouseEvents.isButtonPressed() { + return + } + cancellable?.cancel() + continuation.resume() + } } + } + } + /// Waits asynchronously until all modifier keys are up. + /// + /// - Parameter timeout: Amount of time to wait before throwing an error. + private func waitForAllModifierKeysUp(timeout: Duration? = nil) async throws { + if NSEvent.modifierFlags.isEmpty { + return + } + try await waitWithTask(timeout: timeout) { var cancellable: AnyCancellable? await withCheckedContinuation { continuation in - cancellable = Publishers.Merge( - UniversalEventMonitor.publisher(for: .flagsChanged), - RunLoopLocalEventMonitor.publisher(for: .flagsChanged, mode: .eventTracking) - ) - .removeDuplicates() - .sink { _ in - if NSEvent.modifierFlags.isEmpty { + let mask: NSEvent.EventTypeMask = .flagsChanged + cancellable = RunLoopLocalEventMonitor.publisher(for: mask, mode: .eventTracking) + .merge(with: UniversalEventMonitor.publisher(for: mask)) + .removeDuplicates() + .combineLatest(Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()) + .sink { _ in + guard NSEvent.modifierFlags.isEmpty else { + return + } cancellable?.cancel() continuation.resume() } - } } } } @@ -1079,10 +1054,18 @@ extension MenuBarItemManager { } do { - // Order of these waiters matters, as the modifiers could be released - // while the mouse is still moving. - try await waitForNoModifiersPressed() + // FIXME: Running these checks sequentially like this is prone to error. + // + // For example, say the user is holding down a modifier key while moving + // their mouse - they release the modifier, continue moving their mouse, + // then press the modifier again. We would completely miss this, as the + // modifier check would already be finished. We'd have the same problem + // running the checks concurrently. + // + // We need a way to cooperatively restart each check as needed. + try await waitForAllModifierKeysUp() try await waitForMouseToStopMoving() + try await waitForAllMouseButtonsUp() } catch { throw EventError(code: .couldNotComplete, item: item) } @@ -1408,7 +1391,7 @@ extension MenuBarItemManager { return } - guard !isMouseButtonDown else { + guard !MouseEvents.isButtonPressed() else { Logger.itemManager.debug("Mouse button is down, so waiting to rehide") runTempShownItemTimer(for: 3) return @@ -1473,11 +1456,11 @@ extension MenuBarItemManager { /// - alwaysHiddenControlItem: A menu bar item that represents the control item /// for the always-hidden section. func enforceControlItemOrder(hiddenControlItem: MenuBarItem, alwaysHiddenControlItem: MenuBarItem) async throws { - guard !isMouseButtonDown else { + guard !MouseEvents.isButtonPressed() else { Logger.itemManager.debug("Mouse button is down, so will not enforce control item order") return } - guard !mouseHasRecentlyMoved else { + guard !MouseEvents.lastMovementOccurred(within: .seconds(1)) else { Logger.itemManager.debug("Mouse has recently moved, so will not enforce control item order") return } diff --git a/Ice/Utilities/MouseCursor.swift b/Ice/Utilities/MouseHelpers.swift similarity index 55% rename from Ice/Utilities/MouseCursor.swift rename to Ice/Utilities/MouseHelpers.swift index e10886fbb..41a7ae62d 100644 --- a/Ice/Utilities/MouseCursor.swift +++ b/Ice/Utilities/MouseHelpers.swift @@ -1,5 +1,5 @@ // -// MouseCursor.swift +// MouseHelpers.swift // Ice // @@ -46,6 +46,44 @@ enum MouseCursor { } } +// MARK: - MouseEvents + +/// A namespace for mouse event operations. +enum MouseEvents { + /// Returns a Boolean value that indicates whether a mouse button + /// is pressed. + /// + /// - Parameter button: The mouse button to check. Pass `nil` to + /// check all available mouse buttons (Quartz supports up to 32). + static func isButtonPressed(_ button: CGMouseButton? = nil) -> Bool { + let stateID = CGEventSourceStateID.combinedSessionState + if let button { + return CGEventSource.buttonState(stateID, button: button) + } + for n: UInt32 in 0...31 { + guard + let button = CGMouseButton(rawValue: n), + CGEventSource.buttonState(stateID, button: button) + else { + continue + } + return true + } + return false + } + + /// Returns a Boolean value that indicates whether the last mouse + /// movement event occurred within the given duration. + /// + /// - Parameter interval: The duration within which the last mouse + /// movement event must have occurred in order to return `true`. + static func lastMovementOccurred(within duration: Duration) -> Bool { + let stateID = CGEventSourceStateID.combinedSessionState + let seconds = CGEventSource.secondsSinceLastEventType(stateID, eventType: .mouseMoved) + return .seconds(seconds) <= duration + } +} + // MARK: - Logger private extension Logger { static let mouseCursor = Logger(category: "MouseCursor") From fabe66c3f7d15a5cd2153aa5ec352a823955ca56 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Mon, 16 Jun 2025 09:38:34 -0600 Subject: [PATCH 05/80] macOS 26: UI updates --- .../NavigationIdentifier.swift | 7 +- .../SettingsNavigationIdentifier.swift | 13 ++- .../MenuBarAppearanceEditor.swift | 2 +- .../SettingsPanes/AboutSettingsPane.swift | 4 +- Ice/Settings/SettingsView.swift | 54 +++++++--- Ice/Settings/SettingsWindow.swift | 12 ++- Ice/UI/IceUI/IceForm.swift | 26 ++++- Ice/UI/IceUI/IceGroupBox.swift | 101 ++++++++++++++++-- Ice/UI/IceUI/IceSection.swift | 17 ++- 9 files changed, 192 insertions(+), 44 deletions(-) diff --git a/Ice/Main/Navigation/NavigationIdentifiers/NavigationIdentifier.swift b/Ice/Main/Navigation/NavigationIdentifiers/NavigationIdentifier.swift index 3d8f87ff4..62196503f 100644 --- a/Ice/Main/Navigation/NavigationIdentifiers/NavigationIdentifier.swift +++ b/Ice/Main/Navigation/NavigationIdentifiers/NavigationIdentifier.swift @@ -5,9 +5,12 @@ import SwiftUI -/// A type that represents an identifier used for navigation in a user interface. +/// A type that represents an identifier for a navigation destination. protocol NavigationIdentifier: CaseIterable, Hashable, Identifiable, RawRepresentable { - /// A localized description of the identifier that can be presented to the user. + /// An icon for the identifier's navigation destination. + var iconResource: IconResource { get } + + /// A localized description for the identifier's navigation destination. var localized: LocalizedStringKey { get } } diff --git a/Ice/Main/Navigation/NavigationIdentifiers/SettingsNavigationIdentifier.swift b/Ice/Main/Navigation/NavigationIdentifiers/SettingsNavigationIdentifier.swift index f3c1fee3f..9ce5bb215 100644 --- a/Ice/Main/Navigation/NavigationIdentifiers/SettingsNavigationIdentifier.swift +++ b/Ice/Main/Navigation/NavigationIdentifiers/SettingsNavigationIdentifier.swift @@ -3,7 +3,7 @@ // Ice // -/// An identifier used for navigation in the settings interface. +/// The navigation identifier type for the "Settings" interface. enum SettingsNavigationIdentifier: String, NavigationIdentifier { case general = "General" case menuBarLayout = "Menu Bar Layout" @@ -11,4 +11,15 @@ enum SettingsNavigationIdentifier: String, NavigationIdentifier { case hotkeys = "Hotkeys" case advanced = "Advanced" case about = "About" + + var iconResource: IconResource { + switch self { + case .general: .systemSymbol("gearshape") + case .menuBarLayout: .systemSymbol("rectangle.topthird.inset.filled") + case .menuBarAppearance: .systemSymbol("swatchpalette") + case .hotkeys: .systemSymbol("keyboard") + case .advanced: .systemSymbol("gearshape.2") + case .about: .assetCatalog(.iceCubeStroke) + } + } } diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift index e777c935a..af09aff2d 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift @@ -17,7 +17,7 @@ struct MenuBarAppearanceEditor: View { let location: Location private var mainFormPadding: EdgeInsets { - with(EdgeInsets(all: 20)) { insets in + with(EdgeInsets.iceFormDefaultPadding) { insets in switch location { case .settings: break case .popover: insets.top = 0 diff --git a/Ice/Settings/SettingsPanes/AboutSettingsPane.swift b/Ice/Settings/SettingsPanes/AboutSettingsPane.swift index 2e1cb0912..695685edb 100644 --- a/Ice/Settings/SettingsPanes/AboutSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/AboutSettingsPane.swift @@ -43,10 +43,10 @@ struct AboutSettingsPane: View { var body: some View { VStack(spacing: 0) { mainForm - Spacer(minLength: 20) + Spacer(minLength: 10) bottomBar } - .padding(30) + .padding(.iceFormDefaultPadding) } @ViewBuilder diff --git a/Ice/Settings/SettingsView.swift b/Ice/Settings/SettingsView.swift index 82bf84cab..ab2122975 100644 --- a/Ice/Settings/SettingsView.swift +++ b/Ice/Settings/SettingsView.swift @@ -10,11 +10,20 @@ struct SettingsView: View { @Environment(\.sidebarRowSize) var sidebarRowSize private var sidebarWidth: CGFloat { - switch sidebarRowSize { - case .small: 190 - case .medium: 210 - case .large: 230 - @unknown default: 210 + if #available(macOS 26.0, *) { + switch sidebarRowSize { + case .small: 200 + case .medium: 220 + case .large: 240 + @unknown default: 220 + } + } else { + switch sidebarRowSize { + case .small: 190 + case .medium: 210 + case .large: 230 + @unknown default: 210 + } } } @@ -36,13 +45,17 @@ struct SettingsView: View { } } + private var navigationTitle: LocalizedStringKey { + navigationState.settingsNavigationIdentifier.localized + } + var body: some View { NavigationSplitView { sidebar } detail: { detailView } - .navigationTitle(navigationState.settingsNavigationIdentifier.localized) + .navigationTitle(navigationTitle) } @ViewBuilder @@ -56,7 +69,7 @@ struct SettingsView: View { Text("Ice") .font(.system(size: 36, weight: .medium)) .foregroundStyle(.primary) - .padding(.vertical, 5) + .padding(.bottom, 10) } .collapsible(false) } @@ -67,6 +80,16 @@ struct SettingsView: View { @ViewBuilder private var detailView: some View { + if #available(macOS 26.0, *) { + settingsPane + .scrollEdgeEffectStyle(.hard, for: .top) + } else { + settingsPane + } + } + + @ViewBuilder + private var settingsPane: some View { switch navigationState.settingsNavigationIdentifier { case .general: GeneralSettingsPane() @@ -90,19 +113,18 @@ struct SettingsView: View { .font(.system(size: sidebarItemFontSize)) .padding(.leading, 2) } icon: { - icon(for: identifier).view + icon(for: identifier) } .frame(height: sidebarItemHeight) + .padding(.leading, 1) } - private func icon(for identifier: SettingsNavigationIdentifier) -> IconResource { - switch identifier { - case .general: .systemSymbol("gearshape") - case .menuBarLayout: .systemSymbol("rectangle.topthird.inset.filled") - case .menuBarAppearance: .systemSymbol("swatchpalette") - case .hotkeys: .systemSymbol("keyboard") - case .advanced: .systemSymbol("gearshape.2") - case .about: .assetCatalog(.iceCubeStroke) + @ViewBuilder + private func icon(for identifier: SettingsNavigationIdentifier) -> some View { + if #available(macOS 26.0, *) { + identifier.iconResource.view.padding(3) + } else { + identifier.iconResource.view } } } diff --git a/Ice/Settings/SettingsWindow.swift b/Ice/Settings/SettingsWindow.swift index b8bc94b08..4e11284f8 100644 --- a/Ice/Settings/SettingsWindow.swift +++ b/Ice/Settings/SettingsWindow.swift @@ -10,7 +10,7 @@ struct SettingsWindow: Scene { var body: some Scene { Window(Constants.settingsWindowTitle, id: Constants.settingsWindowID) { - SettingsView() + settingsView .readWindow { window in guard let window else { return @@ -25,4 +25,14 @@ struct SettingsWindow: Scene { .environmentObject(appState) .environmentObject(appState.navigationState) } + + @ViewBuilder + private var settingsView: some View { + if #available(macOS 26.0, *) { + SettingsView() + .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) + } else { + SettingsView() + } + } } diff --git a/Ice/UI/IceUI/IceForm.swift b/Ice/UI/IceUI/IceForm.swift index 6e62b4496..8e5cb0f24 100644 --- a/Ice/UI/IceUI/IceForm.swift +++ b/Ice/UI/IceUI/IceForm.swift @@ -16,8 +16,8 @@ struct IceForm: View { init( alignment: HorizontalAlignment = .center, - padding: EdgeInsets, - spacing: CGFloat = 10, + padding: EdgeInsets = .iceFormDefaultPadding, + spacing: CGFloat = .iceFormDefaultSpacing, @ViewBuilder content: () -> Content ) { self.alignment = alignment @@ -28,13 +28,13 @@ struct IceForm: View { init( alignment: HorizontalAlignment = .center, - padding: CGFloat = 20, - spacing: CGFloat = 10, + padding: CGFloat, + spacing: CGFloat = .iceFormDefaultSpacing, @ViewBuilder content: () -> Content ) { self.init( alignment: alignment, - padding: EdgeInsets(top: padding, leading: padding, bottom: padding, trailing: padding), + padding: EdgeInsets(all: padding), spacing: spacing ) { content() @@ -83,3 +83,19 @@ private struct IceFormToggleStyle: ToggleStyle { } } } + +extension EdgeInsets { + /// The default padding for an ``IceForm``. + static let iceFormDefaultPadding: EdgeInsets = { + var insets = EdgeInsets(all: 20) + if #available(macOS 26.0, *) { + insets.top = 0 + } + return insets + }() +} + +extension CGFloat { + /// The default spacing for an ``IceForm``. + static let iceFormDefaultSpacing: CGFloat = 10 +} diff --git a/Ice/UI/IceUI/IceGroupBox.swift b/Ice/UI/IceUI/IceGroupBox.swift index 09b23685c..7ab0323f0 100644 --- a/Ice/UI/IceUI/IceGroupBox.swift +++ b/Ice/UI/IceUI/IceGroupBox.swift @@ -9,14 +9,18 @@ struct IceGroupBox: View { private let header: Header private let content: Content private let footer: Footer - private let padding: CGFloat + private let padding: EdgeInsets private var backgroundShape: some InsettableShape { - RoundedRectangle(cornerRadius: 6, style: .circular) + if #available(macOS 26.0, *) { + RoundedRectangle(cornerRadius: 10, style: .continuous) + } else { + RoundedRectangle(cornerRadius: 7, style: .circular) + } } init( - padding: CGFloat = 10, + padding: EdgeInsets = .iceGroupBoxDefaultPadding, @ViewBuilder header: () -> Header, @ViewBuilder content: () -> Content, @ViewBuilder footer: () -> Footer @@ -28,7 +32,22 @@ struct IceGroupBox: View { } init( - padding: CGFloat = 10, + padding: CGFloat, + @ViewBuilder header: () -> Header, + @ViewBuilder content: () -> Content, + @ViewBuilder footer: () -> Footer + ) { + self.init(padding: EdgeInsets(all: padding)) { + header() + } content: { + content() + } footer: { + footer() + } + } + + init( + padding: EdgeInsets = .iceGroupBoxDefaultPadding, @ViewBuilder content: () -> Content, @ViewBuilder footer: () -> Footer ) where Header == EmptyView { @@ -42,7 +61,35 @@ struct IceGroupBox: View { } init( - padding: CGFloat = 10, + padding: CGFloat, + @ViewBuilder content: () -> Content, + @ViewBuilder footer: () -> Footer + ) where Header == EmptyView { + self.init(padding: padding) { + EmptyView() + } content: { + content() + } footer: { + footer() + } + } + + init( + padding: EdgeInsets = .iceGroupBoxDefaultPadding, + @ViewBuilder header: () -> Header, + @ViewBuilder content: () -> Content + ) where Footer == EmptyView { + self.init(padding: padding) { + header() + } content: { + content() + } footer: { + EmptyView() + } + } + + init( + padding: CGFloat, @ViewBuilder header: () -> Header, @ViewBuilder content: () -> Content ) where Footer == EmptyView { @@ -56,7 +103,7 @@ struct IceGroupBox: View { } init( - padding: CGFloat = 10, + padding: EdgeInsets = .iceGroupBoxDefaultPadding, @ViewBuilder content: () -> Content ) where Header == EmptyView, Footer == EmptyView { self.init(padding: padding) { @@ -68,9 +115,35 @@ struct IceGroupBox: View { } } + init( + padding: CGFloat, + @ViewBuilder content: () -> Content + ) where Header == EmptyView, Footer == EmptyView { + self.init(padding: padding) { + EmptyView() + } content: { + content() + } footer: { + EmptyView() + } + } + + init( + _ title: LocalizedStringKey, + padding: EdgeInsets = .iceGroupBoxDefaultPadding, + @ViewBuilder content: () -> Content + ) where Header == Text, Footer == EmptyView { + self.init(padding: padding) { + Text(title) + .font(.headline) + } content: { + content() + } + } + init( _ title: LocalizedStringKey, - padding: CGFloat = 10, + padding: CGFloat, @ViewBuilder content: () -> Content ) where Header == Text, Footer == EmptyView { self.init(padding: padding) { @@ -91,12 +164,18 @@ struct IceGroupBox: View { .background { backgroundShape .fill(.quinary) - .overlay { - backgroundShape - .strokeBorder(.quaternary) - } + .strokeBorder(.quaternary) } + .containerShape(backgroundShape) footer } } } + +extension EdgeInsets { + /// The default padding for an ``IceGroupBox``. + static let iceGroupBoxDefaultPadding: EdgeInsets = { + let padding: CGFloat = if #available(macOS 26.0, *) { 12 } else { 10 } + return EdgeInsets(all: padding) + }() +} diff --git a/Ice/UI/IceUI/IceSection.swift b/Ice/UI/IceUI/IceSection.swift index 40f2ec70e..fe2392f31 100644 --- a/Ice/UI/IceUI/IceSection.swift +++ b/Ice/UI/IceUI/IceSection.swift @@ -26,7 +26,7 @@ struct IceSection: View { private var hasDividers: Bool { options.contains(.hasDividers) } init( - spacing: CGFloat = 10, + spacing: CGFloat = .iceSectionDefaultSpacing, options: IceSectionOptions = .default, @ViewBuilder header: () -> Header, @ViewBuilder content: () -> Content, @@ -40,7 +40,7 @@ struct IceSection: View { } init( - spacing: CGFloat = 10, + spacing: CGFloat = .iceSectionDefaultSpacing, options: IceSectionOptions = .default, @ViewBuilder content: () -> Content, @ViewBuilder footer: () -> Footer @@ -55,7 +55,7 @@ struct IceSection: View { } init( - spacing: CGFloat = 10, + spacing: CGFloat = .iceSectionDefaultSpacing, options: IceSectionOptions = .default, @ViewBuilder header: () -> Header, @ViewBuilder content: () -> Content @@ -70,7 +70,7 @@ struct IceSection: View { } init( - spacing: CGFloat = 10, + spacing: CGFloat = .iceSectionDefaultSpacing, options: IceSectionOptions = .default, @ViewBuilder content: () -> Content ) where Header == EmptyView, Footer == EmptyView { @@ -85,7 +85,7 @@ struct IceSection: View { init( _ title: LocalizedStringKey, - spacing: CGFloat = 10, + spacing: CGFloat = .iceSectionDefaultSpacing, options: IceSectionOptions = .default, @ViewBuilder content: () -> Content ) where Header == Text, Footer == EmptyView { @@ -145,3 +145,10 @@ private struct IceSectionLayout: _VariadicView_UnaryViewRoot { } } } + +extension CGFloat { + /// The default spacing for an ``IceSection``. + static let iceSectionDefaultSpacing: CGFloat = { + if #available(macOS 26.0, *) { 11 } else { 10 } + }() +} From 8530421b617164aa2137f69fffc07936fd7eddd0 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Wed, 18 Jun 2025 02:08:11 -0600 Subject: [PATCH 06/80] macOS 26: Work around ControlItem initialization --- Ice/MenuBar/ControlItem/ControlItem.swift | 182 +++++++++++----------- 1 file changed, 90 insertions(+), 92 deletions(-) diff --git a/Ice/MenuBar/ControlItem/ControlItem.swift b/Ice/MenuBar/ControlItem/ControlItem.swift index f75156cd9..935163ab5 100644 --- a/Ice/MenuBar/ControlItem/ControlItem.swift +++ b/Ice/MenuBar/ControlItem/ControlItem.swift @@ -103,26 +103,35 @@ final class ControlItem { self.identifier = identifier self.appState = appState - // This could break in a new macOS release, but we need this constraint in order to be - // able to hide the control item when the `ShowSectionDividers` setting is disabled. A - // previous implementation used the status item's `isVisible` property, which was more - // robust, but would completely remove the control item. With the current set of - // features, we need to be able to accurately retrieve the items for each section, so - // we need the control item to always be present to act as a delimiter. The new solution - // is to remove the constraint that prevents status items from having a length of zero, - // then resize the content view. FIXME: Find a replacement for this. - if - let button = statusItem.button, - let constraints = button.window?.contentView?.constraintsAffectingLayout(for: .horizontal), - let constraint = constraints.first(where: Predicates.controlItemConstraint(button: button)) - { - assert(constraints.filter(Predicates.controlItemConstraint(button: button)).count == 1) - self.constraint = constraint + if let button = statusItem.button { + // This could break in a new macOS release, but we need this constraint in order to be + // able to hide the control item when the `ShowSectionDividers` setting is disabled. A + // previous implementation used the status item's `isVisible` property, which was more + // robust, but would completely remove the control item. With the current set of + // features, we need to be able to accurately retrieve the items for each section, so + // we need the control item to always be present to act as a delimiter. The new solution + // is to remove the constraint that prevents status items from having a length of zero, + // then resize the content view. FIXME: Find a replacement for this. + if + let constraints = button.window?.contentView?.constraintsAffectingLayout(for: .horizontal), + let constraint = constraints.first(where: Predicates.controlItemConstraint(button: button)) + { + assert(constraints.filter(Predicates.controlItemConstraint(button: button)).count == 1) + self.constraint = constraint + } else { + self.constraint = nil + } + + button.target = self + button.action = #selector(performAction) } else { self.constraint = nil } - configureStatusItem() + updateStatusItem(with: state) + Task { + configureCancellables() + } } /// Removes the status item without clearing its stored position. @@ -227,43 +236,6 @@ final class ControlItem { .store(in: &c) if let appState { - appState.settingsManager.generalSettingsManager.$showIceIcon - .receive(on: DispatchQueue.main) - .sink { [weak self] showIceIcon in - guard - let self, - !isSectionDivider - else { - return - } - if showIceIcon { - addToMenuBar() - } else { - removeFromMenuBar() - } - } - .store(in: &c) - - appState.settingsManager.generalSettingsManager.$iceIcon - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self else { - return - } - updateStatusItem(with: state) - } - .store(in: &c) - - appState.settingsManager.generalSettingsManager.$customIceIconIsTemplate - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self else { - return - } - updateStatusItem(with: state) - } - .store(in: &c) - appState.settingsManager.generalSettingsManager.$useIceBar .receive(on: DispatchQueue.main) .sink { [weak self] useIceBar in @@ -281,52 +253,78 @@ final class ControlItem { } .store(in: &c) - appState.settingsManager.advancedSettingsManager.$showSectionDividers - .receive(on: DispatchQueue.main) - .sink { [weak self] shouldShow in - guard - let self, - isSectionDivider, - state == .showItems - else { - return + if identifier == .iceIcon { + appState.settingsManager.generalSettingsManager.$showIceIcon + .combineLatest(statusItem.publisher(for: \.isVisible)) + .removeDuplicates { $0 == $1 } + .receive(on: DispatchQueue.main) + .sink { [weak self] shouldShow, _ in + guard let self else { + return + } + if shouldShow { + addToMenuBar() + } else { + removeFromMenuBar() + } } - isVisible = shouldShow - } - .store(in: &c) + .store(in: &c) - appState.settingsManager.advancedSettingsManager.$enableAlwaysHiddenSection - .receive(on: DispatchQueue.main) - .sink { [weak self] enable in - guard - let self, - identifier == .alwaysHidden - else { - return + appState.settingsManager.generalSettingsManager.$iceIcon + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { + return + } + updateStatusItem(with: state) } - if enable { - addToMenuBar() - } else { - removeFromMenuBar() + .store(in: &c) + + appState.settingsManager.generalSettingsManager.$customIceIconIsTemplate + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { + return + } + updateStatusItem(with: state) } - } - .store(in: &c) - } + .store(in: &c) + } - cancellables = c - } + if identifier == .alwaysHidden { + appState.settingsManager.advancedSettingsManager.$enableAlwaysHiddenSection + .combineLatest(statusItem.publisher(for: \.isVisible)) + .removeDuplicates { $0 == $1 } + .receive(on: DispatchQueue.main) + .sink { [weak self] shouldEnable, _ in + guard let self else { + return + } + if shouldEnable { + addToMenuBar() + } else { + removeFromMenuBar() + } + } + .store(in: &c) + } - /// Sets the initial configuration for the status item. - private func configureStatusItem() { - defer { - configureCancellables() - updateStatusItem(with: state) - } - guard let button = statusItem.button else { - return + if isSectionDivider { + appState.settingsManager.advancedSettingsManager.$showSectionDividers + .receive(on: DispatchQueue.main) + .sink { [weak self] shouldShow in + guard let self else { + return + } + if case .showItems = state { + isVisible = shouldShow + } + } + .store(in: &c) + } } - button.target = self - button.action = #selector(performAction) + + cancellables = c } /// Updates the appearance of the status item using the given hiding state. From ad86802a2d4ec5ee26487e90c574d1fbb5898540 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 20 Jun 2025 04:24:43 -0600 Subject: [PATCH 07/80] macOS 26: Reworks all the way down A big part of this is hopefully a temporary measure. We need an accurate identifier for every item, and the old way just isn't cutting it right now. Items are all owned by the Control Center in macOS 26, and try as I might, I couldn't find a great way to get the _actual_ host apps for the items. Must dig deeper into the mines... Oh yeah, there's also a bunch of random stuff here too that I don't really want to explain. Just know that it probably all fixed something. --- Ice/Bridging/Bridging.swift | 79 ++-- Ice/Bridging/Shims.swift | 7 + Ice/MenuBar/ControlItem/ControlItem.swift | 9 + Ice/MenuBar/MenuBarItems/MenuBarItem.swift | 63 ++-- .../MenuBarItems/MenuBarItemImageCache.swift | 40 +- .../MenuBarItems/MenuBarItemInfo.swift | 134 ++++--- .../MenuBarItems/MenuBarItemManager.swift | 347 ++++++++++-------- Ice/MenuBar/MenuBarSection.swift | 9 - Ice/MenuBar/Search/MenuBarSearchPanel.swift | 26 +- Ice/UI/IceBar/IceBar.swift | 27 +- Ice/UI/LayoutBar/LayoutBarPaddingView.swift | 4 +- Ice/Utilities/Extensions.swift | 11 + 12 files changed, 442 insertions(+), 314 deletions(-) diff --git a/Ice/Bridging/Bridging.swift b/Ice/Bridging/Bridging.swift index 6c1c27748..5cad734b7 100644 --- a/Ice/Bridging/Bridging.swift +++ b/Ice/Bridging/Bridging.swift @@ -56,18 +56,25 @@ extension Bridging { // MARK: - CGSWindow extension Bridging { - /// Returns the frame, specified in screen coordinates, for the - /// window with the specified identifier. + /// Returns the bounds for the window with the specified identifier. /// /// - Parameter windowID: An identifier for a window. - static func getWindowFrame(for windowID: CGWindowID) -> CGRect? { - var rect = CGRect.zero - let result = CGSGetScreenRectForWindow(mainConnectionID, windowID, &rect) - guard result == .success else { - logger.error("CGSGetScreenRectForWindow failed with error \(result.logString)") - return nil + static func getWindowBounds(for windowID: CGWindowID) -> CGRect? { + var bounds = CGRect.zero + if #available(macOS 26.0, *) { + let result = CGSGetWindowBounds(mainConnectionID, windowID, &bounds) + guard result == .success else { + logger.error("CGSGetWindowBounds failed with error \(result.logString)") + return nil + } + } else { + let result = CGSGetScreenRectForWindow(mainConnectionID, windowID, &bounds) + guard result == .success else { + logger.error("CGSGetScreenRectForWindow failed with error \(result.logString)") + return nil + } } - return rect + return bounds } /// Returns the level for the window with the specified identifier. @@ -82,6 +89,40 @@ extension Bridging { } return level } + + /// Returns a Boolean value that indicates whether the window + /// with the given identifier is on the specified space. + /// + /// - Parameters: + /// - windowID: An identifier for a window. + /// - spaceID: An identifier for a space. + static func isWindowOnSpace(_ windowID: CGWindowID, _ spaceID: CGSSpaceID) -> Bool { + let list = getSpaceList(for: windowID, option: .allSpaces) + return list.contains(spaceID) + } + + /// Returns a Boolean value that indicates whether the window + /// with the given identifier is on the current active space. + /// + /// - Parameter windowID: An identifier for a window. + static func isWindowOnActiveSpace(_ windowID: CGWindowID) -> Bool { + let spaceID = getActiveSpaceID() + return isWindowOnSpace(windowID, spaceID) + } + + /// Returns a Boolean value that indicates whether the window + /// with the given identifier is on the specified display. + /// + /// - Parameters: + /// - windowID: An identifier for a window. + /// - displayID: An identifier for a display. + static func isWindowOnDisplay(_ windowID: CGWindowID, _ displayID: CGDirectDisplayID) -> Bool { + if let windowBounds = getWindowBounds(for: windowID) { + let displayBounds = CGDisplayBounds(displayID) + return displayBounds.intersects(windowBounds) + } + return false + } } // MARK: Private Window List Helpers @@ -261,26 +302,6 @@ extension Bridging { return list } - /// Returns a Boolean value that indicates whether the window - /// with the given identifier is on the specified space. - /// - /// - Parameters: - /// - windowID: An identifier for a window. - /// - spaceID: An identifier for a space. - static func isWindowOnSpace(_ windowID: CGWindowID, _ spaceID: CGSSpaceID) -> Bool { - let list = getSpaceList(for: windowID, option: .allSpaces) - return list.contains(spaceID) - } - - /// Returns a Boolean value that indicates whether the window - /// with the given identifier is on the current active space. - /// - /// - Parameter windowID: An identifier for a window. - static func isWindowOnActiveSpace(_ windowID: CGWindowID) -> Bool { - let spaceID = getActiveSpaceID() - return isWindowOnSpace(windowID, spaceID) - } - /// Returns a Boolean value that indicates whether the space /// with the given identifier is fullscreen. /// diff --git a/Ice/Bridging/Shims.swift b/Ice/Bridging/Shims.swift index 0b8694991..eae38b8db 100644 --- a/Ice/Bridging/Shims.swift +++ b/Ice/Bridging/Shims.swift @@ -135,6 +135,13 @@ func CGSGetScreenRectForWindow( _ outRect: inout CGRect ) -> CGError +@_silgen_name("CGSGetWindowBounds") +func CGSGetWindowBounds( + _ cid: CGSConnectionID, + _ wid: CGWindowID, + _ outBounds: inout CGRect +) -> CGError + @_silgen_name("CGSGetWindowLevel") func CGSGetWindowLevel( _ cid: CGSConnectionID, diff --git a/Ice/MenuBar/ControlItem/ControlItem.swift b/Ice/MenuBar/ControlItem/ControlItem.swift index 935163ab5..756256215 100644 --- a/Ice/MenuBar/ControlItem/ControlItem.swift +++ b/Ice/MenuBar/ControlItem/ControlItem.swift @@ -14,6 +14,15 @@ final class ControlItem { case iceIcon = "SItem" case hidden = "HItem" case alwaysHidden = "AHItem" + + /// Legacy menu bar info for the control item with this identifier. + var legacyInfo: MenuBarItemLegacyInfo { + switch self { + case .iceIcon: .iceIcon + case .hidden: .hiddenControlItem + case .alwaysHidden: .alwaysHiddenControlItem + } + } } /// Possible hiding states for control items. diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift index 83086c14c..f08b02b9c 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift @@ -12,6 +12,9 @@ struct MenuBarItem { /// The item's window. let window: WindowInfo + /// The legacy menu bar item info associated with this item. + let legacyInfo: MenuBarItemLegacyInfo + /// The menu bar item info associated with this item. let info: MenuBarItemInfo @@ -37,12 +40,12 @@ struct MenuBarItem { /// A Boolean value that indicates whether the item can be moved. var isMovable: Bool { - info.isMovable + legacyInfo.isMovable } /// A Boolean value that indicates whether the item can be hidden. var canBeHidden: Bool { - info.canBeHidden + legacyInfo.canBeHidden } /// The process identifier of the application that owns the item. @@ -89,36 +92,28 @@ struct MenuBarItem { return bestName } - // Most items will use their computed "best name", but we need - // to handle a few special cases. - return switch info.namespace { + // Most items will use their computed "best name", but we need to + // handle a few special cases for system items. + return switch legacyInfo.namespace { case .passwords, .weather: - // These need more searchable names. - // - // "PasswordsMenuBarExtra" -> "Passwords" - // "WeatherMenu" -> "Weather" - // - // Convert to "Title Case" and take the first word. + // "PasswordsMenuBarExtra" -> "Passwords" + // "WeatherMenu" -> "Weather" String(toTitleCase(bestName).prefix { !$0.isWhitespace }) - case .controlCenter where title == "BentoBox": - bestName // "BentoBox" -> "Control Center" + case .controlCenter where title.hasPrefix("BentoBox"): + bestName case .controlCenter where title == "WiFi": - title // Keep "UpperCamelCase". + title case .controlCenter where title.hasPrefix("Hearing"): // Title of this item was changed to "Hearing_GlowE" in macOS 15.4. String(toTitleCase(title).prefix { $0.isLetter || $0.isNumber }) case .systemUIServer where title.contains("TimeMachine"): - // Title of this item depends on the macOS version. - // - // Sonoma: "TimeMachine.TMMenuExtraHost" - // Sequoia: "TimeMachineMenuExtra.TMMenuExtraHost" - // - // Keep things consistent and replace it. + // Sonoma: "TimeMachine.TMMenuExtraHost" + // Sequoia: "TimeMachineMenuExtra.TMMenuExtraHost" "Time Machine" case .controlCenter, .systemUIServer: - // Most system items are owned by the same couple of apps, so use the - // title instead of the app name. Some are "UpperCamelCase", some are - // dot-separated. Prefix to the first dot and convert to "Title Case". + // Most system items are hosted by one of these two apps. They + // usually have descriptive, but unformatted titles, so we'll do + // some basic formatting ourselves. toTitleCase(title.prefix { $0 != "." }) default: bestName @@ -134,7 +129,7 @@ struct MenuBarItem { /// A string to use for logging purposes. var logString: String { - String(describing: info) + "<\(legacyInfo) (windowID: \(windowID))>" } /// The latest version of the menu bar item, or `nil` if the item @@ -153,12 +148,8 @@ struct MenuBarItem { /// certain that the window is valid. private init(uncheckedItemWindow itemWindow: WindowInfo) { self.window = itemWindow - self.info = MenuBarItemInfo(uncheckedItemWindow: itemWindow) - } - - /// Returns the current frame for the item. - func getCurrentFrame() -> CGRect? { - return Bridging.getWindowFrame(for: windowID) + self.legacyInfo = MenuBarItemLegacyInfo(uncheckedItemWindow: itemWindow) + self.info = MenuBarItemInfo(windowID: itemWindow.windowID) } } @@ -184,8 +175,8 @@ extension MenuBarItem { if let display { let displayBounds = CGDisplayBounds(display) boundsPredicate = { windowID in - if let frame = Bridging.getWindowFrame(for: windowID) { - return displayBounds.intersects(frame) + if let bounds = Bridging.getWindowBounds(for: windowID) { + return displayBounds.intersects(bounds) } return false } @@ -229,9 +220,9 @@ extension MenuBarItem: Hashable { } } -// MARK: - MenuBarItemInfo Unchecked Item Window Initializer +// MARK: - MenuBarItemLegacyInfo Unchecked Item Window Initializer -private extension MenuBarItemInfo { +private extension MenuBarItemLegacyInfo { /// Creates a simplified item from the given window. /// /// This initializer does not perform any checks on the window to ensure that @@ -243,9 +234,9 @@ private extension MenuBarItemInfo { } } -// MARK: - MenuBarItemInfo.Namespace Unchecked Item Window Initializer +// MARK: - MenuBarItemLegacyInfo.Namespace Unchecked Item Window Initializer -private extension MenuBarItemInfo.Namespace { +private extension MenuBarItemLegacyInfo.Namespace { /// Creates a namespace from the given window. /// /// This initializer does not perform any checks on the window to ensure that diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift index 8b13e895e..6fcba2638 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift @@ -112,43 +112,43 @@ final class MenuBarItemImageCache: ObservableObject { let option: CGWindowImageOption = [.boundsIgnoreFraming, .bestResolution] let defaultItemThickness = NSStatusBar.system.thickness * backingScaleFactor - var itemInfos = [CGWindowID: MenuBarItemInfo]() - var itemFrames = [CGWindowID: CGRect]() + var itemInfosDict = [CGWindowID: MenuBarItemInfo]() + var itemBoundsDict = [CGWindowID: CGRect]() var windowIDs = [CGWindowID]() - var frame = CGRect.null + var allBounds = CGRect.null for item in items { let windowID = item.windowID guard - // Use the most up-to-date window frame. - let itemFrame = Bridging.getWindowFrame(for: windowID), - itemFrame.minY == displayBounds.minY + // Use the most up-to-date window bounds. + let itemBounds = Bridging.getWindowBounds(for: windowID), + itemBounds.minY == displayBounds.minY else { continue } - itemInfos[windowID] = item.info - itemFrames[windowID] = itemFrame + itemInfosDict[windowID] = item.info + itemBoundsDict[windowID] = itemBounds windowIDs.append(windowID) - frame = frame.union(itemFrame) + allBounds = allBounds.union(itemBounds) } if let compositeImage = ScreenCapture.captureWindows(windowIDs, option: option), - CGFloat(compositeImage.width) == frame.width * backingScaleFactor + CGFloat(compositeImage.width) == allBounds.width * backingScaleFactor { for windowID in windowIDs { guard - let itemInfo = itemInfos[windowID], - let itemFrame = itemFrames[windowID] + let itemInfo = itemInfosDict[windowID], + let itemBounds = itemBoundsDict[windowID] else { continue } let frame = CGRect( - x: (itemFrame.origin.x - frame.origin.x) * backingScaleFactor, - y: (itemFrame.origin.y - frame.origin.y) * backingScaleFactor, - width: itemFrame.width * backingScaleFactor, - height: itemFrame.height * backingScaleFactor + x: (itemBounds.origin.x - allBounds.origin.x) * backingScaleFactor, + y: (itemBounds.origin.y - allBounds.origin.y) * backingScaleFactor, + width: itemBounds.width * backingScaleFactor, + height: itemBounds.height * backingScaleFactor ) guard let itemImage = compositeImage.cropping(to: frame) else { @@ -162,16 +162,16 @@ final class MenuBarItemImageCache: ObservableObject { for windowID in windowIDs { guard - let itemInfo = itemInfos[windowID], - let itemFrame = itemFrames[windowID] + let itemInfo = itemInfosDict[windowID], + let itemBounds = itemBoundsDict[windowID] else { continue } let frame = CGRect( x: 0, - y: ((itemFrame.height * backingScaleFactor) / 2) - (defaultItemThickness / 2), - width: itemFrame.width * backingScaleFactor, + y: ((itemBounds.height * backingScaleFactor) / 2) - (defaultItemThickness / 2), + width: itemBounds.width * backingScaleFactor, height: defaultItemThickness ) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemInfo.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemInfo.swift index 59f93472b..87f677ddb 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemInfo.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemInfo.swift @@ -3,25 +3,56 @@ // Ice // +import CoreGraphics + +// MARK: - MenuBarItemInfo + /// A simplified version of a menu bar item. +/// +/// This type acts as a partial replacement for the original `MenuBarItemInfo` +/// type (now called ``MenuBarItemLegacyInfo``). Its purpose is to help regain +/// some of the functionality that was broken in macOS 26 Developer Beta 1. +/// +/// A value of this type functions as a unique identifier for a single menu +/// bar item, and is mainly used for caching and comparison. Currently, the +/// only information this type contains is a CGWindowID corresponding to a +/// menu bar item's window, meaning that there are still instances where the +/// legacy type is needed. However, the hope is to build up this new type, +/// so that it can eventually replace the original. struct MenuBarItemInfo: Hashable, CustomStringConvertible { - /// The namespace of the item. + /// The item's window identifier. + let windowID: CGWindowID + + /// A textual representation of the item. + var description: String { + String(describing: windowID) + } +} + +// MARK: - MenuBarItemLegacyInfo + +/// A simplified version of a menu bar item that was used as the primary way +/// to identify menu bar items until macOS 26 (Developer Beta 1). +/// +/// See ``MenuBarItemInfo`` documentation for more details. +struct MenuBarItemLegacyInfo: Hashable, CustomStringConvertible { + /// The namespace of the info's item. let namespace: Namespace - /// The title of the item. + /// The title of the info's item. let title: String - /// A Boolean value that indicates whether the item can be moved. + /// A Boolean value that indicates whether the info's item can be moved. var isMovable: Bool { - !MenuBarItemInfo.immovableItems.contains(self) + !MenuBarItemLegacyInfo.immovableItems.contains(self) } - /// A Boolean value that indicates whether the item can be hidden. + /// A Boolean value that indicates whether the info's item can be hidden. var canBeHidden: Bool { - !MenuBarItemInfo.nonHideableItems.contains(self) + !MenuBarItemLegacyInfo.nonHideableItems.contains(self) } - /// A string representation of the item. + /// A string representation of the info. var stringValue: String { var result = namespace.rawValue if !title.isEmpty { @@ -30,18 +61,18 @@ struct MenuBarItemInfo: Hashable, CustomStringConvertible { return result } - /// A textual representation of the item. + /// A textual representation of the info. var description: String { stringValue } - /// Creates an item with the given namespace and title. + /// Creates info with the given namespace and title. init(namespace: Namespace, title: String) { self.namespace = namespace self.title = title } - /// Creates an item for the control item with the given identifier. + /// Creates info for the control item with the given identifier. private init(controlItem identifier: ControlItem.Identifier) { if #available(macOS 26.0, *) { self.init(namespace: .controlCenter, title: identifier.rawValue) @@ -51,76 +82,77 @@ struct MenuBarItemInfo: Hashable, CustomStringConvertible { } } -// MARK: MenuBarItemInfo Constants +// MARK: MenuBarItemLegacyInfo Constants -extension MenuBarItemInfo { +extension MenuBarItemLegacyInfo { // MARK: Special Item Lists - /// An array of items whose movement is prevented by macOS. + /// An array of infos for items whose movement is prevented by macOS. static let immovableItems = [clock, siri, controlCenter] // FIXME: At some point, Apple made the "MusicRecognition" item hideable. // We need to determine which version of macOS first had this change, and // conditionally exclude the item from this list based on that. // - /// An array of items that can be moved, but cannot be hidden. + /// An array of infos for items that can be moved, but cannot be hidden. static let nonHideableItems = [audioVideoModule, faceTime, musicRecognition, screenCaptureUI] - /// An array of items representing the control items for all sections. - static let controlItems = MenuBarSection.Name.allCases.map { $0.controlItemInfo } + /// An array of infos for items representing Ice's control items. + static let controlItems = ControlItem.Identifier.allCases.map { $0.legacyInfo } // MARK: Control Items - /// The control item for the visible section. - static let iceIcon = MenuBarItemInfo(controlItem: .iceIcon) + /// Info for the control item for the visible section. + static let iceIcon = MenuBarItemLegacyInfo(controlItem: .iceIcon) - /// The control item for the hidden section. - static let hiddenControlItem = MenuBarItemInfo(controlItem: .hidden) + /// Info for the control item for the hidden section. + static let hiddenControlItem = MenuBarItemLegacyInfo(controlItem: .hidden) - /// The control item for the always-hidden section. - static let alwaysHiddenControlItem = MenuBarItemInfo(controlItem: .alwaysHidden) + /// Info for the control item for the always-hidden section. + static let alwaysHiddenControlItem = MenuBarItemLegacyInfo(controlItem: .alwaysHidden) // MARK: Other Items - /// The "Clock" item. - static let clock = MenuBarItemInfo(namespace: .controlCenter, title: "Clock") + /// Info for the "Clock" item. + static let clock = MenuBarItemLegacyInfo(namespace: .controlCenter, title: "Clock") - /// The "Siri" item. - static let siri: MenuBarItemInfo = { + /// Info for the "Siri" item. + static let siri: MenuBarItemLegacyInfo = { if #available(macOS 26.0, *) { - MenuBarItemInfo(namespace: .controlCenter, title: "Siri") + MenuBarItemLegacyInfo(namespace: .controlCenter, title: "Siri") } else { - MenuBarItemInfo(namespace: .systemUIServer, title: "Siri") + MenuBarItemLegacyInfo(namespace: .systemUIServer, title: "Siri") } }() - /// The "Control Center" item. - static let controlCenter: MenuBarItemInfo = { + /// Info for the "Control Center" item. + static let controlCenter: MenuBarItemLegacyInfo = { if #available(macOS 26.0, *) { - MenuBarItemInfo(namespace: .controlCenter, title: "BentoBox-0") + MenuBarItemLegacyInfo(namespace: .controlCenter, title: "BentoBox-0") } else { - MenuBarItemInfo(namespace: .controlCenter, title: "BentoBox") + MenuBarItemLegacyInfo(namespace: .controlCenter, title: "BentoBox") } }() - /// The item that appears in the menu bar while the screen or system + /// Info for the item that appears in the menu bar while the screen or system /// audio is being recorded. - static let audioVideoModule = MenuBarItemInfo(namespace: .controlCenter, title: "AudioVideoModule") + static let audioVideoModule = MenuBarItemLegacyInfo(namespace: .controlCenter, title: "AudioVideoModule") - /// The "FaceTime" item. - static let faceTime = MenuBarItemInfo(namespace: .controlCenter, title: "FaceTime") + /// Info for the "FaceTime" item. + static let faceTime = MenuBarItemLegacyInfo(namespace: .controlCenter, title: "FaceTime") - /// The "MusicRecognition" (a.k.a. "Shazam") item. - static let musicRecognition = MenuBarItemInfo(namespace: .controlCenter, title: "MusicRecognition") + /// Info for the "MusicRecognition" (a.k.a. "Shazam") item. + static let musicRecognition = MenuBarItemLegacyInfo(namespace: .controlCenter, title: "MusicRecognition") - /// The "stop recording" item that appears in the menu bar during screen + // FIXME: How do we reference this item in macOS 26? + /// Info for the "stop recording" item that appears in the menu bar during screen /// recordings started by the macOS "Screenshot" tool. - static let screenCaptureUI = MenuBarItemInfo(namespace: .screenCaptureUI, title: "Item-0") + static let screenCaptureUI = MenuBarItemLegacyInfo(namespace: .screenCaptureUI, title: "Item-0") } -// MARK: MenuBarItemInfo: Codable -extension MenuBarItemInfo: Codable { +// MARK: MenuBarItemLegacyInfo: Codable +extension MenuBarItemLegacyInfo: Codable { init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() let string = try container.decode(String.self) @@ -151,9 +183,9 @@ extension MenuBarItemInfo: Codable { } } -// MARK: - MenuBarItemInfo.Namespace +// MARK: - MenuBarItemLegacyInfo.Namespace -extension MenuBarItemInfo { +extension MenuBarItemLegacyInfo { /// A type that represents a menu bar item namespace. struct Namespace: Codable, Hashable, RawRepresentable, CustomStringConvertible { /// Private representation of a namespace. @@ -178,8 +210,8 @@ extension MenuBarItemInfo { rawValue } - /// An Optional representation of the namespace that converts the ``null`` - /// namespace to `nil`. + /// An Optional representation of the namespace that converts + /// the ``null`` namespace to `nil`. var optional: Namespace? { switch kind { case .null: nil @@ -208,18 +240,18 @@ extension MenuBarItemInfo { /// Creates a namespace with the given optional value. /// - /// If the provided value is `nil`, the namespace is initialized to the ``null`` - /// namespace. + /// If the provided value is `nil`, the namespace is initialized + /// to the ``null`` namespace. /// - /// - Parameter value: An optional value to initialize the namespace with. + /// - Parameter value: An optional value for the namespace. init(_ value: String?) { self = value.map { Namespace($0) } ?? .null } } } -// MARK: MenuBarItemInfo.Namespace Constants -extension MenuBarItemInfo.Namespace { +// MARK: MenuBarItemLegacyInfo.Namespace Constants +extension MenuBarItemLegacyInfo.Namespace { /// The namespace for menu bar items owned by Ice. static let ice = Self(Constants.bundleIdentifier) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index a5b7b288b..3ec73ecf5 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -228,7 +228,7 @@ extension MenuBarItemManager { for (item, destination) in tempShownItems { switch destination { case .leftOfItem(let targetItem): - switch targetItem.info { + switch targetItem.legacyInfo { case .hiddenControlItem: cache[.hidden].append(item) case .alwaysHiddenControlItem: @@ -243,7 +243,7 @@ extension MenuBarItemManager { } } case .rightOfItem(let targetItem): - switch targetItem.info { + switch targetItem.legacyInfo { case .hiddenControlItem: cache[.visible].insert(item, at: 0) case .alwaysHiddenControlItem: @@ -349,8 +349,8 @@ extension MenuBarItemManager { /// A menu bar item event operation timed out. case eventOperationTimeout - /// A menu bar item frame check timed out. - case frameCheckTimeout + /// A menu bar item bounds check timed out. + case boundsCheckTimeout /// An operation timed out. case otherTimeout @@ -366,7 +366,7 @@ extension MenuBarItemManager { case .invalidItem: "invalidItem" case .notMovable: "notMovable" case .eventOperationTimeout: "eventOperationTimeout" - case .frameCheckTimeout: "frameCheckTimeout" + case .boundsCheckTimeout: "boundsCheckTimeout" case .otherTimeout: "otherTimeout" } } @@ -402,8 +402,8 @@ extension MenuBarItemManager { "\"\(item.displayName)\" is not movable" case .eventOperationTimeout: "Event operation timed out for \"\(item.displayName)\"" - case .frameCheckTimeout: - "Frame check timed out for \"\(item.displayName)\"" + case .boundsCheckTimeout: + "Bounds check timed out for \"\(item.displayName)\"" case .otherTimeout: "Operation timed out for \"\(item.displayName)\"" } @@ -432,6 +432,13 @@ extension MenuBarItemManager { // MARK: - Async Waiters extension MenuBarItemManager { + /// Use this to pad out event operations, if needed. + /// + /// - Parameter duration: The duration to wait. Defaults to 20ms. + private func eventSleep(for duration: Duration = .milliseconds(20)) async { + try? await Task.sleep(for: duration) + } + /// Waits asynchronously for the given operation to complete. /// /// - Parameters: @@ -555,15 +562,15 @@ extension MenuBarItemManager { } } - /// Returns the current frame for the given item. + /// Returns the current bounds for the given item. /// - /// - Parameter item: The item to return the current frame for. - private func getCurrentFrame(for item: MenuBarItem) -> CGRect? { - guard let frame = Bridging.getWindowFrame(for: item.window.windowID) else { - Logger.itemManager.error("Couldn't get current frame for \(item.logString)") + /// - Parameter item: The item to return the current bounds for. + private func getCurrentBounds(for item: MenuBarItem) -> CGRect? { + guard let bounds = Bridging.getWindowBounds(for: item.windowID) else { + Logger.itemManager.error("Couldn't get current bounds for \(item.logString)") return nil } - return frame + return bounds } /// Returns the end point for moving an item to the given destination. @@ -572,15 +579,15 @@ extension MenuBarItemManager { private func getEndPoint(for destination: MoveDestination) throws -> CGPoint { switch destination { case .leftOfItem(let targetItem): - guard let currentFrame = getCurrentFrame(for: targetItem) else { + guard let currentBounds = getCurrentBounds(for: targetItem) else { throw EventError(code: .invalidItem, item: targetItem) } - return CGPoint(x: currentFrame.minX, y: currentFrame.midY) + return CGPoint(x: currentBounds.minX, y: currentBounds.midY) case .rightOfItem(let targetItem): - guard let currentFrame = getCurrentFrame(for: targetItem) else { + guard let currentBounds = getCurrentBounds(for: targetItem) else { throw EventError(code: .invalidItem, item: targetItem) } - return CGPoint(x: currentFrame.maxX, y: currentFrame.midY) + return CGPoint(x: currentBounds.maxX, y: currentBounds.midY) } } @@ -589,10 +596,10 @@ extension MenuBarItemManager { /// /// - Parameter item: The item to return the fallback point for. private func getFallbackPoint(for item: MenuBarItem) throws -> CGPoint { - guard let currentFrame = getCurrentFrame(for: item) else { + guard let currentBounds = getCurrentBounds(for: item) else { throw EventError(code: .invalidItem, item: item) } - return CGPoint(x: currentFrame.midX, y: currentFrame.midY) + return CGPoint(x: currentBounds.midX, y: currentBounds.midY) } /// Returns the target item for the given destination. @@ -611,20 +618,20 @@ extension MenuBarItemManager { /// - item: The item to check the position of. /// - destination: The destination to compare the item's position against. private func itemHasCorrectPosition(item: MenuBarItem, for destination: MoveDestination) throws -> Bool { - guard let currentFrame = getCurrentFrame(for: item) else { + guard let currentBounds = getCurrentBounds(for: item) else { throw EventError(code: .invalidItem, item: item) } switch destination { case .leftOfItem(let targetItem): - guard let currentTargetFrame = getCurrentFrame(for: targetItem) else { + guard let currentTargetBounds = getCurrentBounds(for: targetItem) else { throw EventError(code: .invalidItem, item: targetItem) } - return currentFrame.maxX == currentTargetFrame.minX + return currentBounds.maxX == currentTargetBounds.minX case .rightOfItem(let targetItem): - guard let currentTargetFrame = getCurrentFrame(for: targetItem) else { + guard let currentTargetBounds = getCurrentBounds(for: targetItem) else { throw EventError(code: .invalidItem, item: targetItem) } - return currentFrame.minX == currentTargetFrame.maxX + return currentBounds.minX == currentTargetBounds.maxX } } @@ -834,59 +841,59 @@ extension MenuBarItemManager { } /// Does a lot of weird magic to make a menu bar item receive an event, then - /// waits for the frame of the given menu bar item to change before returning. + /// waits for the bounds of the given menu bar item to change before returning. /// /// - Parameters: /// - event: The event to send. /// - firstLocation: The first location to send the event to. /// - secondLocation: The second location to send the event to. - /// - item: The item whose frame should be observed. + /// - item: The item whose bounds should be observed. private func scrombleEvent( _ event: CGEvent, from firstLocation: EventTap.Location, to secondLocation: EventTap.Location, - waitingForFrameChangeOf item: MenuBarItem + waitingForBoundsChangeOf item: MenuBarItem ) async throws { - guard let currentFrame = getCurrentFrame(for: item) else { + guard let currentBounds = getCurrentBounds(for: item) else { try await scrombleEvent(event, from: firstLocation, to: secondLocation, item: item) - Logger.itemManager.warning("Couldn't get menu bar item frame for \(item.logString), so using fixed delay") + Logger.itemManager.warning("Couldn't get menu bar item bounds for \(item.logString), so using fixed delay") // This will be slow, but subsequent events will have a better chance of succeeding. - try await Task.sleep(for: .milliseconds(50)) + try await Task.sleep(for: .milliseconds(100)) return } try await scrombleEvent(event, from: firstLocation, to: secondLocation, item: item) - try await waitForFrameChange(of: item, initialFrame: currentFrame, timeout: .milliseconds(50)) + try await waitForBoundsChange(of: item, initialBounds: currentBounds, timeout: .milliseconds(100)) } - /// Waits for a menu bar item's frame to change from an initial frame. + /// Waits for a menu bar item's bounds to change from an initial value. /// /// - Parameters: - /// - item: The item whose frame should be observed. - /// - initialFrame: An initial frame to compare the item's frame against. + /// - item: The item whose bounds should be observed. + /// - initialBounds: An initial value to compare the item's bounds against. /// - timeout: The amount of time to wait before throwing a timeout error. - private func waitForFrameChange(of item: MenuBarItem, initialFrame: CGRect, timeout: Duration) async throws { - struct FrameCheckCancellationError: Error { } + private func waitForBoundsChange(of item: MenuBarItem, initialBounds: CGRect, timeout: Duration) async throws { + struct BoundsCheckCancellationError: Error { } - let frameCheckTask = Task(timeout: timeout) { + let boundsCheckTask = Task(timeout: timeout) { while true { try Task.checkCancellation() - guard let currentFrame = await self.getCurrentFrame(for: item) else { - throw FrameCheckCancellationError() + guard let currentBounds = await self.getCurrentBounds(for: item) else { + throw BoundsCheckCancellationError() } - if currentFrame != initialFrame { - Logger.itemManager.debug("Menu bar item frame for \(item.logString) has changed to \(NSStringFromRect(currentFrame))") + if currentBounds != initialBounds { + Logger.itemManager.debug("Menu bar item bounds for \(item.logString) changed to \(NSStringFromRect(currentBounds))") return } } } do { - try await frameCheckTask.value - } catch is FrameCheckCancellationError { - Logger.itemManager.warning("Menu bar item frame check for \(item.logString) was cancelled, so using fixed delay") + try await boundsCheckTask.value + } catch is BoundsCheckCancellationError { + Logger.itemManager.warning("Menu bar item bounds check for \(item.logString) was cancelled, so using fixed delay") // This will be slow, but subsequent events will have a better chance of succeeding. - try await Task.sleep(for: .milliseconds(50)) + try await Task.sleep(for: .milliseconds(100)) } catch is TaskTimeoutError { - throw EventError(code: .frameCheckTimeout, item: item) + throw EventError(code: .boundsCheckTimeout, item: item) } } @@ -914,21 +921,23 @@ extension MenuBarItemManager { guard let source = CGEventSource(stateID: .hidSystemState) else { throw EventError(code: .invalidEventSource, item: item) } - guard let currentFrame = getCurrentFrame(for: item) else { + guard let currentBounds = getCurrentBounds(for: item) else { throw EventError(code: .invalidItem, item: item) } + let wakePoint = CGPoint(x: currentBounds.midX, y: currentBounds.midY) + guard let mouseDownEvent = CGEvent.menuBarItemEvent( type: .move(.leftMouseDown), - location: CGPoint(x: currentFrame.midX, y: currentFrame.midY), + location: wakePoint, item: item, pid: item.ownerPID, source: source ), let mouseUpEvent = CGEvent.menuBarItemEvent( type: .move(.leftMouseUp), - location: CGPoint(x: currentFrame.midX, y: currentFrame.midY), + location: wakePoint, item: item, pid: item.ownerPID, source: source @@ -937,18 +946,23 @@ extension MenuBarItemManager { throw EventError(code: .eventCreationFailure, item: item) } - try await scrombleEvent( - mouseDownEvent, - from: .pid(item.ownerPID), - to: .sessionEventTap, - item: item - ) - try await scrombleEvent( - mouseUpEvent, - from: .pid(item.ownerPID), - to: .sessionEventTap, - item: item - ) + let eventTask = Task { + try await scrombleEvent( + mouseDownEvent, + from: .pid(item.ownerPID), + to: .sessionEventTap, + item: item + ) + try await scrombleEvent( + mouseUpEvent, + from: .pid(item.ownerPID), + to: .sessionEventTap, + item: item + ) + } + let result = await eventTask.result + await eventSleep() + try result.get() } /// Moves a menu bar item to the given destination, without restoring the mouse @@ -1018,23 +1032,28 @@ extension MenuBarItemManager { mouseDownEvent, from: .pid(item.ownerPID), to: .sessionEventTap, - waitingForFrameChangeOf: item + waitingForBoundsChangeOf: item ) try await scrombleEvent( mouseUpEvent, from: .pid(item.ownerPID), to: .sessionEventTap, - waitingForFrameChangeOf: item + waitingForBoundsChangeOf: item ) } catch { do { - Logger.itemManager.debug("Posting fallback event for moving \(item.logString)") + let eventTask = Task { + Logger.itemManager.debug("Posting fallback event for moving \(item.logString)") + try await postEventAndWaitToReceive( + fallbackEvent, + to: .sessionEventTap, + item: item + ) + } + let result = await eventTask.result + await eventSleep() // Catch this, as we still want to throw the existing error if the fallback fails. - try await postEventAndWaitToReceive( - fallbackEvent, - to: .sessionEventTap, - item: item - ) + try result.get() } catch { Logger.itemManager.error("Failed to post fallback event for moving \(item.logString)") } @@ -1078,7 +1097,7 @@ extension MenuBarItemManager { guard let cursorLocation = MouseCursor.locationCoreGraphics else { throw EventError(code: .invalidCursorLocation, item: item) } - guard let initialFrame = getCurrentFrame(for: item) else { + guard let initialBounds = getCurrentBounds(for: item) else { throw EventError(code: .invalidItem, item: item) } @@ -1099,10 +1118,10 @@ extension MenuBarItemManager { for n in 1...5 { do { try await moveItemWithoutRestoringMouseLocation(item, to: destination) - guard let newFrame = getCurrentFrame(for: item) else { + guard let newBounds = getCurrentBounds(for: item) else { throw EventError(code: .invalidItem, item: item) } - if newFrame != initialFrame { + if newBounds != initialBounds { Logger.itemManager.info("Successfully moved \(item.logString)") break } else { @@ -1129,7 +1148,14 @@ extension MenuBarItemManager { defer { itemMoveCount -= 1 } - try await move(item: item, to: destination) + + do { + try await move(item: item, to: destination) + } catch { + await eventSleep() + throw error + } + let waitTask = Task(timeout: timeout) { while true { try Task.checkCancellation() @@ -1138,6 +1164,7 @@ extension MenuBarItemManager { } } } + do { try await waitTask.value } catch is TaskTimeoutError { @@ -1157,34 +1184,35 @@ extension MenuBarItemManager { guard let cursorLocation = MouseCursor.locationCoreGraphics else { throw EventError(code: .invalidCursorLocation, item: item) } - guard let currentFrame = getCurrentFrame(for: item) else { + guard let currentBounds = getCurrentBounds(for: item) else { throw EventError(code: .invalidItem, item: item) } - let buttonStates = mouseButton.buttonStates - let clickPoint = CGPoint(x: currentFrame.midX, y: currentFrame.midY) + let clickPoint = CGPoint(x: currentBounds.midX, y: currentBounds.midY) + let mouseTypes: (down: CGEventType, up: CGEventType) = switch mouseButton { + case .left: (.leftMouseDown, .leftMouseUp) + case .right: (.rightMouseDown, .rightMouseUp) + default: (.otherMouseDown, .otherMouseUp) + } guard - let mouseDownEvent = CGEvent.menuBarItemEvent( - type: .click(buttonStates.down), - location: clickPoint, - item: item, - pid: item.ownerPID, - source: source + let mouseDownEvent = CGEvent( + mouseEventSource: source, + mouseType: mouseTypes.down, + mouseCursorPosition: clickPoint, + mouseButton: mouseButton ), - let mouseUpEvent = CGEvent.menuBarItemEvent( - type: .click(buttonStates.up), - location: clickPoint, - item: item, - pid: item.ownerPID, - source: source + let mouseUpEvent = CGEvent( + mouseEventSource: source, + mouseType: mouseTypes.up, + mouseCursorPosition: clickPoint, + mouseButton: mouseButton ), - let fallbackEvent = CGEvent.menuBarItemEvent( - type: .click(buttonStates.up), - location: clickPoint, - item: item, - pid: item.ownerPID, - source: source + let fallbackEvent = CGEvent( + mouseEventSource: source, + mouseType: mouseTypes.up, + mouseCursorPosition: clickPoint, + mouseButton: mouseButton ) else { throw EventError(code: .eventCreationFailure, item: item) @@ -1221,13 +1249,18 @@ extension MenuBarItemManager { ) } catch { do { - Logger.itemManager.debug("Posting fallback event for clicking \(item.logString)") + let eventTask = Task { + Logger.itemManager.debug("Posting fallback event for clicking \(item.logString)") + try await postEventAndWaitToReceive( + fallbackEvent, + to: .sessionEventTap, + item: item + ) + } + let result = await eventTask.result + await eventSleep() // Catch this, as we still want to throw the existing error if the fallback fails. - try await postEventAndWaitToReceive( - fallbackEvent, - to: .sessionEventTap, - item: item - ) + try result.get() } catch { Logger.itemManager.error("Failed to post fallback event for clicking \(item.logString)") } @@ -1281,14 +1314,17 @@ extension MenuBarItemManager { /// clicked once movement is finished. /// - mouseButton: The mouse button of the click. func tempShowItem(_ item: MenuBarItem, clickWhenFinished: Bool, mouseButton: CGMouseButton) { - if - let latest = item.latest, - latest.isOnScreen - { + guard let screen = NSScreen.main else { + return + } + + let displayID = screen.displayID + + if Bridging.isWindowOnDisplay(item.windowID, displayID) { if clickWhenFinished { Task { do { - try await click(item: latest, with: mouseButton) + try await click(item: item, with: mouseButton) } catch { Logger.itemManager.error("ERROR: \(error)") } @@ -1299,8 +1335,7 @@ extension MenuBarItemManager { guard let appState, - let screen = NSScreen.main, - let applicationMenuFrame = appState.menuBarManager.getApplicationMenuFrame(for: screen.displayID) + let applicationMenuFrame = appState.menuBarManager.getApplicationMenuFrame(for: displayID) else { Logger.itemManager.warning("No application menu frame, so not showing \(item.logString)") return @@ -1316,11 +1351,17 @@ extension MenuBarItemManager { } // Remove all items up to the hidden control item. - items.trimPrefix { $0.info != .hiddenControlItem } + items.trimPrefix { $0.legacyInfo != .hiddenControlItem } // Remove the hidden control item. items.removeFirst() + // Remove all offscreen items. - items.trimPrefix { !$0.isOnScreen } + if #available(macOS 26.0, *) { + // TODO: isOnScreen doesn't work properly as of macOS 26 Developer Beta 1. Remove this if/when it works again. + items.trimPrefix { !Bridging.isWindowOnDisplay($0.windowID, displayID) } + } else { + items.trimPrefix { !$0.isOnScreen } + } let maxX = if let rightArea = screen.auxiliaryTopRightArea { max(rightArea.minX + 20, applicationMenuFrame.maxX) @@ -1338,42 +1379,65 @@ extension MenuBarItemManager { return } - let initialWindows = WindowInfo.getOnScreenWindows() + let contextTask = Task { + try await slowMove(item: item, to: .leftOfItem(targetItem)) + await eventSleep() - Task { - if clickWhenFinished { - do { - try await slowMove(item: item, to: .leftOfItem(targetItem)) - try await click(item: item, with: mouseButton) - } catch { - Logger.itemManager.error("ERROR: \(error)") - } + let context: TempShownItemContext + + if #available(macOS 26.0, *) { + await eventSleep() + try await click(item: item, with: mouseButton) + await eventSleep() + + // FIXME: Shown interface check is broken on macOS 26 (at least as of Developer Beta 1). Probably needs a significant rework. + context = TempShownItemContext( + info: item.info, + returnDestination: destination, + shownInterfaceWindow: nil + ) } else { - do { - try await move(item: item, to: .leftOfItem(targetItem)) - } catch { - Logger.itemManager.error("ERROR: \(error)") - } - } + if clickWhenFinished { + let beforeWindows = WindowInfo.getOnScreenWindows() - try? await Task.sleep(for: .milliseconds(100)) + await eventSleep() + try await click(item: item, with: mouseButton) + await eventSleep(for: .milliseconds(100)) - let currentWindows = WindowInfo.getOnScreenWindows() + let afterWindows = WindowInfo.getOnScreenWindows() + + let shownInterfaceWindow = afterWindows.first { afterWindow in + afterWindow.ownerPID == item.ownerPID && + !beforeWindows.contains { beforeWindow in + afterWindow.windowID == beforeWindow.windowID + } + } - let shownInterfaceWindow = currentWindows.first { currentWindow in - currentWindow.ownerPID == item.ownerPID && - !initialWindows.contains { initialWindow in - currentWindow.windowID == initialWindow.windowID + context = TempShownItemContext( + info: item.info, + returnDestination: destination, + shownInterfaceWindow: shownInterfaceWindow + ) + } else { + context = TempShownItemContext( + info: item.info, + returnDestination: destination, + shownInterfaceWindow: nil + ) } } - let context = TempShownItemContext( - info: item.info, - returnDestination: destination, - shownInterfaceWindow: shownInterfaceWindow - ) - tempShownItemContexts.append(context) - runTempShownItemTimer(for: appState.settingsManager.advancedSettingsManager.tempShowInterval) + return context + } + + Task { + do { + let context = try await contextTask.value + tempShownItemContexts.append(context) + runTempShownItemTimer(for: appState.settingsManager.advancedSettingsManager.tempShowInterval) + } catch { + Logger.itemManager.error("ERROR: \(error)") + } } } @@ -1408,22 +1472,17 @@ extension MenuBarItemManager { let items = MenuBarItem.getMenuBarItems(onScreenOnly: false, activeSpaceOnly: true) - MouseCursor.hide() - - defer { - MouseCursor.show() - } - while let context = tempShownItemContexts.popLast() { guard let item = items.first(where: { $0.info == context.info }) else { continue } do { - try await move(item: item, to: context.returnDestination) + try await slowMove(item: item, to: context.returnDestination) } catch { Logger.itemManager.error("Failed to rehide \(item.logString) (error: \(error))") failedContexts.append(context) } + await eventSleep() } if failedContexts.isEmpty { diff --git a/Ice/MenuBar/MenuBarSection.swift b/Ice/MenuBar/MenuBarSection.swift index 58bc061d5..a410318e0 100644 --- a/Ice/MenuBar/MenuBarSection.swift +++ b/Ice/MenuBar/MenuBarSection.swift @@ -31,15 +31,6 @@ final class MenuBarSection { case .alwaysHidden: "always-hidden section" } } - - /// Information for the section's corresponding control item. - var controlItemInfo: MenuBarItemInfo { - switch self { - case .visible: .iceIcon - case .hidden: .hiddenControlItem - case .alwaysHidden: .alwaysHiddenControlItem - } - } } /// The name of the section. diff --git a/Ice/MenuBar/Search/MenuBarSearchPanel.swift b/Ice/MenuBar/Search/MenuBarSearchPanel.swift index 1730cd8c3..32a63df0d 100644 --- a/Ice/MenuBar/Search/MenuBarSearchPanel.swift +++ b/Ice/MenuBar/Search/MenuBarSearchPanel.swift @@ -105,7 +105,7 @@ final class MenuBarSearchPanel: NSPanel { await appState.imageCache.updateCache() } - let hostingView = MenuBarSearchHostingView(appState: appState, panel: self) + let hostingView = MenuBarSearchHostingView(appState: appState, displayID: screen.displayID, panel: self) hostingView.setFrameSize(hostingView.intrinsicContentSize) setFrame(hostingView.frame, display: true) @@ -150,13 +150,17 @@ private final class MenuBarSearchHostingView: NSHostingView { init( appState: AppState, + displayID: CGDirectDisplayID, panel: MenuBarSearchPanel ) { super.init( - rootView: MenuBarSearchContentView(closePanel: { [weak panel] in panel?.close() }) - .environmentObject(appState.itemManager) - .environmentObject(appState.imageCache) - .erasedToAnyView() + rootView: MenuBarSearchContentView( + displayID: displayID, + closePanel: { [weak panel] in panel?.close() } + ) + .environmentObject(appState.itemManager) + .environmentObject(appState.imageCache) + .erasedToAnyView() ) } @@ -187,6 +191,7 @@ private struct MenuBarSearchContentView: View { private let fuse = Fuse(threshold: 0.5) + let displayID: CGDirectDisplayID let closePanel: () -> Void var body: some View { @@ -223,7 +228,7 @@ private struct MenuBarSearchContentView: View { let selection, let item = menuBarItem(for: selection) { - ShowItemButton(item: item) { + ShowItemButton(item: item, displayID: displayID) { performAction(for: item) } } @@ -369,12 +374,17 @@ private struct SettingsButton: View { private struct ShowItemButton: View { let item: MenuBarItem + let displayID: CGDirectDisplayID let action: () -> Void + private var isOnDisplay: Bool { + Bridging.isWindowOnDisplay(item.windowID, displayID) + } + var body: some View { BottomBarButton(action: action) { HStack { - Text(item.isOnScreen ? "Click item" : "Show item") + Text(isOnDisplay ? "Click item" : "Show item") .padding(.horizontal, 5) Image(systemName: "return") @@ -422,7 +432,7 @@ private struct MenuBarSearchItemView: View { } private var appIcon: NSImage? { - if item.info.namespace == .systemUIServer { + if item.legacyInfo.namespace == .systemUIServer { controlCenterIcon } else { item.owningApplication?.icon diff --git a/Ice/UI/IceBar/IceBar.swift b/Ice/UI/IceBar/IceBar.swift index 0bdad4748..222632ab5 100644 --- a/Ice/UI/IceBar/IceBar.swift +++ b/Ice/UI/IceBar/IceBar.swift @@ -136,14 +136,14 @@ final class IceBarPanel: NSPanel { guard lowerBound <= upperBound, let iceIcon = appState.itemManager.itemCache.allItems.first(matching: .iceIcon), - // Bridging.getWindowFrame is more reliable than ControlItem.windowFrame, + // Bridging.getWindowBounds is more reliable than ControlItem.windowFrame, // i.e. if the control item is offscreen. - let itemFrame = Bridging.getWindowFrame(for: iceIcon.windowID) + let itemBounds = Bridging.getWindowBounds(for: iceIcon.windowID) else { return originForRightOfScreen } - return CGPoint(x: (itemFrame.midX - frame.width / 2).clamped(to: lowerBound...upperBound), y: originY) + return CGPoint(x: (itemBounds.midX - frame.width / 2).clamped(to: lowerBound...upperBound), y: originY) } } @@ -165,9 +165,7 @@ final class IceBarPanel: NSPanel { await appState.imageCache.updateCache() } - contentView = IceBarHostingView(appState: appState, colorManager: colorManager, screen: screen, section: section) { [weak self] in - self?.close() - } + contentView = IceBarHostingView(appState: appState, colorManager: colorManager, screen: screen, section: section) updateOrigin(for: screen) @@ -199,11 +197,10 @@ private final class IceBarHostingView: NSHostingView { appState: AppState, colorManager: IceBarColorManager, screen: NSScreen, - section: MenuBarSection.Name, - closePanel: @escaping () -> Void + section: MenuBarSection.Name ) { super.init( - rootView: IceBarContentView(screen: screen, section: section, closePanel: closePanel) + rootView: IceBarContentView(screen: screen, section: section) .environmentObject(appState) .environmentObject(appState.imageCache) .environmentObject(appState.itemManager) @@ -241,7 +238,6 @@ private struct IceBarContentView: View { let screen: NSScreen let section: MenuBarSection.Name - let closePanel: () -> Void private var items: [MenuBarItem] { itemManager.itemCache.managedItems(for: section) @@ -312,7 +308,7 @@ private struct IceBarContentView: View { Text("The Ice Bar requires screen recording permissions.") Button { - closePanel() + menuBarManager.section(withName: section)?.hide() appState.navigationState.settingsNavigationIdentifier = .advanced appState.appDelegate?.openSettingsWindow() } label: { @@ -332,7 +328,7 @@ private struct IceBarContentView: View { ScrollView(.horizontal) { HStack(spacing: 0) { ForEach(items, id: \.windowID) { item in - IceBarItemView(item: item, closePanel: closePanel) + IceBarItemView(item: item, section: section) } } } @@ -351,16 +347,17 @@ private struct IceBarContentView: View { private struct IceBarItemView: View { @EnvironmentObject var imageCache: MenuBarItemImageCache @EnvironmentObject var itemManager: MenuBarItemManager + @EnvironmentObject var menuBarManager: MenuBarManager let item: MenuBarItem - let closePanel: () -> Void + let section: MenuBarSection.Name private var leftClickAction: () -> Void { return { [weak itemManager] in guard let itemManager else { return } - closePanel() + menuBarManager.section(withName: section)?.hide() Task { try await Task.sleep(for: .milliseconds(25)) itemManager.tempShowItem(item, clickWhenFinished: true, mouseButton: .left) @@ -373,7 +370,7 @@ private struct IceBarItemView: View { guard let itemManager else { return } - closePanel() + menuBarManager.section(withName: section)?.hide() Task { try await Task.sleep(for: .milliseconds(25)) itemManager.tempShowItem(item, clickWhenFinished: true, mouseButton: .right) diff --git a/Ice/UI/LayoutBar/LayoutBarPaddingView.swift b/Ice/UI/LayoutBar/LayoutBarPaddingView.swift index beab4f766..0b50b3ea6 100644 --- a/Ice/UI/LayoutBar/LayoutBarPaddingView.swift +++ b/Ice/UI/LayoutBar/LayoutBarPaddingView.swift @@ -102,8 +102,8 @@ final class LayoutBarPaddingView: NSView { let items = MenuBarItem.getMenuBarItems(onScreenOnly: false, activeSpaceOnly: true) let targetItem: MenuBarItem? = switch section.name { case .visible: nil // visible section always has more than 1 item - case .hidden: items.first { $0.info == .hiddenControlItem } - case .alwaysHidden: items.first { $0.info == .alwaysHiddenControlItem } + case .hidden: items.first(matching: .hiddenControlItem) + case .alwaysHidden: items.first(matching: .alwaysHiddenControlItem) } if let targetItem { move(item: draggingSource.item, to: .leftOfItem(targetItem)) diff --git a/Ice/Utilities/Extensions.swift b/Ice/Utilities/Extensions.swift index 86bfa9916..d624230c1 100644 --- a/Ice/Utilities/Extensions.swift +++ b/Ice/Utilities/Extensions.swift @@ -319,6 +319,12 @@ extension Collection where Element == MenuBarItem { func firstIndex(matching info: MenuBarItemInfo) -> Index? { firstIndex { $0.info == info } } + + /// Returns the first index where the menu bar item matching the specified + /// legacy info appears in the collection. + func firstIndex(matching info: MenuBarItemLegacyInfo) -> Index? { + firstIndex { $0.legacyInfo == info } + } } // MARK: - Comparable @@ -497,4 +503,9 @@ extension Sequence where Element == MenuBarItem { func first(matching info: MenuBarItemInfo) -> MenuBarItem? { first { $0.info == info } } + + /// Returns the first menu bar item that matches the specified legacy info. + func first(matching info: MenuBarItemLegacyInfo) -> MenuBarItem? { + first { $0.legacyInfo == info } + } } From fda726eb9a99614a5be78bf423f45cda7cb7886e Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 20 Jun 2025 08:03:07 -0600 Subject: [PATCH 08/80] macOS 26: Fix warnings from the new SDK --- Ice/UI/Pickers/CustomColorPicker/CustomColorPicker.swift | 2 ++ Ice/Utilities/BindingExposable.swift | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Ice/UI/Pickers/CustomColorPicker/CustomColorPicker.swift b/Ice/UI/Pickers/CustomColorPicker/CustomColorPicker.swift index a0a82451b..641a4f795 100644 --- a/Ice/UI/Pickers/CustomColorPicker/CustomColorPicker.swift +++ b/Ice/UI/Pickers/CustomColorPicker/CustomColorPicker.swift @@ -113,6 +113,8 @@ struct CustomColorPicker: NSViewRepresentable { context: Context ) -> CGSize? { switch nsView.controlSize { + case .extraLarge: + CGSize(width: 64, height: 34) case .large: CGSize(width: 55, height: 30) case .regular: diff --git a/Ice/Utilities/BindingExposable.swift b/Ice/Utilities/BindingExposable.swift index bb3712bb6..f27a738dc 100644 --- a/Ice/Utilities/BindingExposable.swift +++ b/Ice/Utilities/BindingExposable.swift @@ -6,6 +6,7 @@ import SwiftUI /// A type that exposes its writable properties as bindings. +@MainActor protocol BindingExposable { /// A lens that exposes bindings to the writable properties of this type. typealias Bindings = ExposedBindings @@ -21,6 +22,7 @@ extension BindingExposable { } /// A lens that exposes bindings to the writable properties of a base object. +@MainActor @dynamicMemberLookup struct ExposedBindings { /// The object whose bindings are exposed. From 5470334931533156129dfa36e5e1d93146a9b49d Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 20 Jun 2025 08:15:50 -0600 Subject: [PATCH 09/80] Update build settings to Xcode 26 --- Ice.xcodeproj/project.pbxproj | 8 +++++++- Ice.xcodeproj/xcshareddata/xcschemes/Ice.xcscheme | 2 +- Ice/Ice.entitlements | 7 +------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/Ice.xcodeproj/project.pbxproj b/Ice.xcodeproj/project.pbxproj index 7f61b6a81..a76c7c096 100644 --- a/Ice.xcodeproj/project.pbxproj +++ b/Ice.xcodeproj/project.pbxproj @@ -104,7 +104,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1430; - LastUpgradeCheck = 1640; + LastUpgradeCheck = 2600; TargetAttributes = { 716683292A767E6A006ABF84 = { CreatedOnToolsVersion = 14.3.1; @@ -238,6 +238,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -295,6 +296,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; }; @@ -312,9 +314,11 @@ CURRENT_PROJECT_VERSION = 1117; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; + ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Ice/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -344,9 +348,11 @@ CURRENT_PROJECT_VERSION = 1117; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; + ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Ice/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; diff --git a/Ice.xcodeproj/xcshareddata/xcschemes/Ice.xcscheme b/Ice.xcodeproj/xcshareddata/xcschemes/Ice.xcscheme index f232e13ec..e80a2bea9 100644 --- a/Ice.xcodeproj/xcshareddata/xcschemes/Ice.xcscheme +++ b/Ice.xcodeproj/xcshareddata/xcschemes/Ice.xcscheme @@ -1,6 +1,6 @@ - - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - - + From b7b7442c7fa401a08dab1701409afd50e909c03b Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 20 Jun 2025 08:38:04 -0600 Subject: [PATCH 10/80] Bump version and build numbers --- Ice.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Ice.xcodeproj/project.pbxproj b/Ice.xcodeproj/project.pbxproj index a76c7c096..ef7cd9d73 100644 --- a/Ice.xcodeproj/project.pbxproj +++ b/Ice.xcodeproj/project.pbxproj @@ -311,7 +311,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1117; + CURRENT_PROJECT_VERSION = 1118; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_APP_SANDBOX = YES; @@ -328,7 +328,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.11.12; + MARKETING_VERSION = "0.11.13-macos26.beta.1"; PRODUCT_BUNDLE_IDENTIFIER = com.jordanbaird.Ice; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -345,7 +345,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1117; + CURRENT_PROJECT_VERSION = 1118; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_APP_SANDBOX = YES; @@ -362,7 +362,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.11.12; + MARKETING_VERSION = "0.11.13-macos26.beta.1"; PRODUCT_BUNDLE_IDENTIFIER = com.jordanbaird.Ice; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; From ac313f26faaefda70cd06dd7067490f720055021 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 20 Jun 2025 11:21:50 -0600 Subject: [PATCH 11/80] Disable sandbox This is what I get for trusting Xcode to update my build settings --- Ice.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Ice.xcodeproj/project.pbxproj b/Ice.xcodeproj/project.pbxproj index ef7cd9d73..11b5cef07 100644 --- a/Ice.xcodeproj/project.pbxproj +++ b/Ice.xcodeproj/project.pbxproj @@ -314,7 +314,7 @@ CURRENT_PROJECT_VERSION = 1118; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -348,7 +348,7 @@ CURRENT_PROJECT_VERSION = 1118; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; From 777168688bb581b7eaf514d702af76cc4f2e352a Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 20 Jun 2025 11:22:37 -0600 Subject: [PATCH 12/80] Bump version and build numbers --- Ice.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Ice.xcodeproj/project.pbxproj b/Ice.xcodeproj/project.pbxproj index 11b5cef07..3a2062a1e 100644 --- a/Ice.xcodeproj/project.pbxproj +++ b/Ice.xcodeproj/project.pbxproj @@ -311,7 +311,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1118; + CURRENT_PROJECT_VERSION = 1119; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_APP_SANDBOX = NO; @@ -328,7 +328,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = "0.11.13-macos26.beta.1"; + MARKETING_VERSION = "0.11.13-dev.1"; PRODUCT_BUNDLE_IDENTIFIER = com.jordanbaird.Ice; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -345,7 +345,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1118; + CURRENT_PROJECT_VERSION = 1119; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_APP_SANDBOX = NO; @@ -362,7 +362,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = "0.11.13-macos26.beta.1"; + MARKETING_VERSION = "0.11.13-dev.1"; PRODUCT_BUNDLE_IDENTIFIER = com.jordanbaird.Ice; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; From f58956beb6b3a94a48a42a822acb044a3c9d4c4a Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Thu, 26 Jun 2025 12:42:52 -0600 Subject: [PATCH 13/80] Misc reworks - Refactor screen capture - Rework menu bar item getters - Update immovable/non-hideable info lists - Remove OSLog wrapper - Minor migration rework - Remove old entitlements file --- Ice.xcodeproj/project.pbxproj | 4 +- Ice/Bridging/Bridging.swift | 23 ++-- Ice/Events/EventManager.swift | 3 +- Ice/Events/EventTap.swift | 19 ++- Ice/Hotkeys/Hotkey.swift | 8 +- Ice/Hotkeys/HotkeyRegistry.swift | 24 ++-- Ice/Hotkeys/KeyCombination.swift | 10 +- Ice/Ice.entitlements | 5 - Ice/Main/AppDelegate.swift | 16 +-- Ice/Main/AppState.swift | 22 ++- Ice/Main/IceApp.swift | 2 +- .../Appearance/MenuBarAppearanceManager.swift | 11 +- .../Appearance/MenuBarOverlayPanel.swift | 21 ++- Ice/MenuBar/ControlItem/ControlItem.swift | 6 - Ice/MenuBar/MenuBarItems/MenuBarItem.swift | 84 +++++++----- .../MenuBarItems/MenuBarItemImageCache.swift | 28 ++-- .../MenuBarItems/MenuBarItemInfo.swift | 28 +++- .../MenuBarItems/MenuBarItemManager.swift | 129 ++++++++++-------- Ice/MenuBar/MenuBarManager.swift | 26 ++-- Ice/MenuBar/MenuBarSection.swift | 5 - .../Spacing/MenuBarItemSpacingManager.swift | 34 +++-- .../GeneralSettingsManager.swift | 10 +- .../HotkeySettingsManager.swift | 10 +- Ice/UI/LayoutBar/LayoutBarPaddingView.swift | 12 +- .../UserNotificationManager.swift | 8 +- Ice/Utilities/Extensions.swift | 7 - Ice/Utilities/Logging.swift | 34 ++--- Ice/Utilities/MigrationManager.swift | 84 ++++++------ Ice/Utilities/MouseHelpers.swift | 12 +- Ice/Utilities/ScreenCapture.swift | 84 +++++++----- 30 files changed, 380 insertions(+), 389 deletions(-) delete mode 100644 Ice/Ice.entitlements diff --git a/Ice.xcodeproj/project.pbxproj b/Ice.xcodeproj/project.pbxproj index 3a2062a1e..a58b7081c 100644 --- a/Ice.xcodeproj/project.pbxproj +++ b/Ice.xcodeproj/project.pbxproj @@ -307,7 +307,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = Ice/Ice.entitlements; + CODE_SIGN_ENTITLEMENTS = ""; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; @@ -341,7 +341,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = Ice/Ice.entitlements; + CODE_SIGN_ENTITLEMENTS = ""; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; diff --git a/Ice/Bridging/Bridging.swift b/Ice/Bridging/Bridging.swift index 5cad734b7..a30d9333f 100644 --- a/Ice/Bridging/Bridging.swift +++ b/Ice/Bridging/Bridging.swift @@ -4,6 +4,7 @@ // import Cocoa +import OSLog /// A namespace for bridged functionality. enum Bridging { @@ -29,7 +30,7 @@ extension Bridging { value as CFTypeRef ) if result != .success { - logger.error("CGSSetConnectionProperty failed with error \(result.logString)") + logger.error("CGSSetConnectionProperty failed with error \(result.logString, privacy: .public)") } } @@ -47,7 +48,7 @@ extension Bridging { &value ) if result != .success { - logger.error("CGSCopyConnectionProperty failed with error \(result.logString)") + logger.error("CGSCopyConnectionProperty failed with error \(result.logString, privacy: .public)") } return value?.takeRetainedValue() } @@ -64,13 +65,13 @@ extension Bridging { if #available(macOS 26.0, *) { let result = CGSGetWindowBounds(mainConnectionID, windowID, &bounds) guard result == .success else { - logger.error("CGSGetWindowBounds failed with error \(result.logString)") + logger.error("CGSGetWindowBounds failed with error \(result.logString, privacy: .public)") return nil } } else { let result = CGSGetScreenRectForWindow(mainConnectionID, windowID, &bounds) guard result == .success else { - logger.error("CGSGetScreenRectForWindow failed with error \(result.logString)") + logger.error("CGSGetScreenRectForWindow failed with error \(result.logString, privacy: .public)") return nil } } @@ -84,7 +85,7 @@ extension Bridging { var level: CGWindowLevel = 0 let result = CGSGetWindowLevel(mainConnectionID, windowID, &level) guard result == .success else { - logger.error("CGSGetWindowLevel failed with error \(result.logString)") + logger.error("CGSGetWindowLevel failed with error \(result.logString, privacy: .public)") return nil } return level @@ -131,7 +132,7 @@ extension Bridging { var count: Int32 = 0 let result = CGSGetWindowCount(mainConnectionID, 0, &count) if result != .success { - logger.error("CGSGetWindowCount failed with error \(result.logString)") + logger.error("CGSGetWindowCount failed with error \(result.logString, privacy: .public)") } return Int(count) } @@ -140,7 +141,7 @@ extension Bridging { var count: Int32 = 0 let result = CGSGetOnScreenWindowCount(mainConnectionID, 0, &count) if result != .success { - logger.error("CGSGetOnScreenWindowCount failed with error \(result.logString)") + logger.error("CGSGetOnScreenWindowCount failed with error \(result.logString, privacy: .public)") } return Int(count) } @@ -157,7 +158,7 @@ extension Bridging { &realCount ) guard result == .success else { - logger.error("CGSGetWindowList failed with error \(result.logString)") + logger.error("CGSGetWindowList failed with error \(result.logString, privacy: .public)") return [] } return [CGWindowID](list[.. Unmanaged? @@ -129,11 +133,11 @@ final class EventTap { callback: handleEvent, userInfo: Unmanaged.passUnretained(self).toOpaque() ) else { - Logger.eventTap.error("Error creating mach port for event tap \"\(self.label)\"") + EventTap.logger.error("Error creating mach port for event tap \"\(self.label, privacy: .public)\"") return } guard let source = CFMachPortCreateRunLoopSource(nil, machPort, 0) else { - Logger.eventTap.error("Error creating run loop source for event tap \"\(self.label)\"") + EventTap.logger.error("Error creating run loop source for event tap \"\(self.label, privacy: .public)\"") return } self.machPort = machPort @@ -201,15 +205,15 @@ final class EventTap { private func withUnwrappedComponents(body: @MainActor (CFRunLoop, CFRunLoopSource, CFMachPort) -> Void) { guard let runLoop else { - Logger.eventTap.error("Missing run loop for event tap \"\(self.label)\"") + EventTap.logger.error("Missing run loop for event tap \"\(self.label, privacy: .public)\"") return } guard let source else { - Logger.eventTap.error("Missing run loop source for event tap \"\(self.label)\"") + EventTap.logger.error("Missing run loop source for event tap \"\(self.label, privacy: .public)\"") return } guard let machPort else { - Logger.eventTap.error("Missing mach port for event tap \"\(self.label)\"") + EventTap.logger.error("Missing mach port for event tap \"\(self.label, privacy: .public)\"") return } body(runLoop, source, machPort) @@ -256,8 +260,3 @@ private func handleEvent( let eventTap = Unmanaged.fromOpaque(refcon).takeUnretainedValue() return EventTap.performCallback(for: eventTap, proxy: proxy, type: type, event: event) } - -// MARK: - Logger -private extension Logger { - static let eventTap = Logger(category: "EventTap") -} diff --git a/Ice/Hotkeys/Hotkey.swift b/Ice/Hotkeys/Hotkey.swift index 2f3b6db74..7851c325f 100644 --- a/Ice/Hotkeys/Hotkey.swift +++ b/Ice/Hotkeys/Hotkey.swift @@ -4,6 +4,7 @@ // import Combine +import OSLog /// A combination of a key and modifiers that can be used to /// trigger actions on system-wide key-up or key-down events. @@ -90,7 +91,7 @@ extension Hotkey { return } guard let appState else { - Logger.hotkey.error("Error invalidating hotkey: Missing AppState") + Logger.default.error("Error invalidating hotkey: Missing app state") return } defer { @@ -140,8 +141,3 @@ extension Hotkey: Hashable { hasher.combine(action) } } - -// MARK: - Logger -private extension Logger { - static let hotkey = Logger(category: "Hotkey") -} diff --git a/Ice/Hotkeys/HotkeyRegistry.swift b/Ice/Hotkeys/HotkeyRegistry.swift index e731c5ceb..b5528b4e8 100644 --- a/Ice/Hotkeys/HotkeyRegistry.swift +++ b/Ice/Hotkeys/HotkeyRegistry.swift @@ -6,6 +6,7 @@ import Carbon.HIToolbox import Cocoa import Combine +import OSLog /// An object that manages the registration, storage, and unregistration of hotkeys. final class HotkeyRegistry { @@ -52,6 +53,8 @@ final class HotkeyRegistry { } } + private let logger = Logger(category: "HotkeyRegistry") + private let signature = OSType(1231250720) // OSType for Ice private var eventHandlerRef: EventHandlerRef? @@ -129,21 +132,21 @@ final class HotkeyRegistry { } guard let keyCombination = hotkey.keyCombination else { - Logger.hotkeyRegistry.error("Hotkey does not have a valid key combination") + logger.error("Hotkey does not have a valid key combination") return nil } var status = installIfNeeded() guard status == noErr else { - Logger.hotkeyRegistry.error("Hotkey event handler installation failed with status \(status)") + logger.error("Hotkey event handler installation failed with status \(status, privacy: .public)") return nil } let id = Context.currentID guard registrations[id] == nil else { - Logger.hotkeyRegistry.error("Hotkey already registered for id \(id)") + logger.error("Hotkey already registered for id \(id, privacy: .public)") return nil } @@ -159,12 +162,12 @@ final class HotkeyRegistry { ) guard status == noErr else { - Logger.hotkeyRegistry.error("Hotkey registration failed with status \(status)") + logger.error("Hotkey registration failed with status \(status, privacy: .public)") return nil } guard let hotKeyRef else { - Logger.hotkeyRegistry.error("Hotkey registration failed due to invalid EventHotKeyRef") + logger.error("Hotkey registration failed due to invalid EventHotKeyRef") return nil } @@ -185,12 +188,12 @@ final class HotkeyRegistry { /// its registration in an inactive state. private func retainedUnregister(_ id: UInt32) { guard let registration = registrations[id] else { - Logger.hotkeyRegistry.error("No registered key combination for id \(id)") + logger.error("No registered key combination for id \(id, privacy: .public)") return } let status = UnregisterEventHotKey(registration.hotKeyRef) guard status == noErr else { - Logger.hotkeyRegistry.error("Hotkey unregistration failed with status \(status)") + logger.error("Hotkey unregistration failed with status \(status, privacy: .public)") return } registration.hotKeyRef = nil @@ -236,7 +239,7 @@ final class HotkeyRegistry { let hotKeyRef else { registrations.removeValue(forKey: registration.hotKeyID.id) - Logger.hotkeyRegistry.error("Hotkey registration failed with status \(status)") + logger.error("Hotkey registration failed with status \(status, privacy: .public)") continue } @@ -284,8 +287,3 @@ final class HotkeyRegistry { return noErr } } - -// MARK: - Logger -private extension Logger { - static let hotkeyRegistry = Logger(category: "HotkeyRegistry") -} diff --git a/Ice/Hotkeys/KeyCombination.swift b/Ice/Hotkeys/KeyCombination.swift index ba16f50f4..503d68f84 100644 --- a/Ice/Hotkeys/KeyCombination.swift +++ b/Ice/Hotkeys/KeyCombination.swift @@ -5,6 +5,7 @@ import Carbon.HIToolbox import Cocoa +import OSLog struct KeyCombination: Hashable { let key: KeyCode @@ -31,11 +32,11 @@ private func getSystemReservedKeyCombinations() -> [KeyCombination] { let status = CopySymbolicHotKeys(&symbolicHotkeys) guard status == noErr else { - Logger.keyCombination.error("CopySymbolicHotKeys returned invalid status: \(status)") + Logger.serialization.error("CopySymbolicHotKeys returned invalid status: \(status, privacy: .public)") return [] } guard let reservedHotkeys = symbolicHotkeys?.takeRetainedValue() as? [[String: Any]] else { - Logger.keyCombination.error("Failed to serialize symbolic hotkeys") + Logger.serialization.error("Failed to serialize symbolic hotkeys") return [] } @@ -83,8 +84,3 @@ extension KeyCombination: Codable { try container.encode(modifiers.rawValue) } } - -// MARK: - Logger -private extension Logger { - static let keyCombination = Logger(category: "KeyCombination") -} diff --git a/Ice/Ice.entitlements b/Ice/Ice.entitlements deleted file mode 100644 index 0c67376eb..000000000 --- a/Ice/Ice.entitlements +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/Ice/Main/AppDelegate.swift b/Ice/Main/AppDelegate.swift index ee2377277..5cbb86af0 100644 --- a/Ice/Main/AppDelegate.swift +++ b/Ice/Main/AppDelegate.swift @@ -4,16 +4,19 @@ // import SwiftUI +import OSLog @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { private weak var appState: AppState? + private let logger = Logger(category: "AppDelegate") + // MARK: NSApplicationDelegate Methods func applicationWillFinishLaunching(_ notification: Notification) { guard let appState else { - Logger.appDelegate.warning("Missing app state in applicationWillFinishLaunching") + logger.warning("Missing app state in applicationWillFinishLaunching") return } @@ -26,7 +29,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { guard let appState else { - Logger.appDelegate.warning("Missing app state in applicationDidFinishLaunching") + logger.warning("Missing app state in applicationDidFinishLaunching") return } @@ -74,7 +77,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Assigns the app state to the delegate. func assignAppState(_ appState: AppState) { guard self.appState == nil else { - Logger.appDelegate.warning("Multiple attempts made to assign app state") + logger.warning("Multiple attempts made to assign app state") return } self.appState = appState @@ -83,7 +86,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Opens the settings window and activates the app. @objc func openSettingsWindow() { guard let appState else { - Logger.appDelegate.error("Failed to open settings window") + logger.error("Failed to open settings window") return } // Small delay makes this more reliable. @@ -93,8 +96,3 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } } - -// MARK: - Logger -private extension Logger { - static let appDelegate = Logger(category: "AppDelegate") -} diff --git a/Ice/Main/AppState.swift b/Ice/Main/AppState.swift index 24e81482a..44517b3ad 100644 --- a/Ice/Main/AppState.swift +++ b/Ice/Main/AppState.swift @@ -5,6 +5,7 @@ import Combine import SwiftUI +import OSLog /// The model for app-wide state. @MainActor @@ -60,6 +61,9 @@ final class AppState: ObservableObject { /// Storage for internal observers. private var cancellables = Set() + /// Logger for the app state. + private let logger = Logger(category: "AppState") + /// A Boolean value that indicates whether the app is running as a SwiftUI preview. let isPreview: Bool = { #if DEBUG @@ -127,7 +131,7 @@ final class AppState: ObservableObject { } .store(in: &c) } else { - Logger.appState.warning("No settings window!") + logger.warning("No settings window!") } Publishers.Merge( @@ -191,7 +195,7 @@ final class AppState: ObservableObject { /// Assigns the app delegate to the app state. func assignAppDelegate(_ appDelegate: AppDelegate) { guard self.appDelegate == nil else { - Logger.appState.warning("Multiple attempts made to assign app delegate") + logger.warning("Multiple attempts made to assign app delegate") return } self.appDelegate = appDelegate @@ -200,7 +204,7 @@ final class AppState: ObservableObject { /// Assigns the settings window to the app state. func assignSettingsWindow(_ window: NSWindow) { guard window.identifier?.rawValue == Constants.settingsWindowID else { - Logger.appState.warning("Window \(window.identifier?.rawValue ?? "") is not the settings window!") + logger.warning("Window \(window.identifier?.rawValue ?? "", privacy: .public) is not the settings window!") return } settingsWindow = window @@ -210,7 +214,7 @@ final class AppState: ObservableObject { /// Assigns the permissions window to the app state. func assignPermissionsWindow(_ window: NSWindow) { guard window.identifier?.rawValue == Constants.permissionsWindowID else { - Logger.appState.warning("Window \(window.identifier?.rawValue ?? "") is not the permissions window!") + logger.warning("Window \(window.identifier?.rawValue ?? "", privacy: .public) is not the permissions window!") return } permissionsWindow = window @@ -221,7 +225,7 @@ final class AppState: ObservableObject { func openWindow(id: String) { // Defer to the next run loop to prevent conflicts with SwiftUI. DispatchQueue.main.async { - Logger.appState.debug("Opening window with id: \(id)") + self.logger.debug("Opening window with id: \(id, privacy: .public)") EnvironmentValues().openWindow(id: id) } } @@ -230,7 +234,7 @@ final class AppState: ObservableObject { func dismissWindow(id: String) { // Defer to the next run loop to prevent conflicts with SwiftUI. DispatchQueue.main.async { - Logger.appState.debug("Dismissing window with id: \(id)") + self.logger.debug("Dismissing window with id: \(id, privacy: .public)") EnvironmentValues().dismissWindow(id: id) } } @@ -294,9 +298,3 @@ final class AppState: ObservableObject { // MARK: AppState: BindingExposable extension AppState: BindingExposable { } - -// MARK: - Logger -private extension Logger { - /// The logger to use for the app state. - static let appState = Logger(category: "AppState") -} diff --git a/Ice/Main/IceApp.swift b/Ice/Main/IceApp.swift index c0a4457f4..ae9cf6910 100644 --- a/Ice/Main/IceApp.swift +++ b/Ice/Main/IceApp.swift @@ -12,7 +12,7 @@ struct IceApp: App { init() { NSSplitViewItem.swizzle() - MigrationManager.migrateAll(appState: appState) + MigrationManager(appState: appState).migrateAll() appDelegate.assignAppState(appState) } diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift index 74cda2f4d..d8dda7d47 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift @@ -5,6 +5,7 @@ import Cocoa import Combine +import OSLog /// A manager for the appearance of the menu bar. @MainActor @@ -51,7 +52,7 @@ final class MenuBarAppearanceManager: ObservableObject { configuration = try decoder.decode(MenuBarAppearanceConfigurationV2.self, from: data) } } catch { - Logger.appearanceManager.error("Error decoding configuration: \(error)") + Logger.serialization.error("Error decoding menu bar appearance configuration: \(error)") } } @@ -80,7 +81,7 @@ final class MenuBarAppearanceManager: ObservableObject { .receive(on: DispatchQueue.main) .sink { completion in if case .failure(let error) = completion { - Logger.appearanceManager.error("Error encoding configuration: \(error)") + Logger.serialization.error("Error encoding menu bar appearance configuration: \(error)") } } receiveValue: { data in Defaults.set(data, forKey: .menuBarAppearanceConfigurationV2) @@ -156,9 +157,3 @@ final class MenuBarAppearanceManager: ObservableObject { // MARK: MenuBarAppearanceManager: BindingExposable extension MenuBarAppearanceManager: BindingExposable { } - -// MARK: - Logger -private extension Logger { - /// The logger to use for the menu bar appearance manager. - static let appearanceManager = Logger(category: "MenuBarAppearanceManager") -} diff --git a/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift b/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift index 5b1776c28..14bf9f256 100644 --- a/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift +++ b/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift @@ -5,6 +5,7 @@ import Cocoa import Combine +import OSLog // MARK: - Overlay Panel @@ -51,6 +52,9 @@ final class MenuBarOverlayPanel: NSPanel { } } + /// Shared logger for overlay panels. + private static let logger = Logger(category: "MenuBarOverlayPanel") + /// A Boolean value that indicates whether the panel needs to be shown. @Published var needsShow = false @@ -259,20 +263,20 @@ final class MenuBarOverlayPanel: NSPanel { case .updates: "Preventing overlay panel from updating." } guard let appState else { - Logger.overlayPanel.debug("No app state. \(actionMessage)") + MenuBarOverlayPanel.logger.debug("No app state. \(actionMessage, privacy: .public)") return nil } guard !appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults else { - Logger.overlayPanel.debug("Menu bar is hidden by system. \(actionMessage)") + MenuBarOverlayPanel.logger.debug("Menu bar is hidden by system. \(actionMessage, privacy: .public)") return nil } guard !appState.isActiveSpaceFullscreen else { - Logger.overlayPanel.debug("Active space is fullscreen. \(actionMessage)") + MenuBarOverlayPanel.logger.debug("Active space is fullscreen. \(actionMessage, privacy: .public)") return nil } let owningDisplay = owningScreen.displayID guard appState.menuBarManager.hasValidMenuBar(in: windows, for: owningDisplay) else { - Logger.overlayPanel.debug("No valid menu bar found. \(actionMessage)") + MenuBarOverlayPanel.logger.debug("No valid menu bar found. \(actionMessage, privacy: .public)") return nil } return owningDisplay @@ -324,7 +328,7 @@ final class MenuBarOverlayPanel: NSPanel { } guard appState.appearanceManager.overlayPanels.contains(self) else { - Logger.overlayPanel.warning("Overlay panel \(self) not retained") + MenuBarOverlayPanel.logger.warning("Overlay panel \(self) not retained") return } @@ -570,7 +574,7 @@ private final class MenuBarOverlayPanelContentView: NSView { return CGRect(x: rect.minX, y: rect.minY, width: maxX, height: rect.height) }() let trailingPathBounds: CGRect = { - let items = MenuBarItem.getMenuBarItems(on: screen.displayID, onScreenOnly: true, activeSpaceOnly: false) + let items = MenuBarItem.getMenuBarItems(on: screen.displayID, option: .onScreen) guard !items.isEmpty else { return .zero } @@ -786,8 +790,3 @@ private final class MenuBarOverlayPanelContentView: NSView { } } } - -// MARK: - Logger -private extension Logger { - static let overlayPanel = Logger(category: "MenuBarOverlayPanel") -} diff --git a/Ice/MenuBar/ControlItem/ControlItem.swift b/Ice/MenuBar/ControlItem/ControlItem.swift index 756256215..61d3aa6b0 100644 --- a/Ice/MenuBar/ControlItem/ControlItem.swift +++ b/Ice/MenuBar/ControlItem/ControlItem.swift @@ -577,9 +577,3 @@ final class ControlItem { StatusItemDefaults[.preferredPosition, autosaveName] = cached } } - -// MARK: - Logger -private extension Logger { - /// The logger to use for control items. - static let controlItem = Logger(category: "ControlItem") -} diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift index f08b02b9c..cd37a8f23 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift @@ -132,15 +132,6 @@ struct MenuBarItem { "<\(legacyInfo) (windowID: \(windowID))>" } - /// The latest version of the menu bar item, or `nil` if the item - /// no longer exists. - var latest: MenuBarItem? { - guard let window = WindowInfo(windowID: windowID) else { - return nil - } - return MenuBarItem(uncheckedItemWindow: window) - } - /// Creates a menu bar item from the given window. /// /// This initializer does not perform any checks on the window to ensure that @@ -153,28 +144,38 @@ struct MenuBarItem { } } -// MARK: MenuBarItem Getters +// MARK: - MenuBarItem List + extension MenuBarItem { - /// Returns an array of the current menu bar items in the menu bar on the given display. + /// Options that specify the menu bar items in a list. + struct ListOption: OptionSet { + let rawValue: Int + + /// Specifies menu bar items that are currently on-screen. + static let onScreen = ListOption(rawValue: 1 << 0) + + /// Specifies menu bar items on the currently active space. + static let activeSpace = ListOption(rawValue: 1 << 1) + } + + /// Creates and returns a list of menu bar items windows for the given display. /// /// - Parameters: - /// - display: The display to retrieve the menu bar items on. Pass `nil` to return the - /// menu bar items across all displays. - /// - onScreenOnly: A Boolean value that indicates whether only the menu bar items that - /// are on screen should be returned. - /// - activeSpaceOnly: A Boolean value that indicates whether only the menu bar items - /// that are on the active space should be returned. - static func getMenuBarItems(on display: CGDirectDisplayID? = nil, onScreenOnly: Bool, activeSpaceOnly: Bool) -> [MenuBarItem] { - var option: Bridging.WindowListOption = [.menuBarItems] - - var boundsPredicate: (CGWindowID) -> Bool = { _ in true } - var spacePredicate: (CGWindowID) -> Bool = { _ in true } - - if onScreenOnly { - option.insert(.onScreen) + /// - display: An identifier for a display. Pass `nil` to return the menu bar + /// item windows across all available displays. + /// - option: Options that filter the returned list. Pass an empty option set + /// to return all available menu bar item windows. + static func getMenuBarItemWindows(on display: CGDirectDisplayID? = nil, option: ListOption) -> [WindowInfo] { + var bridgingOption: Bridging.WindowListOption = .menuBarItems + + var onScreenPredicate: (CGWindowID) -> Bool = { _ in true } + var activeSpacePredicate: (CGWindowID) -> Bool = { _ in true } + + if option.contains(.onScreen) { + bridgingOption.insert(.onScreen) if let display { let displayBounds = CGDisplayBounds(display) - boundsPredicate = { windowID in + onScreenPredicate = { windowID in if let bounds = Bridging.getWindowBounds(for: windowID) { return displayBounds.intersects(bounds) } @@ -182,27 +183,42 @@ extension MenuBarItem { } } } - if activeSpaceOnly { - option.insert(.activeSpace) + if option.contains(.activeSpace) { + bridgingOption.insert(.activeSpace) if let spaceID = display.flatMap(Bridging.getCurrentSpaceID) { - spacePredicate = { windowID in + activeSpacePredicate = { windowID in Bridging.isWindowOnSpace(windowID, spaceID) } } } - return Bridging.getWindowList(option: option).lazy + return Bridging.getWindowList(option: bridgingOption) .compactMap { windowID in guard - boundsPredicate(windowID), - spacePredicate(windowID), + onScreenPredicate(windowID), + activeSpacePredicate(windowID), let window = WindowInfo(windowID: windowID) else { return nil } - return MenuBarItem(uncheckedItemWindow: window) + return window + } + .sorted { lhs, rhs in + lhs.frame.maxX < rhs.frame.maxX } - .sortedByOrderInMenuBar() + } + + /// Creates and returns a list of menu bar items for the given display. + /// + /// - Parameters: + /// - display: An identifier for a display. Pass `nil` to return the menu bar + /// items across all available displays. + /// - option: Options that filter the returned list. Pass an empty option set + /// to return all available menu bar items. + static func getMenuBarItems(on display: CGDirectDisplayID? = nil, option: ListOption) -> [MenuBarItem] { + getMenuBarItemWindows(on: display, option: option).map { window in + MenuBarItem(uncheckedItemWindow: window) + } } } diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift index 6fcba2638..847e37ba9 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift @@ -5,9 +5,13 @@ import Cocoa import Combine +import OSLog /// Cache for menu bar item images. final class MenuBarItemImageCache: ObservableObject { + /// Logger for the menu bar item image cache. + private static let logger = Logger(category: "MenuBarItemImageCache") + /// The cached item images. @Published private(set) var images = [MenuBarItemInfo: CGImage]() @@ -75,8 +79,8 @@ final class MenuBarItemImageCache: ObservableObject { } /// Logs a reason for skipping the cache. - private func logSkippingCache(reason: String) { - Logger.imageCache.debug("Skipping menu bar item image cache as \(reason)") + private func logSkippingCache(reason: @escaping @autoclosure () -> String) { + MenuBarItemImageCache.logger.debug("Skipping menu bar item image cache as \(reason(), privacy: .public)") } /// Returns a Boolean value that indicates whether caching menu bar items failed for @@ -158,7 +162,12 @@ final class MenuBarItemImageCache: ObservableObject { images[itemInfo] = itemImage } } else { - Logger.imageCache.warning("Composite image capture failed. Attempting to capturing items individually.") + MenuBarItemImageCache.logger.warning( + """ + Composite capture failed for \(section.logString, privacy: .public). \ + Attempting to capture each item individually. + """ + ) for windowID in windowIDs { guard @@ -206,7 +215,12 @@ final class MenuBarItemImageCache: ObservableObject { } let sectionImages = await createImages(for: section, screen: screen) guard !sectionImages.isEmpty else { - Logger.imageCache.warning("Update image cache failed for \(section.logString)") + MenuBarItemImageCache.logger.warning( + """ + Failed to update cached menu bar item images for \ + \(section.logString, privacy: .public) + """ + ) continue } newImages.merge(sectionImages) { (_, new) in new } @@ -280,9 +294,3 @@ final class MenuBarItemImageCache: ObservableObject { await updateCache(sections: sectionsNeedingDisplay) } } - -// MARK: - Logger - -private extension Logger { - static let imageCache = Logger(category: "MenuBarItemImageCache") -} diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemInfo.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemInfo.swift index 87f677ddb..f8c2470e1 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemInfo.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemInfo.swift @@ -89,14 +89,30 @@ extension MenuBarItemLegacyInfo { // MARK: Special Item Lists /// An array of infos for items whose movement is prevented by macOS. - static let immovableItems = [clock, siri, controlCenter] + static let immovableItems: [MenuBarItemLegacyInfo] = { + var items = [clock, controlCenter] + if #unavailable(macOS 26.0) { + items.append(siri) + } + return items + }() - // FIXME: At some point, Apple made the "MusicRecognition" item hideable. + // TODO: MusicRecognition became hideable in what macOS version? + // + // At some point, it became possible to hide the "MusicRecognition" item. // We need to determine which version of macOS first had this change, and - // conditionally exclude the item from this list based on that. + // and conditionally exclude the item from this list. + // + // We're using macOS 15.3.2 for now, but it could be earlier. // /// An array of infos for items that can be moved, but cannot be hidden. - static let nonHideableItems = [audioVideoModule, faceTime, musicRecognition, screenCaptureUI] + static let nonHideableItems: [MenuBarItemLegacyInfo] = { + var items = [audioVideoModule, faceTime, screenCaptureUI] + if #unavailable(macOS 15.3.2) { + items.append(musicRecognition) + } + return items + }() /// An array of infos for items representing Ice's control items. static let controlItems = ControlItem.Identifier.allCases.map { $0.legacyInfo } @@ -112,7 +128,7 @@ extension MenuBarItemLegacyInfo { /// Info for the control item for the always-hidden section. static let alwaysHiddenControlItem = MenuBarItemLegacyInfo(controlItem: .alwaysHidden) - // MARK: Other Items + // MARK: Other System Items /// Info for the "Clock" item. static let clock = MenuBarItemLegacyInfo(namespace: .controlCenter, title: "Clock") @@ -145,7 +161,7 @@ extension MenuBarItemLegacyInfo { /// Info for the "MusicRecognition" (a.k.a. "Shazam") item. static let musicRecognition = MenuBarItemLegacyInfo(namespace: .controlCenter, title: "MusicRecognition") - // FIXME: How do we reference this item in macOS 26? + // TODO: How do we reference this item in macOS 26? /// Info for the "stop recording" item that appears in the menu bar during screen /// recordings started by the macOS "Screenshot" tool. static let screenCaptureUI = MenuBarItemLegacyInfo(namespace: .screenCaptureUI, title: "Item-0") diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index 3ec73ecf5..13eb98e81 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -5,6 +5,7 @@ import Cocoa import Combine +import OSLog /// Manager for menu bar items. @MainActor @@ -100,6 +101,9 @@ final class MenuBarItemManager: ObservableObject { /// The shared app state. private(set) weak var appState: AppState? + /// Logger for the menu bar item manager. + private let logger = Logger(category: "MenuBarItemManager") + /// Storage for internal observers. private var cancellables = Set() @@ -182,12 +186,12 @@ final class MenuBarItemManager: ObservableObject { extension MenuBarItemManager { /// Logs a warning that the given menu bar item was not added to the cache. private func logNotCachedWarning(for item: MenuBarItem) { - Logger.itemManager.warning("\(item.logString) was not cached") + logger.warning("\(item.logString, privacy: .public) was not cached") } /// Logs a reason for skipping the cache. private func logSkippingCache(reason: String) { - Logger.itemManager.debug("Skipping menu bar item cache as \(reason)") + logger.debug("Skipping menu bar item cache as \(reason, privacy: .public)") } /// Caches the given menu bar items, without checking whether the control @@ -197,7 +201,7 @@ extension MenuBarItemManager { alwaysHiddenControlItem: MenuBarItem?, otherItems: [MenuBarItem] ) { - Logger.itemManager.debug("Caching menu bar items") + logger.debug("Caching menu bar items") let predicates = Predicates.sectionPredicates( hiddenControlItem: hiddenControlItem, @@ -286,14 +290,14 @@ extension MenuBarItemManager { cachedItemWindowIDs = itemWindowIDs } - var items = MenuBarItem.getMenuBarItems(onScreenOnly: false, activeSpaceOnly: true) + var items = MenuBarItem.getMenuBarItems(option: .activeSpace) let hiddenControlItem = items.firstIndex(matching: .hiddenControlItem).map { items.remove(at: $0) } let alwaysHiddenControlItem = items.firstIndex(matching: .alwaysHiddenControlItem).map { items.remove(at: $0) } guard let hiddenControlItem else { - Logger.itemManager.warning("Missing control item for hidden section") - Logger.itemManager.debug("Clearing menu bar item cache") + logger.warning("Missing control item for hidden section") + logger.debug("Clearing menu bar item cache") itemCache.clear() return } @@ -311,8 +315,8 @@ extension MenuBarItemManager { otherItems: items ) } catch { - Logger.itemManager.error("Error enforcing control item order: \(error)") - Logger.itemManager.debug("Clearing menu bar item cache") + logger.error("Error enforcing control item order: \(error, privacy: .public)") + logger.debug("Clearing menu bar item cache") itemCache.clear() } } @@ -567,7 +571,7 @@ extension MenuBarItemManager { /// - Parameter item: The item to return the current bounds for. private func getCurrentBounds(for item: MenuBarItem) -> CGRect? { guard let bounds = Bridging.getWindowBounds(for: item.windowID) else { - Logger.itemManager.error("Couldn't get current bounds for \(item.logString)") + logger.error("Couldn't get current bounds for \(item.logString, privacy: .public)") return nil } return bounds @@ -659,7 +663,7 @@ extension MenuBarItemManager { /// - event: The event to post. /// - location: The event tap location to post the event to. private nonisolated func postEvent(_ event: CGEvent, to location: EventTap.Location) { - Logger.itemManager.debug("Posting \(event.type.logString) to \(location.logString)") + logger.debug("Posting \(event.type.logString, privacy: .public) to \(location.logString, privacy: .public)") switch location { case .hidEventTap: event.post(tap: .cghidEventTap) @@ -709,11 +713,11 @@ extension MenuBarItemManager { // Ensure the tap is enabled, preventing multiple calls to resume(). guard proxy.isEnabled else { - Logger.itemManager.debug("Event tap \"\(proxy.label)\" is disabled (item: \(item.logString))") + logger.debug("Event tap \"\(proxy.label, privacy: .public)\" is disabled (item: \(item.logString, privacy: .public))") return nil } - Logger.itemManager.debug("Received \(type.logString) at \(location.logString) (item: \(item.logString))") + logger.debug("Received \(type.logString, privacy: .public) at \(location.logString, privacy: .public) (item: \(item.logString, privacy: .public))") // Disable the tap and resume the continuation. proxy.disable() @@ -722,8 +726,8 @@ extension MenuBarItemManager { return nil } - eventTap.enable(timeout: .milliseconds(50)) { - Logger.itemManager.error("Event tap \"\(eventTap.label)\" timed out (item: \(item.logString))") + eventTap.enable(timeout: .milliseconds(50)) { [logger] in + logger.error("Event tap \"\(eventTap.label, privacy: .public)\" timed out (item: \(item.logString, privacy: .public))") eventTap.disable() continuation.resume(throwing: EventError(code: .eventOperationTimeout, item: item)) } @@ -813,7 +817,7 @@ extension MenuBarItemManager { // Ensure the tap is enabled, preventing multiple calls to resume(). guard proxy.isEnabled else { - Logger.itemManager.debug("Event tap \"\(proxy.label)\" is disabled (item: \(item.logString))") + logger.debug("Event tap \"\(proxy.label, privacy: .public)\" is disabled (item: \(item.logString, privacy: .public))") return nil } @@ -828,8 +832,8 @@ extension MenuBarItemManager { // Enable both taps, with a timeout on the second tap. eventTap1.enable() - eventTap2.enable(timeout: .milliseconds(50)) { - Logger.itemManager.error("Event tap \"\(eventTap2.label)\" timed out (item: \(item.logString))") + eventTap2.enable(timeout: .milliseconds(50)) { [logger] in + logger.error("Event tap \"\(eventTap2.label, privacy: .public)\" timed out (item: \(item.logString, privacy: .public))") eventTap1.disable() eventTap2.disable() continuation.resume(throwing: EventError(code: .eventOperationTimeout, item: item)) @@ -856,7 +860,7 @@ extension MenuBarItemManager { ) async throws { guard let currentBounds = getCurrentBounds(for: item) else { try await scrombleEvent(event, from: firstLocation, to: secondLocation, item: item) - Logger.itemManager.warning("Couldn't get menu bar item bounds for \(item.logString), so using fixed delay") + logger.warning("Couldn't get menu bar item bounds for \(item.logString, privacy: .public), so using fixed delay") // This will be slow, but subsequent events will have a better chance of succeeding. try await Task.sleep(for: .milliseconds(100)) return @@ -874,14 +878,17 @@ extension MenuBarItemManager { private func waitForBoundsChange(of item: MenuBarItem, initialBounds: CGRect, timeout: Duration) async throws { struct BoundsCheckCancellationError: Error { } - let boundsCheckTask = Task(timeout: timeout) { + let boundsCheckTask = Task(timeout: timeout) { [weak self] in while true { try Task.checkCancellation() - guard let currentBounds = await self.getCurrentBounds(for: item) else { + guard + let self, + let currentBounds = await getCurrentBounds(for: item) + else { throw BoundsCheckCancellationError() } if currentBounds != initialBounds { - Logger.itemManager.debug("Menu bar item bounds for \(item.logString) changed to \(NSStringFromRect(currentBounds))") + logger.debug("Menu bar item bounds for \(item.logString, privacy: .public) changed to \(NSStringFromRect(currentBounds), privacy: .public)") return } } @@ -889,7 +896,7 @@ extension MenuBarItemManager { do { try await boundsCheckTask.value } catch is BoundsCheckCancellationError { - Logger.itemManager.warning("Menu bar item bounds check for \(item.logString) was cancelled, so using fixed delay") + logger.warning("Menu bar item bounds check for \(item.logString, privacy: .public) was cancelled, so using fixed delay") // This will be slow, but subsequent events will have a better chance of succeeding. try await Task.sleep(for: .milliseconds(100)) } catch is TaskTimeoutError { @@ -916,7 +923,7 @@ extension MenuBarItemManager { /// Tries to wake up the given item if it is not responding to events. private func wakeUpItem(_ item: MenuBarItem) async throws { - Logger.itemManager.debug("Attempting to wake up \(item.logString)") + logger.debug("Attempting to wake up \(item.logString, privacy: .public)") guard let source = CGEventSource(stateID: .hidSystemState) else { throw EventError(code: .invalidEventSource, item: item) @@ -1043,7 +1050,7 @@ extension MenuBarItemManager { } catch { do { let eventTask = Task { - Logger.itemManager.debug("Posting fallback event for moving \(item.logString)") + logger.debug("Posting fallback event for moving \(item.logString, privacy: .public)") try await postEventAndWaitToReceive( fallbackEvent, to: .sessionEventTap, @@ -1055,7 +1062,7 @@ extension MenuBarItemManager { // Catch this, as we still want to throw the existing error if the fallback fails. try result.get() } catch { - Logger.itemManager.error("Failed to post fallback event for moving \(item.logString)") + logger.error("Failed to post fallback event for moving \(item.logString, privacy: .public)") } throw error } @@ -1068,7 +1075,12 @@ extension MenuBarItemManager { /// - destination: A destination to move the menu bar item. func move(item: MenuBarItem, to destination: MoveDestination) async throws { if try itemHasCorrectPosition(item: item, for: destination) { - Logger.itemManager.debug("\(item.logString) is already in the correct position") + logger.debug( + """ + \(item.logString, privacy: .public) is already in \ + the correct position + """ + ) return } @@ -1089,7 +1101,12 @@ extension MenuBarItemManager { throw EventError(code: .couldNotComplete, item: item) } - Logger.itemManager.info("Moving \(item.logString) to \(destination.logString)") + logger.info( + """ + Moving \(item.logString, privacy: .public) to \ + \(destination.logString, privacy: .public) + """ + ) guard let appState else { throw EventError(code: .invalidAppState, item: item) @@ -1122,15 +1139,20 @@ extension MenuBarItemManager { throw EventError(code: .invalidItem, item: item) } if newBounds != initialBounds { - Logger.itemManager.info("Successfully moved \(item.logString)") + logger.info("Successfully moved item") break } else { throw EventError(code: .couldNotComplete, item: item) } } catch where n < 5 { - Logger.itemManager.warning("Attempt \(n) to move \(item.logString) failed (error: \(error))") + logger.warning( + """ + Item movement attempt \(n, privacy: .public) \ + failed with error: \(error, privacy: .public) + """ + ) try await wakeUpItem(item) - Logger.itemManager.info("Retrying move of \(item.logString)") + logger.info("Retrying move of item") continue } } @@ -1236,7 +1258,7 @@ extension MenuBarItemManager { } do { - Logger.itemManager.info("Clicking \(item.logString) with \(mouseButton.logString)") + logger.info("Clicking \(item.logString, privacy: .public) with \(mouseButton.logString, privacy: .public)") try await postEventAndWaitToReceive( mouseDownEvent, to: .sessionEventTap, @@ -1250,7 +1272,7 @@ extension MenuBarItemManager { } catch { do { let eventTask = Task { - Logger.itemManager.debug("Posting fallback event for clicking \(item.logString)") + logger.debug("Posting fallback event for clicking \(item.logString, privacy: .public)") try await postEventAndWaitToReceive( fallbackEvent, to: .sessionEventTap, @@ -1262,7 +1284,7 @@ extension MenuBarItemManager { // Catch this, as we still want to throw the existing error if the fallback fails. try result.get() } catch { - Logger.itemManager.error("Failed to post fallback event for clicking \(item.logString)") + logger.error("Failed to post fallback event for clicking \(item.logString, privacy: .public)") } throw error } @@ -1288,14 +1310,14 @@ extension MenuBarItemManager { /// Schedules a timer for the given interval, attempting to rehide the current /// temporarily shown items when the timer fires. private func runTempShownItemTimer(for interval: TimeInterval) { - Logger.itemManager.debug("Running rehide timer for temporarily shown items with interval: \(interval)") + logger.debug("Running rehide timer for temporarily shown items with interval: \(interval, privacy: .public)") tempShownItemsTimer?.invalidate() tempShownItemsTimer = .scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] timer in guard let self else { timer.invalidate() return } - Logger.itemManager.debug("Rehide timer fired") + logger.debug("Rehide timer fired") Task { await self.rehideTempShownItems() } @@ -1326,7 +1348,7 @@ extension MenuBarItemManager { do { try await click(item: item, with: mouseButton) } catch { - Logger.itemManager.error("ERROR: \(error)") + logger.error("ERROR: \(error, privacy: .public)") } } } @@ -1337,16 +1359,16 @@ extension MenuBarItemManager { let appState, let applicationMenuFrame = appState.menuBarManager.getApplicationMenuFrame(for: displayID) else { - Logger.itemManager.warning("No application menu frame, so not showing \(item.logString)") + logger.warning("No application menu frame, so not showing \(item.logString, privacy: .public)") return } - Logger.itemManager.info("Temporarily showing \(item.logString)") + logger.info("Temporarily showing \(item.logString, privacy: .public)") - var items = MenuBarItem.getMenuBarItems(onScreenOnly: false, activeSpaceOnly: true) + var items = MenuBarItem.getMenuBarItems(option: .activeSpace) guard let destination = getReturnDestination(for: item, in: items) else { - Logger.itemManager.warning("No return destination for \(item.logString)") + logger.warning("No return destination for \(item.logString, privacy: .public)") return } @@ -1436,7 +1458,7 @@ extension MenuBarItemManager { tempShownItemContexts.append(context) runTempShownItemTimer(for: appState.settingsManager.advancedSettingsManager.tempShowInterval) } catch { - Logger.itemManager.error("ERROR: \(error)") + logger.error("ERROR: \(error, privacy: .public)") } } } @@ -1456,21 +1478,21 @@ extension MenuBarItemManager { } guard !MouseEvents.isButtonPressed() else { - Logger.itemManager.debug("Mouse button is down, so waiting to rehide") + logger.debug("Mouse button is down, so waiting to rehide") runTempShownItemTimer(for: 3) return } guard !tempShownItemContexts.contains(where: { $0.isShowingInterface }) else { - Logger.itemManager.debug("Menu bar item interface is shown, so waiting to rehide") + logger.debug("Menu bar item interface is shown, so waiting to rehide") runTempShownItemTimer(for: 3) return } - Logger.itemManager.info("Rehiding temporarily shown items") + logger.info("Rehiding temporarily shown items") var failedContexts = [TempShownItemContext]() - let items = MenuBarItem.getMenuBarItems(onScreenOnly: false, activeSpaceOnly: true) + let items = MenuBarItem.getMenuBarItems(option: .activeSpace) while let context = tempShownItemContexts.popLast() { guard let item = items.first(where: { $0.info == context.info }) else { @@ -1479,7 +1501,7 @@ extension MenuBarItemManager { do { try await slowMove(item: item, to: context.returnDestination) } catch { - Logger.itemManager.error("Failed to rehide \(item.logString) (error: \(error))") + logger.error("Failed to rehide \(item.logString, privacy: .public) (error: \(error, privacy: .public))") failedContexts.append(context) } await eventSleep() @@ -1490,7 +1512,7 @@ extension MenuBarItemManager { tempShownItemsTimer = nil } else { tempShownItemContexts = failedContexts - Logger.itemManager.warning("Some items failed to rehide") + logger.warning("Some items failed to rehide") runTempShownItemTimer(for: 3) } } @@ -1516,15 +1538,15 @@ extension MenuBarItemManager { /// for the always-hidden section. func enforceControlItemOrder(hiddenControlItem: MenuBarItem, alwaysHiddenControlItem: MenuBarItem) async throws { guard !MouseEvents.isButtonPressed() else { - Logger.itemManager.debug("Mouse button is down, so will not enforce control item order") + logger.debug("Mouse button is down, so will not enforce control item order") return } guard !MouseEvents.lastMovementOccurred(within: .seconds(1)) else { - Logger.itemManager.debug("Mouse has recently moved, so will not enforce control item order") + logger.debug("Mouse has recently moved, so will not enforce control item order") return } if hiddenControlItem.frame.maxX <= alwaysHiddenControlItem.frame.minX { - Logger.itemManager.info("Arranging menu bar items") + logger.info("Arranging menu bar items") try await slowMove(item: alwaysHiddenControlItem, to: .leftOfItem(hiddenControlItem)) } } @@ -1704,10 +1726,3 @@ private extension CGEvent { return event } } - -// MARK: - Logger - -private extension Logger { - /// The logger to use for the menu bar item manager. - static let itemManager = Logger(category: "MenuBarItemManager") -} diff --git a/Ice/MenuBar/MenuBarManager.swift b/Ice/MenuBar/MenuBarManager.swift index e950a9a5d..5b6f7e98f 100644 --- a/Ice/MenuBar/MenuBarManager.swift +++ b/Ice/MenuBar/MenuBarManager.swift @@ -6,6 +6,7 @@ import AXSwift import Combine import SwiftUI +import OSLog /// Manager for the state of the menu bar. @MainActor @@ -25,6 +26,9 @@ final class MenuBarManager: ObservableObject { /// A Boolean value that indicates whether the "ShowOnHover" feature is allowed. @Published var showOnHoverAllowed = true + /// Logger for the menu bar manager. + private let logger = Logger(category: "MenuBarManager") + /// The shared app state. private weak var appState: AppState? @@ -73,12 +77,12 @@ final class MenuBarManager: ObservableObject { private func initializeSections() { // Make sure initialization can only happen once. guard sections.isEmpty else { - Logger.menuBarManager.warning("Sections already initialized") + logger.warning("Sections already initialized") return } guard let appState else { - Logger.menuBarManager.error("Error initializing menu bar sections: Missing app state") + logger.error("Error initializing menu bar sections: Missing app state") return } @@ -196,7 +200,7 @@ final class MenuBarManager: ObservableObject { } // Get all items. - var items = MenuBarItem.getMenuBarItems(on: displayID, onScreenOnly: false, activeSpaceOnly: true) + var items = MenuBarItem.getMenuBarItems(on: displayID, option: .activeSpace) // Filter the items down according to the currently enabled/shown sections. if @@ -376,10 +380,10 @@ final class MenuBarManager: ObservableObject { /// Hides the application menus. func hideApplicationMenus() { guard let appState else { - Logger.menuBarManager.error("Error hiding application menus: Missing app state") + logger.error("Error hiding application menus: Missing app state") return } - Logger.menuBarManager.info("Hiding application menus") + logger.info("Hiding application menus") appState.activate(withPolicy: .regular) isHidingApplicationMenus = true } @@ -387,10 +391,10 @@ final class MenuBarManager: ObservableObject { /// Shows the application menus. func showApplicationMenus() { guard let appState else { - Logger.menuBarManager.error("Error showing application menus: Missing app state") + logger.error("Error showing application menus: Missing app state") return } - Logger.menuBarManager.info("Showing application menus") + logger.info("Showing application menus") appState.deactivate(withPolicy: .accessory) isHidingApplicationMenus = false } @@ -407,7 +411,7 @@ final class MenuBarManager: ObservableObject { /// Shows the appearance editor popover, centered under the menu bar. @objc private func showAppearanceEditorPopover() { guard let appState else { - Logger.menuBarManager.error("Error showing appearance editor popover: Missing app state") + logger.error("Error showing appearance editor popover: Missing app state") return } let panel = MenuBarAppearanceEditorPanel(appState: appState) @@ -441,9 +445,3 @@ struct MenuBarAverageColorInfo: Hashable { var color: CGColor var source: Source } - -// MARK: - Logger -private extension Logger { - /// Logger to use for the menu bar manager. - static let menuBarManager = Logger(category: "MenuBarManager") -} diff --git a/Ice/MenuBar/MenuBarSection.swift b/Ice/MenuBar/MenuBarSection.swift index a410318e0..559d885a5 100644 --- a/Ice/MenuBar/MenuBarSection.swift +++ b/Ice/MenuBar/MenuBarSection.swift @@ -301,8 +301,3 @@ final class MenuBarSection { // MARK: MenuBarSection: BindingExposable extension MenuBarSection: BindingExposable { } - -// MARK: - Logger -private extension Logger { - static let menuBarSection = Logger(category: "MenuBarSection") -} diff --git a/Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift b/Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift index 4eb0fae39..518c9c84e 100644 --- a/Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift +++ b/Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift @@ -5,6 +5,7 @@ import Cocoa import Combine +import OSLog /// Manager for menu bar item spacing. @MainActor @@ -36,6 +37,9 @@ final class MenuBarItemSpacingManager { } } + /// Logger for the menu bar item spacing manager. + private let logger = Logger(category: "MenuBarItemSpacingManager") + /// Delay before force terminating an app. private let forceTerminateDelay = 1 @@ -68,18 +72,13 @@ final class MenuBarItemSpacingManager { try await runCommand("defaults", with: ["-currentHost", "write", "-globalDomain", key.rawValue, "-int", String(key.defaultValue + offset)]) } - /// Returns a log string for the given app. - private nonisolated func logString(for app: NSRunningApplication) -> String { - app.localizedName ?? app.bundleIdentifier ?? "" - } - /// Asynchronously signals the given app to quit. private func signalAppToQuit(_ app: NSRunningApplication) async throws { if app.isTerminated { - Logger.spacing.debug("Application \"\(logString(for: app))\" is already terminated") + logger.debug("Application \"\(app.logString, privacy: .public)\" is already terminated") return } else { - Logger.spacing.debug("Signaling application \"\(logString(for: app))\" to quit") + logger.debug("Signaling application \"\(app.logString, privacy: .public)\" to quit") } app.terminate() @@ -89,7 +88,12 @@ final class MenuBarItemSpacingManager { let timeoutTask = Task { try await Task.sleep(for: .seconds(forceTerminateDelay)) if !app.isTerminated { - Logger.spacing.debug("Application \"\(logString(for: app))\" did not terminate within \(forceTerminateDelay) seconds, attempting to force terminate") + logger.debug( + """ + Application \"\(app.logString, privacy: .public)\" did not terminate within \ + \(self.forceTerminateDelay, privacy: .public) seconds, attempting to force terminate + """ + ) app.forceTerminate() } } @@ -103,7 +107,7 @@ final class MenuBarItemSpacingManager { } timeoutTask.cancel() cancellable?.cancel() - Logger.spacing.debug("Application \"\(logString(for: app))\" terminated successfully") + logger.debug("Application \"\(app.logString, privacy: .public)\" terminated successfully") continuation.resume() } } @@ -112,7 +116,7 @@ final class MenuBarItemSpacingManager { /// Asynchronously launches the app at the given URL. private nonisolated func launchApp(at applicationURL: URL, bundleIdentifier: String) async throws { if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == bundleIdentifier }) { - Logger.spacing.debug("Application \"\(logString(for: app))\" is already open, so skipping launch") + logger.debug("Application \"\(app.logString, privacy: .public)\" is already open, so skipping launch") return } let configuration = NSWorkspace.OpenConfiguration() @@ -154,7 +158,7 @@ final class MenuBarItemSpacingManager { try? await Task.sleep(for: .milliseconds(100)) - let items = MenuBarItem.getMenuBarItems(onScreenOnly: false, activeSpaceOnly: true) + let items = MenuBarItem.getMenuBarItems(option: .activeSpace) let pids = Set(items.map { $0.ownerPID }) var failedApps = [String]() @@ -209,7 +213,9 @@ final class MenuBarItemSpacingManager { } } -// MARK: - Logger -private extension Logger { - static let spacing = Logger(category: "Spacing") +private extension NSRunningApplication { + /// A string to use for logging purposes. + var logString: String { + localizedName ?? bundleIdentifier ?? "" + } } diff --git a/Ice/Settings/SettingsManagers/GeneralSettingsManager.swift b/Ice/Settings/SettingsManagers/GeneralSettingsManager.swift index d4d0cd583..7eba6d9eb 100644 --- a/Ice/Settings/SettingsManagers/GeneralSettingsManager.swift +++ b/Ice/Settings/SettingsManagers/GeneralSettingsManager.swift @@ -5,6 +5,7 @@ import Combine import Foundation +import OSLog @MainActor final class GeneralSettingsManager: ObservableObject { @@ -106,7 +107,7 @@ final class GeneralSettingsManager: ObservableObject { do { iceIcon = try decoder.decode(ControlItemImageSet.self, from: data) } catch { - Logger.generalSettingsManager.error("Error decoding Ice icon: \(error)") + Logger.serialization.error("Error decoding Ice icon: \(error, privacy: .public)") } if case .custom = iceIcon.name { lastCustomIceIcon = iceIcon @@ -137,7 +138,7 @@ final class GeneralSettingsManager: ObservableObject { let data = try encoder.encode(iceIcon) Defaults.set(data, forKey: .iceIcon) } catch { - Logger.generalSettingsManager.error("Error encoding Ice icon: \(error)") + Logger.serialization.error("Error encoding Ice icon: \(error, privacy: .public)") } } .store(in: &c) @@ -219,8 +220,3 @@ final class GeneralSettingsManager: ObservableObject { // MARK: GeneralSettingsManager: BindingExposable extension GeneralSettingsManager: BindingExposable { } - -// MARK: - Logger -private extension Logger { - static let generalSettingsManager = Logger(category: "GeneralSettingsManager") -} diff --git a/Ice/Settings/SettingsManagers/HotkeySettingsManager.swift b/Ice/Settings/SettingsManagers/HotkeySettingsManager.swift index ea8c2cbeb..0b49ff778 100644 --- a/Ice/Settings/SettingsManagers/HotkeySettingsManager.swift +++ b/Ice/Settings/SettingsManagers/HotkeySettingsManager.swift @@ -5,6 +5,7 @@ import Combine import Foundation +import OSLog @MainActor final class HotkeySettingsManager: ObservableObject { @@ -41,7 +42,7 @@ final class HotkeySettingsManager: ObservableObject { do { hotkey.keyCombination = try decoder.decode(KeyCombination?.self, from: data) } catch { - Logger.hotkeySettingsManager.error("Error decoding hotkey: \(error)") + Logger.serialization.error("Error decoding hotkey: \(error, privacy: .public)") } } } @@ -66,7 +67,7 @@ final class HotkeySettingsManager: ObservableObject { do { dict[hotkey.action.rawValue] = try self.encoder.encode(hotkey.keyCombination) } catch { - Logger.hotkeySettingsManager.error("Error encoding hotkey: \(error)") + Logger.serialization.error("Error encoding hotkey: \(error, privacy: .public)") } } Defaults.set(dict, forKey: .hotkeys) @@ -80,8 +81,3 @@ final class HotkeySettingsManager: ObservableObject { hotkeys.first { $0.action == action } } } - -// MARK: - Logger -private extension Logger { - static let hotkeySettingsManager = Logger(category: "HotkeySettingsManager") -} diff --git a/Ice/UI/LayoutBar/LayoutBarPaddingView.swift b/Ice/UI/LayoutBar/LayoutBarPaddingView.swift index 0b50b3ea6..d5ad049e1 100644 --- a/Ice/UI/LayoutBar/LayoutBarPaddingView.swift +++ b/Ice/UI/LayoutBar/LayoutBarPaddingView.swift @@ -5,6 +5,7 @@ import Cocoa import Combine +import OSLog /// A Cocoa view that manages the menu bar layout interface. final class LayoutBarPaddingView: NSView { @@ -99,7 +100,7 @@ final class LayoutBarPaddingView: NSView { if arrangedViews.count == 1 { // dragging source is the only view in the layout bar, so we // need to find a target item - let items = MenuBarItem.getMenuBarItems(onScreenOnly: false, activeSpaceOnly: true) + let items = MenuBarItem.getMenuBarItems(option: .activeSpace) let targetItem: MenuBarItem? = switch section.name { case .visible: nil // visible section always has more than 1 item case .hidden: items.first(matching: .hiddenControlItem) @@ -108,7 +109,7 @@ final class LayoutBarPaddingView: NSView { if let targetItem { move(item: draggingSource.item, to: .leftOfItem(targetItem)) } else { - Logger.layoutBar.error("No target item for layout bar drag") + Logger.default.error("No target item for layout bar drag") } } else if arrangedViews.indices.contains(index + 1) { // we have a view to the right of the dragging source @@ -134,15 +135,10 @@ final class LayoutBarPaddingView: NSView { try await appState.itemManager.slowMove(item: item, to: destination) appState.itemManager.removeTempShownItemFromCache(with: item.info) } catch { - Logger.layoutBar.error("Error moving menu bar item: \(error)") + Logger.default.error("Error moving menu bar item: \(error, privacy: .public)") let alert = NSAlert(error: error) alert.runModal() } } } } - -// MARK: - Logger -private extension Logger { - static let layoutBar = Logger(category: "LayoutBar") -} diff --git a/Ice/UserNotifications/UserNotificationManager.swift b/Ice/UserNotifications/UserNotificationManager.swift index 8aa9bbab6..42890e121 100644 --- a/Ice/UserNotifications/UserNotificationManager.swift +++ b/Ice/UserNotifications/UserNotificationManager.swift @@ -3,6 +3,7 @@ // Ice // +import OSLog import UserNotifications /// Manager for user notifications. @@ -31,7 +32,7 @@ final class UserNotificationManager: NSObject { do { try await notificationCenter.requestAuthorization(options: [.badge, .alert, .sound]) } catch { - Logger.userNotifications.error("Failed to request authorization for notifications: \(error)") + Logger.default.error("Failed to request notification authorization: \(error)") } } } @@ -83,8 +84,3 @@ extension UserNotificationManager: @preconcurrency UNUserNotificationCenterDeleg } } } - -// MARK: - Logger -private extension Logger { - static let userNotifications = Logger(category: "UserNotifications") -} diff --git a/Ice/Utilities/Extensions.swift b/Ice/Utilities/Extensions.swift index d624230c1..4bc101b25 100644 --- a/Ice/Utilities/Extensions.swift +++ b/Ice/Utilities/Extensions.swift @@ -492,13 +492,6 @@ extension Publisher { // MARK: - Sequence where Element == MenuBarItem extension Sequence where Element == MenuBarItem { - /// Returns the menu bar items, sorted by their order in the menu bar. - func sortedByOrderInMenuBar() -> [MenuBarItem] { - sorted { lhs, rhs in - lhs.frame.maxX < rhs.frame.maxX - } - } - /// Returns the first menu bar item that matches the specified info. func first(matching info: MenuBarItemInfo) -> MenuBarItem? { first { $0.info == info } diff --git a/Ice/Utilities/Logging.swift b/Ice/Utilities/Logging.swift index e0dfcf303..18d0251a1 100644 --- a/Ice/Utilities/Logging.swift +++ b/Ice/Utilities/Logging.swift @@ -5,33 +5,19 @@ import OSLog -/// A type that encapsulates logging behavior for Ice. -struct Logger { - /// The unified logger at the base of this logger. - private let base: os.Logger - - /// Creates a logger for Ice using the specified category. +extension Logger { + /// Creates a logger using the specified category. init(category: String) { - self.base = os.Logger(subsystem: Constants.bundleIdentifier, category: category) + self.init(subsystem: Constants.bundleIdentifier, category: category) } +} - /// Logs the given informative message to the logger. - func info(_ message: String) { - base.info("\(message, privacy: .public)") - } +// MARK: - Shared Loggers - /// Logs the given debug message to the logger. - func debug(_ message: String) { - base.debug("\(message, privacy: .public)") - } +extension Logger { + /// The default logger. + static let `default` = Logger(.default) - /// Logs the given error message to the logger. - func error(_ message: String) { - base.error("\(message, privacy: .public)") - } - - /// Logs the given warning message to the logger. - func warning(_ message: String) { - base.warning("\(message, privacy: .public)") - } + /// The logger for serialization operations. + static let serialization = Logger(category: "Serialization") } diff --git a/Ice/Utilities/MigrationManager.swift b/Ice/Utilities/MigrationManager.swift index 0141e0bf2..cb689bc4c 100644 --- a/Ice/Utilities/MigrationManager.swift +++ b/Ice/Utilities/MigrationManager.swift @@ -4,9 +4,14 @@ // import Cocoa +import OSLog +// FIXME: Migration has gotten extremely messy. It should really just be completely redone at this point. +// TODO: Decide what needs to stay in the new implementation, and what has been around long enough that it can be removed. @MainActor struct MigrationManager { + private let logger = Logger(category: "Migration") + let appState: AppState let encoder = JSONEncoder() let decoder = JSONDecoder() @@ -16,38 +21,36 @@ struct MigrationManager { extension MigrationManager { /// Performs all migrations. - static func migrateAll(appState: AppState) { - let manager = MigrationManager(appState: appState) + func migrateAll() { + var results = [MigrationResult]() do { try performAll(blocks: [ - manager.migrate0_8_0, - manager.migrate0_10_0, + migrate0_8_0, + migrate0_10_0, ]) + } catch let error as MigrationError { + results.append(.failureAndLogError(error)) } catch { - logError(error) + logger.error("Migration failed with unknown error \(error)") } - let results = [ - manager.migrate0_10_1(), - manager.migrate0_11_10(), + results += [ + migrate0_10_1(), + migrate0_11_10(), ] for result in results { switch result { case .success: - break + continue case .successButShowAlert(let alert): alert.runModal() case .failureAndLogError(let error): - logError(error) + logger.error("Migration failed with error \(error, privacy: .public)") } } } - - private static func logError(_ error: any Error) { - Logger.migration.error("Migration failed with error: \(error)") - } } // MARK: - Migrate 0.8.0 @@ -59,13 +62,13 @@ extension MigrationManager { guard !Defaults.bool(forKey: .hasMigrated0_8_0) else { return } - try MigrationManager.performAll(blocks: [ + try performAll(blocks: [ migrateHotkeys0_8_0, migrateControlItems0_8_0, migrateSections0_8_0, ]) Defaults.set(true, forKey: .hasMigrated0_8_0) - Logger.migration.info("Successfully migrated to 0.8.0 settings") + logger.info("Successfully migrated to 0.8.0 settings") } // MARK: Migrate Hotkeys @@ -186,11 +189,11 @@ extension MigrationManager { guard !Defaults.bool(forKey: .hasMigrated0_10_0) else { return } - try MigrationManager.performAll(blocks: [ + try performAll(blocks: [ migrateControlItems0_10_0, ]) Defaults.set(true, forKey: .hasMigrated0_10_0) - Logger.migration.info("Successfully migrated to 0.10.0 settings") + logger.info("Successfully migrated to 0.10.0 settings") } private func migrateControlItems0_10_0() throws { @@ -216,7 +219,7 @@ extension MigrationManager { switch result { case .success, .successButShowAlert: Defaults.set(true, forKey: .hasMigrated0_10_1) - Logger.migration.info("Successfully migrated to 0.10.1 settings") + logger.info("Successfully migrated to 0.10.1 settings") case .failureAndLogError: break } @@ -242,8 +245,10 @@ extension MigrationManager { } let alert = NSAlert() - alert.messageText = "Due to a bug in the 0.10.0 release, the data for Ice's menu bar items was corrupted and their positions had to be reset." - alert.informativeText = "Our sincerest apologies for the inconvenience." + alert.messageText = """ + Due to a bug in a previous version of the app, the data for \ + Ice’s menu bar sections was corrupted and had to be reset. + """ return .successButShowAlert(alert) } @@ -255,6 +260,7 @@ extension MigrationManager { // MARK: - Migrate 0.11.10 extension MigrationManager { + /// Performs all migrations for the `0.11.10` release. private func migrate0_11_10() -> MigrationResult { guard !Defaults.bool(forKey: .hasMigrated0_11_10) else { return .success @@ -263,7 +269,7 @@ extension MigrationManager { switch result { case .success, .successButShowAlert: Defaults.set(true, forKey: .hasMigrated0_11_10) - Logger.migration.info("Successfully migrated to 0.11.10 settings") + logger.info("Successfully migrated to 0.11.10 settings") case .failureAndLogError: break } @@ -272,7 +278,12 @@ extension MigrationManager { private func migrateAppearanceConfiguration0_11_10() -> MigrationResult { guard let oldData = Defaults.data(forKey: .menuBarAppearanceConfiguration) else { - return .failureAndLogError(.appearanceConfigurationMigrationError(.missingConfiguration)) + if Defaults.object(forKey: .menuBarAppearanceConfiguration) != nil { + logger.warning("Previous menu bar appearance data is corrupted.") + } + // This is either the first launch, or the data is malformed. + // Either way, not much to do here. + return .success } do { let oldConfiguration = try decoder.decode(MenuBarAppearanceConfigurationV1.self, from: oldData) @@ -297,7 +308,7 @@ extension MigrationManager { let newData = try encoder.encode(newConfiguration) Defaults.set(newData, forKey: .menuBarAppearanceConfigurationV2) } catch { - return .failureAndLogError(.appearanceConfigurationMigrationError(.otherError(error))) + return .failureAndLogError(.appearanceConfigurationMigrationError(error)) } return .success } @@ -308,7 +319,7 @@ extension MigrationManager { extension MigrationManager { /// Performs every block in the given array, catching any thrown /// errors and rethrowing them as a combined error. - private static func performAll(blocks: [() throws -> Void]) throws { + private func performAll(blocks: [() throws -> Void]) throws { let results = blocks.map { block in Result(catching: block) } @@ -347,14 +358,14 @@ extension MigrationManager { } } -// MARK: - Errors +// MARK: - MigrationError extension MigrationManager { enum MigrationError: Error, CustomStringConvertible { case invalidMenuBarSectionsJSONObject(Any) case hotkeyMigrationError(any Error) case controlItemMigrationError(any Error) - case appearanceConfigurationMigrationError(AppearanceConfigurationMigrationError) + case appearanceConfigurationMigrationError(any Error) case combinedError([any Error]) var description: String { @@ -372,20 +383,6 @@ extension MigrationManager { } } } - - enum AppearanceConfigurationMigrationError: Error, CustomStringConvertible { - case otherError(any Error) - case missingConfiguration - - var description: String { - switch self { - case .otherError(let error): - error.localizedDescription - case .missingConfiguration: - "Missing menu bar appearance configuration" - } - } - } } // MARK: - ControlItem.Identifier Extension @@ -411,8 +408,3 @@ private extension MenuBarSection.Name { } } } - -// MARK: - Logger -private extension Logger { - static let migration = Logger(category: "Migration") -} diff --git a/Ice/Utilities/MouseHelpers.swift b/Ice/Utilities/MouseHelpers.swift index 41a7ae62d..8a261b6ed 100644 --- a/Ice/Utilities/MouseHelpers.swift +++ b/Ice/Utilities/MouseHelpers.swift @@ -4,6 +4,7 @@ // import CoreGraphics +import OSLog /// A namespace for mouse cursor operations. enum MouseCursor { @@ -23,7 +24,7 @@ enum MouseCursor { static func hide() { let result = CGDisplayHideCursor(CGMainDisplayID()) if result != .success { - Logger.mouseCursor.error("CGDisplayHideCursor failed with error \(result.logString)") + Logger.default.error("CGDisplayHideCursor failed with error \(result.logString, privacy: .public)") } } @@ -31,7 +32,7 @@ enum MouseCursor { static func show() { let result = CGDisplayShowCursor(CGMainDisplayID()) if result != .success { - Logger.mouseCursor.error("CGDisplayShowCursor failed with error \(result.logString)") + Logger.default.error("CGDisplayShowCursor failed with error \(result.logString, privacy: .public)") } } @@ -41,7 +42,7 @@ enum MouseCursor { static func warp(to point: CGPoint) { let result = CGWarpMouseCursorPosition(point) if result != .success { - Logger.mouseCursor.error("CGWarpMouseCursorPosition failed with error \(result.logString)") + Logger.default.error("CGWarpMouseCursorPosition failed with error \(result.logString, privacy: .public)") } } } @@ -83,8 +84,3 @@ enum MouseEvents { return .seconds(seconds) <= duration } } - -// MARK: - Logger -private extension Logger { - static let mouseCursor = Logger(category: "MouseCursor") -} diff --git a/Ice/Utilities/ScreenCapture.swift b/Ice/Utilities/ScreenCapture.swift index efa69aab4..d46ecc009 100644 --- a/Ice/Utilities/ScreenCapture.swift +++ b/Ice/Utilities/ScreenCapture.swift @@ -3,49 +3,54 @@ // Ice // +// MARK: - ScreenCapture + import CoreGraphics import ScreenCaptureKit /// A namespace for screen capture operations. enum ScreenCapture { - /// Returns a Boolean value that indicates whether the app has been granted screen capture permissions. + + // MARK: Permissions + + /// Returns a Boolean value that indicates whether the app has screen capture permissions. static func checkPermissions() -> Bool { - for item in MenuBarItem.getMenuBarItems(onScreenOnly: false, activeSpaceOnly: true) { - // Don't check items owned by Ice. - if item.owningApplication == .current { + for windowID in Bridging.getWindowList(option: [.menuBarItems, .activeSpace]) { + guard + let window = WindowInfo(windowID: windowID), + window.owningApplication != .current // Skip windows we own. + else { continue } - return item.title != nil + return window.title != nil } - // CGPreflightScreenCaptureAccess() only returns an initial value for whether the app - // has permissions, but we can use it as a fallback. + // CGPreflightScreenCaptureAccess() only returns an initial value, but we can + // use it as a fallback. return CGPreflightScreenCaptureAccess() } - /// Returns a Boolean value that indicates whether the app has been granted screen capture permissions. + /// Returns a Boolean value that indicates whether the app has screen capture permissions. /// - /// The first time this function is called, the permissions state is computed, cached, and returned. - /// Subsequent calls either return the cached value, or recompute the permissions state before caching - /// and returning it. + /// This function caches its initial result and returns it on subsequent calls. Pass `true` + /// to the `reset` parameter to replace the cached result with a newly computed value. static func cachedCheckPermissions(reset: Bool = false) -> Bool { enum Context { - static var lastCheckResult: Bool? + static var cachedResult: Bool? } - if !reset { - if let lastCheckResult = Context.lastCheckResult { - return lastCheckResult - } + if !reset, let result = Context.cachedResult { + return result } - let realResult = checkPermissions() - Context.lastCheckResult = realResult - return realResult + let result = checkPermissions() + Context.cachedResult = result + return result } /// Requests screen capture permissions. static func requestPermissions() { if #available(macOS 15.0, *) { + // TODO: Find out if we still need this. // CGRequestScreenCaptureAccess() is broken on macOS 15. SCShareableContent requires // screen capture permissions, and triggers a request if the user doesn't have them. SCShareableContent.getWithCompletionHandler { _, _ in } @@ -54,45 +59,58 @@ enum ScreenCapture { } } + // MARK: Capture Window(s) + /// Captures a composite image of an array of windows. /// + /// The windows are composited from front to back, according to the order of the `windowIDs` + /// parameter. + /// /// - Parameters: /// - windowIDs: The identifiers of the windows to capture. - /// - screenBounds: The bounds to capture. Pass `nil` to capture the minimum rectangle that encloses the windows. - /// - option: Options that specify the image to be captured. + /// - screenBounds: The bounds to capture, specified in screen coordinates. Pass `nil` to + /// capture the minimum rectangle that encloses the windows. + /// - option: Options that specify which parts of the windows are captured. static func captureWindows(_ windowIDs: [CGWindowID], screenBounds: CGRect? = nil, option: CGWindowImageOption = []) -> CGImage? { - let pointer = UnsafeMutablePointer.allocate(capacity: windowIDs.count) - for (index, windowID) in windowIDs.enumerated() { - pointer[index] = UnsafeRawPointer(bitPattern: UInt(windowID)) + var pointers: [UnsafeRawPointer?] = windowIDs.reduce(into: []) { result, windowID in + guard let pointer = UnsafeRawPointer(bitPattern: UInt(windowID)) else { + return + } + result.append(pointer) } - guard let windowArray = CFArrayCreate(kCFAllocatorDefault, pointer, windowIDs.count, nil) else { + guard let windowArray = CFArrayCreate(nil, &pointers, pointers.count, nil) else { return nil } - return .windowListImage(from: screenBounds ?? .null, windowArray: windowArray, imageOption: option) + let screenBounds = screenBounds ?? .null + return CGImage.windowListImage(from: screenBounds, windowArray: windowArray, imageOption: option) } /// Captures an image of a window. /// /// - Parameters: /// - windowID: The identifier of the window to capture. - /// - screenBounds: The bounds to capture. Pass `nil` to capture the minimum rectangle that encloses the window. - /// - option: Options that specify the image to be captured. + /// - screenBounds: The bounds to capture, specified in screen coordinates. Pass `nil` to + /// capture the minimum rectangle that encloses the window. + /// - option: Options that specify which parts of the window are captured. static func captureWindow(_ windowID: CGWindowID, screenBounds: CGRect? = nil, option: CGWindowImageOption = []) -> CGImage? { - captureWindows([windowID], screenBounds: screenBounds, option: option) + return captureWindows([windowID], screenBounds: screenBounds, option: option) } } -/// A protocol used to suppress deprecation warnings for the `CGWindowList` screen capture APIs. +// MARK: - WindowListImage Helper + +/// A protocol to suppress warnings for the deprecated CGWindowList screen capture APIs. /// -/// ScreenCaptureKit doesn't support capturing composite images of offscreen menu bar items, but -/// this should be replaced once it does. +/// ScreenCaptureKit doesn't support capturing composite images of offscreen menu bar items. +/// This should be replaced once it does. private protocol WindowListImage { init?(windowListFromArrayScreenBounds: CGRect, windowArray: CFArray, imageOption: CGWindowImageOption) } private extension WindowListImage { + @inline(__always) // Ensure a direct call to the initializer. static func windowListImage(from screenBounds: CGRect, windowArray: CFArray, imageOption: CGWindowImageOption) -> Self? { - Self(windowListFromArrayScreenBounds: screenBounds, windowArray: windowArray, imageOption: imageOption) + return Self(windowListFromArrayScreenBounds: screenBounds, windowArray: windowArray, imageOption: imageOption) } } From b6eff36d39ec28792304b9a323ff534953538e4e Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Thu, 26 Jun 2025 15:01:54 -0600 Subject: [PATCH 14/80] macOS 26: `ControlItem` changes Should also fix a crash when accessing `ControlItem.windowNumber`. --- Ice/Events/EventManager.swift | 11 +-- .../Appearance/MenuBarOverlayPanel.swift | 2 +- Ice/MenuBar/ControlItem/ControlItem.swift | 72 ++++++++++++++----- Ice/UI/IceBar/IceBar.swift | 72 ++++++++++--------- 4 files changed, 99 insertions(+), 58 deletions(-) diff --git a/Ice/Events/EventManager.swift b/Ice/Events/EventManager.swift index 196b62d0d..a5686798b 100644 --- a/Ice/Events/EventManager.swift +++ b/Ice/Events/EventManager.swift @@ -110,7 +110,7 @@ final class EventManager { // frame of the hidden section's control item, which we know will always be in the // menu bar, and run the show-on-hover check when it changes. Publishers.CombineLatest3( - hiddenSection.controlItem.$windowFrame, + hiddenSection.controlItem.$frame, appState.$isActiveSpaceFullscreen, appState.menuBarManager.$isMenuBarHiddenBySystem ) @@ -190,8 +190,9 @@ extension EventManager { return } - if let visibleSection = appState.menuBarManager.section(withName: .visible) { - guard event.window !== visibleSection.controlItem.window else { + // Make sure clicking the Ice icon doesn't trigger rehide. + if let iceIcon = appState.menuBarManager.controlItem(withName: .visible) { + guard event.window !== iceIcon.window else { return } } @@ -448,7 +449,7 @@ extension EventManager { // that the menu bar is hidden and the mouse is not inside. guard let iceIcon = appState.menuBarManager.controlItem(withName: .visible), - let iceIconFrame = iceIcon.windowFrame, + let iceIconFrame = iceIcon.frame, iceIconFrame.maxY <= screen.frame.maxY, let mouseLocation = MouseCursor.locationAppKit else { @@ -533,7 +534,7 @@ extension EventManager { func isMouseInsideIceIcon(appState: AppState) -> Bool { guard let visibleSection = appState.menuBarManager.section(withName: .visible), - let iceIconFrame = visibleSection.controlItem.windowFrame, + let iceIconFrame = visibleSection.controlItem.frame, let mouseLocation = MouseCursor.locationAppKit else { return false diff --git a/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift b/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift index 14bf9f256..c2f651695 100644 --- a/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift +++ b/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift @@ -405,7 +405,7 @@ private final class MenuBarOverlayPanelContentView: NSView { // are actually updated on-screen. Since the view's drawing process relies // on getting an accurate position of each menu bar item, we need to use // something that publishes its changes only after the items are updated. - section.controlItem.$windowFrame + section.controlItem.$onScreenFrame .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.needsDisplay = true diff --git a/Ice/MenuBar/ControlItem/ControlItem.swift b/Ice/MenuBar/ControlItem/ControlItem.swift index 61d3aa6b0..db97b2d78 100644 --- a/Ice/MenuBar/ControlItem/ControlItem.swift +++ b/Ice/MenuBar/ControlItem/ControlItem.swift @@ -42,8 +42,20 @@ final class ControlItem { /// A Boolean value that indicates whether the control item is visible (`@Published`). @Published var isVisible = true - /// The frame of the control item's window (`@Published`). - @Published private(set) var windowFrame: CGRect? + /// The control item's window (`@Published`). + @Published private(set) var window: NSWindow? + + /// The control item's frame (`@Published`). + @Published private(set) var frame: CGRect? + + /// The control item's screen (`@Published`). + @Published private(set) var screen: NSScreen? + + /// The control item's frame, if it is onscreen (`@Published`). + @Published private(set) var onScreenFrame: CGRect? + + /// The control item's identifier. + let identifier: Identifier /// The shared app state. private weak var appState: AppState? @@ -54,9 +66,6 @@ final class ControlItem { /// A horizontal constraint for the control item's content view. private let constraint: NSLayoutConstraint? - /// The control item's identifier. - private let identifier: Identifier - /// Storage for internal observers. private var cancellables = Set() @@ -65,11 +74,6 @@ final class ControlItem { appState?.menuBarManager.sections.first { $0.controlItem === self } } - /// The control item's window. - var window: NSWindow? { - statusItem.button?.window - } - // /// The identifier of the control item's window. // var windowID: CGWindowID? { // guard let window else { @@ -231,19 +235,49 @@ final class ControlItem { } .store(in: &c) - window?.publisher(for: \.frame) + statusItem.publisher(for: \.button) + .compactMap { $0 } + .flatMap { $0.publisher(for: \.window) } + .sink { [weak self] window in + self?.window = window + } + .store(in: &c) + + $window + .compactMap { $0 } + .flatMap { $0.publisher(for: \.frame) } .sink { [weak self] frame in - guard - let self, - let screen = window?.screen, - screen.frame.intersects(frame) - else { - return - } - windowFrame = frame + self?.frame = frame + } + .store(in: &c) + + $window + .compactMap { $0 } + .flatMap { $0.publisher(for: \.screen) } + .sink { [weak self] screen in + self?.screen = screen } .store(in: &c) + Publishers.CombineLatest( + $frame + .compactMap { $0 }, + $screen + .compactMap { $0 } + .flatMap { $0.publisher(for: \.frame) } + ) + .sink { [weak self] frame, screenFrame in + guard let self else { + return + } + if screenFrame.intersects(frame) { + onScreenFrame = frame + } else { + onScreenFrame = nil + } + } + .store(in: &c) + if let appState { appState.settingsManager.generalSettingsManager.$useIceBar .receive(on: DispatchQueue.main) diff --git a/Ice/UI/IceBar/IceBar.swift b/Ice/UI/IceBar/IceBar.swift index 222632ab5..1aba314a9 100644 --- a/Ice/UI/IceBar/IceBar.swift +++ b/Ice/UI/IceBar/IceBar.swift @@ -44,56 +44,52 @@ final class IceBarPanel: NSPanel { private func configureCancellables() { var c = Set() - // Close the panel when the active space changes, or when the screen parameters change. + // Hide the panel when the active space or screen parameters change. Publishers.Merge( NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.activeSpaceDidChangeNotification), NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification) ) .sink { [weak self] _ in - self?.close() + self?.hide() } .store(in: &c) - if - let section = appState?.menuBarManager.section(withName: .hidden), - let window = section.controlItem.window - { - window.publisher(for: \.frame) - .debounce(for: 0.1, scheduler: DispatchQueue.main) - .sink { [weak self, weak window] _ in - guard - let self, - let appState, - // Only continue if the menu bar is automatically hidden, as Ice - // can't currently display its menu bar items. - appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults, - let info = window.flatMap({ WindowInfo(windowID: CGWindowID($0.windowNumber)) }), - // Window being offscreen means the menu bar is currently hidden. - // Close the bar, as things will start to look weird if we don't. - !info.isOnScreen - else { - return - } - close() - } - .store(in: &c) - } - // Update the panel's origin whenever its size changes. - publisher(for: \.frame) - .map(\.size) + publisher(for: \.frame).map(\.size) .removeDuplicates() .sink { [weak self] _ in - guard - let self, - let screen - else { + guard let self, let screen else { return } updateOrigin(for: screen) } .store(in: &c) + if let controlItem = appState?.menuBarManager.controlItem(withName: .hidden) { + // Use the hidden control item's frame to determine if the menu bar + // is hidden. Hide the panel if so. + controlItem.$frame + .combineLatest(controlItem.$screen) + .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) + .sink { [weak self] (frame, screen) in + guard let self else { + return + } + + guard let frame, let screen else { + hide() + return + } + + // Icon is not vertically visible. We can infer that the + // menu bar is hidden. + if frame.maxY > screen.frame.maxY { + hide() + } + } + .store(in: &c) + } + cancellables = c } @@ -178,6 +174,16 @@ final class IceBarPanel: NSPanel { orderFrontRegardless() } + func hide() { + if + let name = currentSection, + let section = appState?.menuBarManager.section(withName: name) + { + section.hide() + } + close() + } + override func close() { super.close() contentView = nil From 7edd1c6d3d6e40d355a36c79f6abd06e4a676b58 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 27 Jun 2025 14:48:57 -0600 Subject: [PATCH 15/80] Settings and state reworks - Rework app lifecycle - Rework how windows are initialized - Update documentation comments - Refactoring and cleanup --- Ice/Main/AppDelegate.swift | 90 ++++----- Ice/Main/AppState.swift | 178 +++++++----------- Ice/Main/IceApp.swift | 11 +- .../Appearance/MenuBarOverlayPanel.swift | 13 +- .../MenuBarItems/MenuBarItemImageCache.swift | 8 +- Ice/MenuBar/MenuBarManager.swift | 21 ++- Ice/MenuBar/Search/MenuBarSearchPanel.swift | 3 +- Ice/Permissions/PermissionsManager.swift | 77 ++++---- Ice/Permissions/PermissionsView.swift | 71 +++---- Ice/Permissions/PermissionsWindow.swift | 15 +- Ice/Settings/SettingsWindow.swift | 8 +- Ice/UI/IceBar/IceBar.swift | 3 +- Ice/UI/IceBar/IceBarColorManager.swift | 8 +- Ice/UI/IceUI/IceWindow.swift | 105 +++++++++++ .../CustomGradientPicker.swift | 2 +- Ice/UI/ViewModifiers/OnWindowChange.swift | 60 ++++++ Ice/UI/ViewModifiers/Once.swift | 53 +++++- Ice/UI/ViewModifiers/ReadWindow.swift | 56 ------ Ice/UI/Views/CalloutBox.swift | 141 ++++++++++++++ Ice/Utilities/Extensions.swift | 45 ++++- 20 files changed, 614 insertions(+), 354 deletions(-) create mode 100644 Ice/UI/IceUI/IceWindow.swift create mode 100644 Ice/UI/ViewModifiers/OnWindowChange.swift delete mode 100644 Ice/UI/ViewModifiers/ReadWindow.swift create mode 100644 Ice/UI/Views/CalloutBox.swift diff --git a/Ice/Main/AppDelegate.swift b/Ice/Main/AppDelegate.swift index 5cbb86af0..40c6a1d09 100644 --- a/Ice/Main/AppDelegate.swift +++ b/Ice/Main/AppDelegate.swift @@ -3,40 +3,27 @@ // Ice // -import SwiftUI import OSLog +import SwiftUI @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { - private weak var appState: AppState? + /// The shared app state. + let appState = AppState() + /// Logger for the delegate. private let logger = Logger(category: "AppDelegate") // MARK: NSApplicationDelegate Methods func applicationWillFinishLaunching(_ notification: Notification) { - guard let appState else { - logger.warning("Missing app state in applicationWillFinishLaunching") - return - } - - // Assign the delegate to the shared app state. - appState.assignAppDelegate(self) - - // Allow the app to set the cursor in the background. - appState.setsCursorInBackground = true + // Initial chore work. + NSSplitViewItem.swizzle() + MigrationManager(appState: appState).migrateAll() + Bridging.setConnectionProperty(true, forKey: "SetsCursorInBackground") } func applicationDidFinishLaunching(_ notification: Notification) { - guard let appState else { - logger.warning("Missing app state in applicationDidFinishLaunching") - return - } - - // Dismiss the windows. - appState.dismissSettingsWindow() - appState.dismissPermissionsWindow() - // Hide the main menu to make more space in the menu bar. if let mainMenu = NSApp.mainMenu { for item in mainMenu.items { @@ -44,27 +31,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } - // Perform setup after a small delay to ensure that the settings window - // has been assigned. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - guard !appState.isPreview else { - return - } - // If we have the required permissions, set up the shared app state. - // Otherwise, open the permissions window. - switch appState.permissionsManager.permissionsState { - case .hasAllPermissions, .hasRequiredPermissions: - appState.performSetup() - case .missingPermissions: - appState.activate(withPolicy: .regular) - appState.openPermissionsWindow() - } + #if DEBUG + // Stop here if running as a preview. + if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { + return + } + #endif + + // Depending on the permissions state, either perform setup + // or prompt to grant permissions. + switch appState.permissionsManager.permissionsState { + case .hasAll: + appState.permissionsManager.logger.info("Passed all permissions checks") + appState.performSetup(hasPermissions: true) + case .hasRequired: + appState.permissionsManager.logger.info("Passed required permissions checks") + appState.performSetup(hasPermissions: true) + case .missing: + appState.permissionsManager.logger.info("Failed required permissions checks") + appState.performSetup(hasPermissions: false) } } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - // Deactivate and set the policy to accessory when all windows are closed. - appState?.deactivate(withPolicy: .accessory) + if + sender.isActive, + sender.activationPolicy() != .accessory, + appState.navigationState.isAppFrontmost + { + logger.debug("All windows closed - deactivating") + appState.deactivate(withPolicy: .accessory) + } return false } @@ -74,25 +71,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: Other Methods - /// Assigns the app state to the delegate. - func assignAppState(_ appState: AppState) { - guard self.appState == nil else { - logger.warning("Multiple attempts made to assign app state") - return - } - self.appState = appState - } - /// Opens the settings window and activates the app. @objc func openSettingsWindow() { - guard let appState else { - logger.error("Failed to open settings window") - return - } // Small delay makes this more reliable. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - appState.activate(withPolicy: .regular) - appState.openSettingsWindow() + self.appState.activate(withPolicy: .regular) + self.appState.openWindow(.settings) } } } diff --git a/Ice/Main/AppState.swift b/Ice/Main/AppState.swift index 44517b3ad..c86ec05a8 100644 --- a/Ice/Main/AppState.swift +++ b/Ice/Main/AppState.swift @@ -4,8 +4,8 @@ // import Combine -import SwiftUI import OSLog +import SwiftUI /// The model for app-wide state. @MainActor @@ -49,37 +49,42 @@ final class AppState: ObservableObject { /// The app's hotkey registry. nonisolated let hotkeyRegistry = HotkeyRegistry() - /// The app's delegate. - private(set) weak var appDelegate: AppDelegate? - - /// The window that contains the settings interface. - private(set) weak var settingsWindow: NSWindow? - - /// The window that contains the permissions interface. - private(set) weak var permissionsWindow: NSWindow? - /// Storage for internal observers. private var cancellables = Set() /// Logger for the app state. private let logger = Logger(category: "AppState") - /// A Boolean value that indicates whether the app is running as a SwiftUI preview. - let isPreview: Bool = { - #if DEBUG - let environment = ProcessInfo.processInfo.environment - let key = "XCODE_RUNNING_FOR_PREVIEWS" - return environment[key] != nil - #else - return false - #endif + /// Setup actions, run once on first access. + private lazy var setupActions: () = { + logger.info("Running setup actions") + configureCancellables() + permissionsManager.stopAllChecks() + menuBarManager.performSetup() + appearanceManager.performSetup() + eventManager.performSetup() + settingsManager.performSetup() + itemManager.performSetup() + imageCache.performSetup() + updatesManager.performSetup() + userNotificationManager.performSetup() }() - /// A Boolean value that indicates whether the application can set the cursor - /// in the background. - var setsCursorInBackground: Bool { - get { Bridging.getConnectionProperty(forKey: "SetsCursorInBackground") as? Bool ?? false } - set { Bridging.setConnectionProperty(newValue, forKey: "SetsCursorInBackground") } + /// Performs app state setup. + /// + /// - Parameter hasPermissions: If `true`, continues with setup normally. + /// If `false`, prompts the user to grant permissions. + func performSetup(hasPermissions: Bool) { + if hasPermissions { + _ = setupActions + } else { + Task { + // Delay to prevent conflicts with the app delegate. + try await Task.sleep(for: .milliseconds(100)) + activate(withPolicy: .regular) + openWindow(.permissions) + } + } } /// Configures the internal observers for the app state. @@ -89,17 +94,17 @@ final class AppState: ObservableObject { Publishers.Merge3( NSWorkspace.shared.notificationCenter .publisher(for: NSWorkspace.activeSpaceDidChangeNotification) - .mapToVoid(), + .replace(with: ()), // Frontmost application change can indicate a space change from one display to // another, which gets ignored by NSWorkspace.activeSpaceDidChangeNotification. NSWorkspace.shared .publisher(for: \.frontmostApplication) - .mapToVoid(), + .replace(with: ()), // Clicking into a fullscreen space from another space is also ignored. UniversalEventMonitor .publisher(for: .leftMouseDown) .delay(for: 0.1, scheduler: DispatchQueue.main) - .mapToVoid() + .replace(with: ()) ) .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -120,30 +125,27 @@ final class AppState: ObservableObject { } .store(in: &c) - if let settingsWindow { - settingsWindow.publisher(for: \.isVisible) - .debounce(for: 0.05, scheduler: DispatchQueue.main) - .sink { [weak self] isVisible in - guard let self else { - return - } - navigationState.isSettingsPresented = isVisible + publisherForWindow(.settings) + .flatMap { $0.publisher } // Short circuit if nil. + .flatMap { $0.publisher(for: \.isVisible) } + .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) + .sink { [weak self] isVisible in + guard let self else { + return } - .store(in: &c) - } else { - logger.warning("No settings window!") - } + navigationState.isSettingsPresented = isVisible + } + .store(in: &c) - Publishers.Merge( + Publishers.CombineLatest( navigationState.$isAppFrontmost, navigationState.$isSettingsPresented ) - .debounce(for: 0.1, scheduler: DispatchQueue.main) + .map { $0 && $1 } + .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) + .merge(with: Just(true).delay(for: 1, scheduler: DispatchQueue.main)) .sink { [weak self] shouldUpdate in - guard - let self, - shouldUpdate - else { + guard let self, shouldUpdate else { return } Task.detached { @@ -178,51 +180,23 @@ final class AppState: ObservableObject { cancellables = c } - /// Sets up the app state. - func performSetup() { - configureCancellables() - permissionsManager.stopAllChecks() - menuBarManager.performSetup() - appearanceManager.performSetup() - eventManager.performSetup() - settingsManager.performSetup() - itemManager.performSetup() - imageCache.performSetup() - updatesManager.performSetup() - userNotificationManager.performSetup() - } - - /// Assigns the app delegate to the app state. - func assignAppDelegate(_ appDelegate: AppDelegate) { - guard self.appDelegate == nil else { - logger.warning("Multiple attempts made to assign app delegate") - return - } - self.appDelegate = appDelegate - } - - /// Assigns the settings window to the app state. - func assignSettingsWindow(_ window: NSWindow) { - guard window.identifier?.rawValue == Constants.settingsWindowID else { - logger.warning("Window \(window.identifier?.rawValue ?? "", privacy: .public) is not the settings window!") - return - } - settingsWindow = window - configureCancellables() - } - - /// Assigns the permissions window to the app state. - func assignPermissionsWindow(_ window: NSWindow) { - guard window.identifier?.rawValue == Constants.permissionsWindowID else { - logger.warning("Window \(window.identifier?.rawValue ?? "", privacy: .public) is not the permissions window!") - return + /// Returns a publisher for the window with the given identifier. + func publisherForWindow(_ id: IceWindowIdentifier) -> some Publisher { + return NSApp.publisher(for: \.windows).mergeMap { window in + window.publisher(for: \.identifier) + .map { [weak window] identifier in + guard identifier?.rawValue == id.rawValue else { + return nil + } + return window + } + .first { $0 != nil } + .replaceEmpty(with: nil) } - permissionsWindow = window - configureCancellables() } /// Opens the window with the given identifier. - func openWindow(id: String) { + func openWindow(_ id: IceWindowIdentifier) { // Defer to the next run loop to prevent conflicts with SwiftUI. DispatchQueue.main.async { self.logger.debug("Opening window with id: \(id, privacy: .public)") @@ -231,7 +205,7 @@ final class AppState: ObservableObject { } /// Dismisses the window with the given identifier. - func dismissWindow(id: String) { + func dismissWindow(_ id: IceWindowIdentifier) { // Defer to the next run loop to prevent conflicts with SwiftUI. DispatchQueue.main.async { self.logger.debug("Dismissing window with id: \(id, privacy: .public)") @@ -239,32 +213,12 @@ final class AppState: ObservableObject { } } - /// Opens the settings window. - func openSettingsWindow() { - openWindow(id: Constants.settingsWindowID) - } - - /// Dismisses the settings window. - func dismissSettingsWindow() { - dismissWindow(id: Constants.settingsWindowID) - } - - /// Opens the permissions window. - func openPermissionsWindow() { - openWindow(id: Constants.permissionsWindowID) - } - - /// Dismisses the permissions window. - func dismissPermissionsWindow() { - dismissWindow(id: Constants.permissionsWindowID) - } - - /// Activates the app and sets its activation policy to the given value. + /// Activates the app and sets its activation policy. func activate(withPolicy policy: NSApplication.ActivationPolicy) { - // What follows is NOT at all straightforward, but this seems to - // be about the only way to make app activation (mostly) reliable - // after activation changes made in macOS 14. + // What follows is NOT at all straightforward, but it seems to + // make app activation (mostly) reliable after changes made in + // macOS 14. let current = NSRunningApplication.current let workspace = NSWorkspace.shared @@ -289,7 +243,7 @@ final class AppState: ObservableObject { current.activate(from: frontmost) } - /// Deactivates the app and sets its activation policy to the given value. + /// Deactivates the app and sets its activation policy. func deactivate(withPolicy policy: NSApplication.ActivationPolicy) { NSApp.deactivate() NSApp.setActivationPolicy(policy) diff --git a/Ice/Main/IceApp.swift b/Ice/Main/IceApp.swift index ae9cf6910..6172fa360 100644 --- a/Ice/Main/IceApp.swift +++ b/Ice/Main/IceApp.swift @@ -8,16 +8,9 @@ import SwiftUI @main struct IceApp: App { @NSApplicationDelegateAdaptor var appDelegate: AppDelegate - @ObservedObject var appState = AppState() - - init() { - NSSplitViewItem.swizzle() - MigrationManager(appState: appState).migrateAll() - appDelegate.assignAppState(appState) - } var body: some Scene { - SettingsWindow(appState: appState) - PermissionsWindow(appState: appState) + SettingsWindow(appState: appDelegate.appState) + PermissionsWindow(appState: appDelegate.appState) } } diff --git a/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift b/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift index c2f651695..fa978e62c 100644 --- a/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift +++ b/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift @@ -182,10 +182,10 @@ final class MenuBarOverlayPanel: NSPanel { Publishers.Merge( publisher(for: \.isOnActiveSpace) .receive(on: DispatchQueue.main) - .mapToVoid(), + .replace(with: ()), UniversalEventMonitor.publisher(for: .leftMouseUp) .filter { [weak self] _ in self?.isOnActiveSpace ?? false } - .mapToVoid() + .replace(with: ()) ) .debounce(for: 0.05, scheduler: DispatchQueue.main) .sink { [weak self] in @@ -320,10 +320,7 @@ final class MenuBarOverlayPanel: NSPanel { /// Shows the panel. private func show() { - guard - let appState, - !appState.isPreview - else { + guard let appState else { return } @@ -452,8 +449,8 @@ private final class MenuBarOverlayPanelContentView: NSView { } // Redraw whenever the configurations change. - $fullConfiguration.mapToVoid() - .merge(with: $previewConfiguration.mapToVoid()) + $fullConfiguration.replace(with: ()) + .merge(with: $previewConfiguration.replace(with: ())) .sink { [weak self] _ in self?.needsDisplay = true } diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift index 847e37ba9..b360f83b3 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift @@ -46,19 +46,19 @@ final class MenuBarItemImageCache: ObservableObject { if let appState { Publishers.Merge3( // Update every 3 seconds at minimum. - Timer.publish(every: 3, on: .main, in: .default).autoconnect().mapToVoid(), + Timer.publish(every: 3, on: .main, in: .default).autoconnect().replace(with: ()), // Update when the active space or screen parameters change. Publishers.Merge( NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.activeSpaceDidChangeNotification), NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification) ) - .mapToVoid(), + .replace(with: ()), // Update when the average menu bar color or cached items change. Publishers.Merge( - appState.menuBarManager.$averageColorInfo.removeDuplicates().mapToVoid(), - appState.itemManager.$itemCache.removeDuplicates().mapToVoid() + appState.menuBarManager.$averageColorInfo.removeDuplicates().replace(with: ()), + appState.itemManager.$itemCache.removeDuplicates().replace(with: ()) ) ) .throttle(for: 0.5, scheduler: DispatchQueue.main, latest: false) diff --git a/Ice/MenuBar/MenuBarManager.swift b/Ice/MenuBar/MenuBarManager.swift index 5b6f7e98f..97055cfae 100644 --- a/Ice/MenuBar/MenuBarManager.swift +++ b/Ice/MenuBar/MenuBarManager.swift @@ -5,8 +5,8 @@ import AXSwift import Combine -import SwiftUI import OSLog +import SwiftUI /// Manager for the state of the menu bar. @MainActor @@ -26,6 +26,9 @@ final class MenuBarManager: ObservableObject { /// A Boolean value that indicates whether the "ShowOnHover" feature is allowed. @Published var showOnHoverAllowed = true + /// Reference to the settings window. + @Published private var settingsWindow: NSWindow? + /// Logger for the menu bar manager. private let logger = Logger(category: "MenuBarManager") @@ -50,7 +53,7 @@ final class MenuBarManager: ObservableObject { /// A Boolean value that indicates whether the manager can update its stored /// information for the menu bar's average color. private var canUpdateAverageColorInfo: Bool { - appState?.settingsWindow?.isVisible == true + settingsWindow?.isVisible == true } /// A Boolean value that indicates whether at least one of the manager's @@ -148,7 +151,15 @@ final class MenuBarManager: ObservableObject { } .store(in: &c) - appState?.settingsWindow?.publisher(for: \.isVisible) + appState?.publisherForWindow(.settings) + .sink { [weak self] window in + self?.settingsWindow = window + } + .store(in: &c) + + $settingsWindow + .flatMap { $0.publisher } // Short circuit if nil. + .flatMap { $0.publisher(for: \.isVisible) } .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updateAverageColorInfo() @@ -182,7 +193,7 @@ final class MenuBarManager: ObservableObject { appState.settingsManager.advancedSettingsManager.hideApplicationMenus, !isMenuBarHiddenBySystem, !appState.isActiveSpaceFullscreen, - appState.settingsWindow?.isVisible == false + !appState.navigationState.isSettingsPresented else { return } @@ -242,7 +253,7 @@ final class MenuBarManager: ObservableObject { func updateAverageColorInfo() { guard canUpdateAverageColorInfo, - let screen = appState?.settingsWindow?.screen + let screen = settingsWindow?.screen else { return } diff --git a/Ice/MenuBar/Search/MenuBarSearchPanel.swift b/Ice/MenuBar/Search/MenuBarSearchPanel.swift index 32a63df0d..74232d5b4 100644 --- a/Ice/MenuBar/Search/MenuBarSearchPanel.swift +++ b/Ice/MenuBar/Search/MenuBarSearchPanel.swift @@ -219,7 +219,8 @@ private struct MenuBarSearchContentView: View { HStack { SettingsButton { closePanel() - itemManager.appState?.appDelegate?.openSettingsWindow() + itemManager.appState?.activate(withPolicy: .regular) + itemManager.appState?.openWindow(.settings) } Spacer() diff --git a/Ice/Permissions/PermissionsManager.swift b/Ice/Permissions/PermissionsManager.swift index c991d0474..83c2ad52b 100644 --- a/Ice/Permissions/PermissionsManager.swift +++ b/Ice/Permissions/PermissionsManager.swift @@ -5,72 +5,71 @@ import Combine import Foundation +import OSLog /// A type that manages the permissions of the app. @MainActor final class PermissionsManager: ObservableObject { - /// The state of the granted permissions for the app. + /// The state of the app's granted permissions. enum PermissionsState { - case missingPermissions - case hasAllPermissions - case hasRequiredPermissions + case missing + case hasAll + case hasRequired } - /// The state of the granted permissions for the app. - @Published var permissionsState = PermissionsState.missingPermissions + /// The manager's logger. + let logger = Logger(category: "Permissions") - let accessibilityPermission: AccessibilityPermission + /// The permission for "Accessibility" features. + let accessibilityPermission = AccessibilityPermission() - let screenRecordingPermission: ScreenRecordingPermission + /// The permission for "Screen Recording" features. + let screenRecordingPermission = ScreenRecordingPermission() - let allPermissions: [Permission] + /// The state of the app's granted permissions. + @Published private(set) var permissionsState: PermissionsState = .missing + /// The shared app state. private(set) weak var appState: AppState? - private var cancellables = Set() + /// Storage for internal observers. + private var cancellable: AnyCancellable? + /// All permissions the app asks for. + var allPermissions: [Permission] { + [accessibilityPermission, screenRecordingPermission] + } + + /// The required permissions for basic app functionality. var requiredPermissions: [Permission] { allPermissions.filter { $0.isRequired } } + /// Creates a new permissions manager. init(appState: AppState) { self.appState = appState - self.accessibilityPermission = AccessibilityPermission() - self.screenRecordingPermission = ScreenRecordingPermission() - self.allPermissions = [ - accessibilityPermission, - screenRecordingPermission, - ] - configureCancellables() + self.updatePermissionsState() + self.cancellable = Publishers.MergeMany(allPermissions.map { $0.$hasPermission }) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updatePermissionsState() + } } - private func configureCancellables() { - var c = Set() - - Publishers.Merge( - accessibilityPermission.$hasPermission.mapToVoid(), - screenRecordingPermission.$hasPermission.mapToVoid() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] in - guard let self else { - return - } - if allPermissions.allSatisfy({ $0.hasPermission }) { - permissionsState = .hasAllPermissions - } else if requiredPermissions.allSatisfy({ $0.hasPermission }) { - permissionsState = .hasRequiredPermissions - } else { - permissionsState = .missingPermissions - } + /// Updates the current permissions state. + private func updatePermissionsState() { + if allPermissions.allSatisfy({ $0.hasPermission }) { + permissionsState = .hasAll + } else if requiredPermissions.allSatisfy({ $0.hasPermission }) { + permissionsState = .hasRequired + } else { + permissionsState = .missing } - .store(in: &c) - - cancellables = c } /// Stops running all permissions checks. func stopAllChecks() { + logger.info("Stopping all permissions checks") for permission in allPermissions { permission.stopCheck() } diff --git a/Ice/Permissions/PermissionsView.swift b/Ice/Permissions/PermissionsView.swift index 8b7430095..256d25c5a 100644 --- a/Ice/Permissions/PermissionsView.swift +++ b/Ice/Permissions/PermissionsView.swift @@ -6,11 +6,10 @@ import SwiftUI struct PermissionsView: View { - @EnvironmentObject var permissionsManager: PermissionsManager - @Environment(\.openWindow) private var openWindow + @EnvironmentObject private var manager: PermissionsManager private var continueButtonText: LocalizedStringKey { - if case .hasRequiredPermissions = permissionsManager.permissionsState { + if case .hasRequired = manager.permissionsState { "Continue in Limited Mode" } else { "Continue" @@ -18,10 +17,13 @@ struct PermissionsView: View { } private var continueButtonForegroundStyle: some ShapeStyle { - if case .hasRequiredPermissions = permissionsManager.permissionsState { - AnyShapeStyle(.yellow) - } else { + switch manager.permissionsState { + case .missing: + AnyShapeStyle(.secondary) + case .hasAll: AnyShapeStyle(.primary) + case .hasRequired: + AnyShapeStyle(.yellow) } } @@ -38,21 +40,6 @@ struct PermissionsView: View { } .padding(.horizontal) .fixedSize() - .readWindow { window in - guard let window else { - return - } - window.styleMask.remove([.closable, .miniaturizable]) - if let contentView = window.contentView { - with(contentView.safeAreaInsets) { insets in - insets.bottom = -insets.bottom - insets.left = -insets.left - insets.right = -insets.right - insets.top = -insets.top - contentView.additionalSafeAreaInsets = insets - } - } - } } @ViewBuilder @@ -88,7 +75,7 @@ struct PermissionsView: View { @ViewBuilder private var permissionsGroupStack: some View { VStack(spacing: 7.5) { - ForEach(permissionsManager.allPermissions) { permission in + ForEach(manager.allPermissions) { permission in permissionBox(permission) } } @@ -116,18 +103,29 @@ struct PermissionsView: View { @ViewBuilder private var continueButton: some View { Button { - guard let appState = permissionsManager.appState else { + guard let appState = manager.appState else { + return + } + + appState.dismissWindow(.permissions) + + guard manager.permissionsState != .missing else { + appState.performSetup(hasPermissions: false) return } - appState.performSetup() - appState.permissionsWindow?.close() - appState.appDelegate?.openSettingsWindow() + + appState.performSetup(hasPermissions: true) + + Task { + appState.activate(withPolicy: .regular) + appState.openWindow(.settings) + } } label: { Text(continueButtonText) .frame(maxWidth: .infinity) .foregroundStyle(continueButtonForegroundStyle) } - .disabled(permissionsManager.permissionsState == .missingPermissions) + .disabled(manager.permissionsState == .missing) } @ViewBuilder @@ -154,14 +152,14 @@ struct PermissionsView: View { } Button { - guard let appState = permissionsManager.appState else { + guard let appState = manager.appState else { return } permission.performRequest() Task { await permission.waitForPermission() appState.activate(withPolicy: .regular) - openWindow(id: Constants.permissionsWindowID) + appState.openWindow(.permissions) } } label: { if permission.hasPermission { @@ -174,18 +172,9 @@ struct PermissionsView: View { .allowsHitTesting(!permission.hasPermission) if !permission.isRequired { - IceGroupBox { - AnnotationView( - alignment: .center, - font: .callout.bold() - ) { - Label { - Text("Ice can work in a limited mode without this permission.") - } icon: { - Image(systemName: "checkmark.shield") - .foregroundStyle(.green) - } - } + CalloutBox("Ice can work in a limited mode without this permission.") { + Image(systemName: "checkmark.shield") + .foregroundStyle(.green) } } } diff --git a/Ice/Permissions/PermissionsWindow.swift b/Ice/Permissions/PermissionsWindow.swift index afdafe7f1..4429a4208 100644 --- a/Ice/Permissions/PermissionsWindow.swift +++ b/Ice/Permissions/PermissionsWindow.swift @@ -9,13 +9,22 @@ struct PermissionsWindow: Scene { @ObservedObject var appState: AppState var body: some Scene { - Window(Constants.permissionsWindowTitle, id: Constants.permissionsWindowID) { + IceWindow(id: .permissions) { PermissionsView() - .readWindow { window in + .onWindowChange { window in guard let window else { return } - appState.assignPermissionsWindow(window) + window.styleMask.remove([.closable, .miniaturizable]) + if let contentView = window.contentView { + with(contentView.safeAreaInsets) { insets in + insets.bottom = -insets.bottom + insets.left = -insets.left + insets.right = -insets.right + insets.top = -insets.top + contentView.additionalSafeAreaInsets = insets + } + } } } .windowResizability(.contentSize) diff --git a/Ice/Settings/SettingsWindow.swift b/Ice/Settings/SettingsWindow.swift index 4e11284f8..f84637a45 100644 --- a/Ice/Settings/SettingsWindow.swift +++ b/Ice/Settings/SettingsWindow.swift @@ -9,14 +9,8 @@ struct SettingsWindow: Scene { @ObservedObject var appState: AppState var body: some Scene { - Window(Constants.settingsWindowTitle, id: Constants.settingsWindowID) { + IceWindow(id: .settings) { settingsView - .readWindow { window in - guard let window else { - return - } - appState.assignSettingsWindow(window) - } .frame(minWidth: 825, minHeight: 500) } .commandsRemoved() diff --git a/Ice/UI/IceBar/IceBar.swift b/Ice/UI/IceBar/IceBar.swift index 1aba314a9..1d1db3703 100644 --- a/Ice/UI/IceBar/IceBar.swift +++ b/Ice/UI/IceBar/IceBar.swift @@ -316,7 +316,8 @@ private struct IceBarContentView: View { Button { menuBarManager.section(withName: section)?.hide() appState.navigationState.settingsNavigationIdentifier = .advanced - appState.appDelegate?.openSettingsWindow() + appState.activate(withPolicy: .regular) + appState.openWindow(.settings) } label: { Text("Open Ice Settings") } diff --git a/Ice/UI/IceBar/IceBarColorManager.swift b/Ice/UI/IceBar/IceBarColorManager.swift index fd0d58422..3979e7479 100644 --- a/Ice/UI/IceBar/IceBarColorManager.swift +++ b/Ice/UI/IceBar/IceBarColorManager.swift @@ -64,16 +64,16 @@ final class IceBarColorManager: ObservableObject { Publishers.Merge4( NSWorkspace.shared.notificationCenter .publisher(for: NSWorkspace.activeSpaceDidChangeNotification) - .mapToVoid(), + .replace(with: ()), NotificationCenter.default .publisher(for: NSApplication.didChangeScreenParametersNotification) - .mapToVoid(), + .replace(with: ()), DistributedNotificationCenter.default() .publisher(for: DistributedNotificationCenter.interfaceThemeChangedNotification) - .mapToVoid(), + .replace(with: ()), Timer.publish(every: 5, on: .main, in: .default) .autoconnect() - .mapToVoid() + .replace(with: ()) ) .receive(on: DispatchQueue.main) .sink { [weak self, weak iceBarPanel] in diff --git a/Ice/UI/IceUI/IceWindow.swift b/Ice/UI/IceUI/IceWindow.swift new file mode 100644 index 000000000..7820a82b0 --- /dev/null +++ b/Ice/UI/IceUI/IceWindow.swift @@ -0,0 +1,105 @@ +// +// IceWindow.swift +// Ice +// + +import SwiftUI + +// MARK: - IceWindow + +/// A custom scene representing one of Ice's windows. +struct IceWindow: Scene { + @Environment(\.openWindow) private var openWindow + @Environment(\.dismissWindow) private var dismissWindow + + /// The window's identifier. + let id: IceWindowIdentifier + + /// The window's content view. + let content: Content + + /// Creates a window with an identifier constant. + /// + /// - Parameters: + /// - id: A custom identifier constant. + /// - content: The content view to display in the window. + init(id: IceWindowIdentifier, @ViewBuilder content: () -> Content) { + self.id = id + self.content = content() + } + + var body: some Scene { + MenuBarExtra("", isInserted: .constant(false)) { }.once { + initializeWindow() + } + + Window(id.titleKey, id: id.rawValue) { + content.onWindowChange { window in + guard let window else { + return + } + window.collectionBehavior.insert(.moveToActiveSpace) + } + } + } + + private func initializeWindow() { + openWindow(id: id) + dismissWindow(id: id) + } +} + +// MARK: - IceWindowIdentifier + +/// Custom identifier constants uses to create Ice's windows. +enum IceWindowIdentifier: String, Sendable, CustomStringConvertible { + /// The identifier for Ice's main settings window. + case settings = "SettingsWindow" + + /// The identifier for Ice's permissions window. + case permissions = "PermissionsWindow" + + /// The non-localized title of the corresponding window. + /// + /// - Note: Use ``titleKey`` to get the localized title. + var titleString: String { + switch self { + case .settings: "Ice" + case .permissions: "Permissions" + } + } + + /// The localized title of the corresponding window. + /// + /// - Note: Use ``titleString`` to get the non-localized title. + var titleKey: LocalizedStringKey { + LocalizedStringKey(titleString) + } + + /// A textual representation of the identifier. + var description: String { + rawValue + } +} + +// MARK: - OpenWindowAction + +extension OpenWindowAction { + /// Opens the corresponding window for the given identifier. + /// + /// - Parameter id: An identifier for one of Ice's windows. + func callAsFunction(id: IceWindowIdentifier) { + callAsFunction(id: id.rawValue) + } +} + +// MARK: - DismissWindowAction + +extension DismissWindowAction { + /// Dismisses the corresponding window for the given identifier. + /// + /// - Parameter id: An identifier for one of Ice's windows. + func callAsFunction(id: IceWindowIdentifier) { + callAsFunction(id: id.rawValue) + } +} diff --git a/Ice/UI/Pickers/CustomGradientPicker/CustomGradientPicker.swift b/Ice/UI/Pickers/CustomGradientPicker/CustomGradientPicker.swift index ec23585f1..28b569e2e 100644 --- a/Ice/UI/Pickers/CustomGradientPicker/CustomGradientPicker.swift +++ b/Ice/UI/Pickers/CustomGradientPicker/CustomGradientPicker.swift @@ -60,7 +60,7 @@ struct CustomGradientPicker: View { .onChange(of: gradient) { _, newValue in gradientChanged(to: newValue) } - .readWindow(window: $window) + .onWindowChange(update: $window) } @ViewBuilder diff --git a/Ice/UI/ViewModifiers/OnWindowChange.swift b/Ice/UI/ViewModifiers/OnWindowChange.swift new file mode 100644 index 000000000..36fe1c29b --- /dev/null +++ b/Ice/UI/ViewModifiers/OnWindowChange.swift @@ -0,0 +1,60 @@ +// +// OnWindowChange.swift +// Ice +// + +import SwiftUI + +private nonisolated struct WindowReaderView: NSViewRepresentable { + final class Represented: NSView { + let action: (NSWindow?) -> Void + + init(action: @escaping (NSWindow?) -> Void) { + self.action = action + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + Task { + action(window) + } + } + } + + let action: (NSWindow?) -> Void + + func makeNSView(context: Context) -> Represented { + return Represented(action: action) + } + + func updateNSView(_ nsView: Represented, context: Context) { } +} + +extension View { + /// Adds an action to perform when the view's window changes. + /// + /// - Parameter action: The action to perform when the view's window + /// changes. The closure passes the new window as a parameter. The + /// new window can be `nil`. + nonisolated func onWindowChange(perform action: @escaping (_ window: NSWindow?) -> Void) -> some View { + background { + WindowReaderView(action: action) + } + } + + /// Updates the given binding when the view's window changes. + /// + /// - Parameter binding: The binding to update when the view's window + /// changes. The new window can be `nil`. + nonisolated func onWindowChange(update binding: Binding) -> some View { + onWindowChange { window in + binding.wrappedValue = window + } + } +} diff --git a/Ice/UI/ViewModifiers/Once.swift b/Ice/UI/ViewModifiers/Once.swift index d7d116e9f..183d14f75 100644 --- a/Ice/UI/ViewModifiers/Once.swift +++ b/Ice/UI/ViewModifiers/Once.swift @@ -5,17 +5,30 @@ import SwiftUI +private struct OnceAction { + private var action: (() -> Void)? + + init(action: @escaping () -> Void) { + self.action = action + } + + mutating func callAsFunction() { + if let action = action.take() { + action() + } + } +} + private struct OnceModifier: ViewModifier { - @State private var hasAppeared = false + @State private var action: OnceAction - let onAppear: () -> Void + init(action: @escaping () -> Void) { + self.action = OnceAction(action: action) + } func body(content: Content) -> some View { content.onAppear { - if !hasAppeared { - onAppear() - hasAppeared = true - } + action() } } } @@ -26,6 +39,32 @@ extension View { /// /// - Parameter action: The action to perform. func once(perform action: @escaping () -> Void) -> some View { - modifier(OnceModifier(onAppear: action)) + modifier(OnceModifier(action: action)) + } +} + +private struct OnceScene: Scene { + @State private var action: OnceAction + + let content: Content + + init(content: Content, action: @escaping () -> Void) { + self.action = OnceAction(action: action) + self.content = content + } + + var body: some Scene { + content.onChange(of: 0, initial: true) { + action() + } + } +} + +extension Scene { + /// Adds an action to perform exactly once, when the scene appears. + /// + /// - Parameter action: The action to perform. + func once(perform action: @escaping () -> Void) -> some Scene { + OnceScene(content: self, action: action) } } diff --git a/Ice/UI/ViewModifiers/ReadWindow.swift b/Ice/UI/ViewModifiers/ReadWindow.swift deleted file mode 100644 index 178e01569..000000000 --- a/Ice/UI/ViewModifiers/ReadWindow.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// ReadWindow.swift -// Ice -// - -import Combine -import SwiftUI - -private struct WindowReader: NSViewRepresentable { - final class Coordinator: ObservableObject { - private var cancellable: AnyCancellable? - - func configure(for view: NSView, onWindowChange: @MainActor @escaping (NSWindow?) -> Void) { - cancellable = view.publisher(for: \.window).sink { window in - Task { @MainActor in - onWindowChange(window) - } - } - } - } - - let onWindowChange: @MainActor (NSWindow?) -> Void - - func makeNSView(context: Context) -> NSView { - let view = NSView() - context.coordinator.configure(for: view) { window in - onWindowChange(window) - } - return view - } - - func makeCoordinator() -> Coordinator { - return Coordinator() - } - - func updateNSView(_: NSView, context: Context) { } -} - -extension View { - /// Reads the window of this view, performing the given closure when - /// the window changes. - /// - /// - Parameter onChange: A closure to perform when the window changes. - func readWindow(onChange: @MainActor @escaping (_ window: NSWindow?) -> Void) -> some View { - background { - WindowReader(onWindowChange: onChange) - } - } - - /// Reads the window of this view, assigning it to the given binding. - /// - /// - Parameter window: A binding to use to store the view's window. - func readWindow(window: Binding) -> some View { - readWindow { window.wrappedValue = $0 } - } -} diff --git a/Ice/UI/Views/CalloutBox.swift b/Ice/UI/Views/CalloutBox.swift new file mode 100644 index 000000000..e60d5bf5d --- /dev/null +++ b/Ice/UI/Views/CalloutBox.swift @@ -0,0 +1,141 @@ +// +// CalloutBox.swift +// Ice +// + +import SwiftUI + +struct CalloutBox: View { + private let content: Content + private let icon: Icon + private let alignment: HorizontalAlignment + private let font: Font? + private let foregroundStyle: ForegroundStyle + + private init( + content: Content, + icon: Icon, + alignment: HorizontalAlignment, + font: Font?, + foregroundStyle: ForegroundStyle + ) { + self.content = content + self.icon = icon + self.alignment = alignment + self.font = font + self.foregroundStyle = foregroundStyle + } + + init( + alignment: HorizontalAlignment = .center, + font: Font? = .calloutBox, + foregroundStyle: ForegroundStyle = .secondary, + @ViewBuilder content: () -> Content, + @ViewBuilder icon: () -> Icon + ) { + self.init( + content: content(), + icon: icon(), + alignment: alignment, + font: font, + foregroundStyle: foregroundStyle + ) + } + + init( + alignment: HorizontalAlignment = .center, + font: Font? = .calloutBox, + foregroundStyle: ForegroundStyle = .secondary, + @ViewBuilder content: () -> Content + ) where Icon == EmptyView { + self.init( + content: content(), + icon: EmptyView(), + alignment: alignment, + font: font, + foregroundStyle: foregroundStyle + ) + } + + init( + _ titleKey: LocalizedStringKey, + alignment: HorizontalAlignment = .center, + font: Font? = .calloutBox, + foregroundStyle: ForegroundStyle = .secondary + ) where Content == Text, Icon == EmptyView { + self.init( + content: Text(titleKey), + icon: EmptyView(), + alignment: alignment, + font: font, + foregroundStyle: foregroundStyle + ) + } + + init( + _ titleKey: LocalizedStringKey, + alignment: HorizontalAlignment = .center, + font: Font? = .calloutBox, + foregroundStyle: ForegroundStyle = .secondary, + @ViewBuilder icon: () -> Icon + ) where Content == Text { + self.init( + content: Text(titleKey), + icon: icon(), + alignment: alignment, + font: font, + foregroundStyle: foregroundStyle + ) + } + + init( + systemImage: String, + alignment: HorizontalAlignment = .center, + font: Font? = .calloutBox, + foregroundStyle: ForegroundStyle = .secondary, + @ViewBuilder content: () -> Content + ) where Icon == Image { + self.init( + content: content(), + icon: Image(systemName: systemImage), + alignment: alignment, + font: font, + foregroundStyle: foregroundStyle + ) + } + + init( + _ titleKey: LocalizedStringKey, + systemImage: String, + alignment: HorizontalAlignment = .center, + font: Font? = .calloutBox, + foregroundStyle: ForegroundStyle = .secondary + ) where Content == Text, Icon == Image { + self.init( + content: Text(titleKey), + icon: Image(systemName: systemImage), + alignment: alignment, + font: font, + foregroundStyle: foregroundStyle + ) + } + + var body: some View { + IceGroupBox { + Label { + content + } icon: { + icon + } + .font(font) + .foregroundStyle(foregroundStyle) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: Alignment(horizontal: alignment, vertical: .center)) + } + } +} + +extension Font { + /// The default font for Ice callout boxes. + static let calloutBox = callout.bold() +} diff --git a/Ice/Utilities/Extensions.swift b/Ice/Utilities/Extensions.swift index 4bc101b25..984052510 100644 --- a/Ice/Utilities/Extensions.swift +++ b/Ice/Utilities/Extensions.swift @@ -483,9 +483,48 @@ extension NSStatusItem { // MARK: - Publisher extension Publisher { - /// Transforms all elements from the upstream publisher into `Void` values. - func mapToVoid() -> some Publisher { - map { _ in () } + /// Replaces all elements from the upstream publisher using the + /// provided closure. + /// + /// - Parameter transform: A closure that returns an element to + /// publish in place of the upstream element. + func replace(_ transform: @escaping () -> T) -> Publishers.Map { + map { _ in transform() } + } + + /// Replaces all elements from the upstream publisher with the + /// provided element. + /// + /// - Parameter output: An element to publish in place of the + /// upstream element. + func replace(with output: T) -> Publishers.Map { + replace { output } + } + + func mergeReplace(_ other: P, with output: T) -> Publishers.Merge, Publishers.Map> { + replace(with: output).merge(with: other.replace(with: output)) + } + + func mergeReplace(_ other: P, transform: @escaping () -> T) -> Publishers.Merge, Publishers.Map> { + replace(transform).merge(with: other.replace(transform)) + } +} + +// MARK: - Publisher where Output: Sequence, Failure == Never + +extension Publisher where Output: Sequence, Failure == Never { + /// Transforms the elements of the upstream sequence into publishers and + /// merges the results. + /// + /// - Parameter transform: A closure that takes an element of the upstream + /// sequence as a parameter and returns a publisher. + /// + /// - Returns: A publisher that emits an event when any upstream publisher + /// emits an event. + func mergeMap(_ transform: @escaping (Output.Element) -> P) -> some Publisher { + flatMap { sequence in + Publishers.MergeMany(sequence.map(transform)) + } } } From 653404f495bf8f577f732873b56d19cf7409a0c6 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 27 Jun 2025 14:49:39 -0600 Subject: [PATCH 16/80] Don't hide app menus when using Ice Bar --- Ice/MenuBar/MenuBarManager.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Ice/MenuBar/MenuBarManager.swift b/Ice/MenuBar/MenuBarManager.swift index 97055cfae..c1c0fbfe0 100644 --- a/Ice/MenuBar/MenuBarManager.swift +++ b/Ice/MenuBar/MenuBarManager.swift @@ -186,11 +186,13 @@ final class MenuBarManager: ObservableObject { // Don't continue if: // * The "HideApplicationMenus" setting isn't enabled. + // * Using the Ice Bar. // * The menu bar is hidden by the system. // * The active space is fullscreen. // * The settings window is visible. guard appState.settingsManager.advancedSettingsManager.hideApplicationMenus, + !appState.settingsManager.generalSettingsManager.useIceBar, !isMenuBarHiddenBySystem, !appState.isActiveSpaceFullscreen, !appState.navigationState.isSettingsPresented From a7ae7a1b35dd261071dc3b8d4945a2cf18df1da1 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 27 Jun 2025 15:00:27 -0600 Subject: [PATCH 17/80] macOS 26: Search panel redesign --- Ice/MenuBar/Search/MenuBarSearchPanel.swift | 127 ++++++++++++++------ Ice/UI/Views/SectionedList.swift | 17 ++- 2 files changed, 108 insertions(+), 36 deletions(-) diff --git a/Ice/MenuBar/Search/MenuBarSearchPanel.swift b/Ice/MenuBar/Search/MenuBarSearchPanel.swift index 74232d5b4..22b309c2a 100644 --- a/Ice/MenuBar/Search/MenuBarSearchPanel.swift +++ b/Ice/MenuBar/Search/MenuBarSearchPanel.swift @@ -194,6 +194,14 @@ private struct MenuBarSearchContentView: View { let displayID: CGDirectDisplayID let closePanel: () -> Void + private var bottomBarPadding: CGFloat { + if #available(macOS 26.0, *) { + return 7 + } else { + return 5 + } + } + var body: some View { VStack(spacing: 0) { TextField(text: $searchText, prompt: Text("Search menu bar items…")) { @@ -208,9 +216,18 @@ private struct MenuBarSearchContentView: View { Divider() - SectionedList(selection: $selection, items: $displayedItems) - .contentPadding(8) - .scrollContentBackground(.hidden) + if #available(macOS 26.0, *) { + GlassEffectContainer(spacing: 0) { + SectionedList(selection: $selection, items: $displayedItems) + .contentPadding(8) + .scrollContentBackground(.hidden) + } + .clipped() + } else { + SectionedList(selection: $selection, items: $displayedItems) + .contentPadding(8) + .scrollContentBackground(.hidden) + } Divider() .offset(y: 1) @@ -234,7 +251,7 @@ private struct MenuBarSearchContentView: View { } } } - .padding(5) + .padding(bottomBarPadding) .background(.thinMaterial) } .background { @@ -324,6 +341,14 @@ private struct BottomBarButton: View { let content: Content let action: () -> Void + private var backgroundShape: some InsettableShape { + if #available(macOS 26.0, *) { + RoundedRectangle(cornerRadius: 8, style: .continuous) + } else { + RoundedRectangle(cornerRadius: 5, style: .circular) + } + } + init(action: @escaping () -> Void, @ViewBuilder content: () -> Content) { self.action = action self.content = content() @@ -333,7 +358,7 @@ private struct BottomBarButton: View { content .padding(3) .background { - RoundedRectangle(cornerRadius: 5, style: .circular) + backgroundShape .fill(.regularMaterial) .brightness(0.25) .opacity(isPressed ? 0.5 : isHovering ? 0.25 : 0) @@ -378,6 +403,14 @@ private struct ShowItemButton: View { let displayID: CGDirectDisplayID let action: () -> Void + private var backgroundShape: some InsettableShape { + if #available(macOS 26.0, *) { + RoundedRectangle(cornerRadius: 5, style: .continuous) + } else { + RoundedRectangle(cornerRadius: 3, style: .circular) + } + } + private var isOnDisplay: Bool { Bridging.isWindowOnDisplay(item.windowID, displayID) } @@ -385,18 +418,19 @@ private struct ShowItemButton: View { var body: some View { BottomBarButton(action: action) { HStack { - Text(isOnDisplay ? "Click item" : "Show item") - .padding(.horizontal, 5) + Text("\(isOnDisplay ? "Click" : "Show") item") + .padding(.leading, 5) Image(systemName: "return") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 11, height: 11) .foregroundStyle(.secondary) + .fontWeight(.bold) .padding(.horizontal, 7) .padding(.vertical, 5) .background { - RoundedRectangle(cornerRadius: 3, style: .circular) + backgroundShape .fill(.regularMaterial) .brightness(0.25) .opacity(0.5) @@ -418,12 +452,12 @@ private struct MenuBarSearchItemView: View { let item: MenuBarItem - private var image: NSImage? { + private var image: NSImage { guard let image = imageCache.images[item.info]?.trimmingTransparentPixels(around: [.minXEdge, .maxXEdge]), let screen = imageCache.screen else { - return nil + return NSImage() } let size = CGSize( width: CGFloat(image.width) / screen.backingScaleFactor, @@ -432,49 +466,74 @@ private struct MenuBarSearchItemView: View { return NSImage(cgImage: image, size: size) } - private var appIcon: NSImage? { + private var appIcon: NSImage { if item.legacyInfo.namespace == .systemUIServer { - controlCenterIcon + controlCenterIcon ?? NSImage() } else { - item.owningApplication?.icon + item.owningApplication?.icon ?? NSImage() + } + } + + private var backgroundShape: some InsettableShape { + if #available(macOS 26.0, *) { + RoundedRectangle(cornerRadius: 7, style: .continuous) + } else { + RoundedRectangle(cornerRadius: 5, style: .circular) + } + } + + private var size: CGFloat { + if #available(macOS 26.0, *) { + return 26 + } else { + return 24 + } + } + + private var padding: CGFloat { + if #available(macOS 26.0, *) { + return 6 + } else { + return 8 } } var body: some View { HStack { - if let appIcon { - Image(nsImage: appIcon) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 24, height: 24) - } + Image(nsImage: appIcon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size, height: size) Text(item.displayName) Spacer() imageViewWithBackground } - .padding(8) + .padding(padding) } @ViewBuilder private var imageViewWithBackground: some View { - if let image { - ZStack { - RoundedRectangle(cornerRadius: 5, style: .circular) - .fill(.regularMaterial) + if #available(macOS 26.0, *) { + imageView.glassEffect( + Glass.regular.tint(.secondary.opacity(0.33)), + in: backgroundShape + ) + } else { + imageView.background { + backgroundShape + .fill(.regularMaterial.opacity(0.75)) .brightness(0.25) - .opacity(0.75) - .frame(width: item.frame.width) .overlay { - RoundedRectangle(cornerRadius: 5, style: .circular) - .inset(by: 0.5) - .stroke(lineWidth: 1) - .foregroundStyle(.white) - .opacity(0.15) + backgroundShape + .strokeBorder(.white.opacity(0.15)) } - - Image(nsImage: image) - .frame(height: 24) } } } + + @ViewBuilder + private var imageView: some View { + Image(nsImage: image) + .frame(width: item.frame.width, height: size) + } } diff --git a/Ice/UI/Views/SectionedList.swift b/Ice/UI/Views/SectionedList.swift index ba7dfae98..3fff6e06d 100644 --- a/Ice/UI/Views/SectionedList.swift +++ b/Ice/UI/Views/SectionedList.swift @@ -186,6 +186,14 @@ private struct SectionedListItemView: View { let item: SectionedListItem + private var backgroundShape: some InsettableShape { + if #available(macOS 26.0, *) { + RoundedRectangle(cornerRadius: 10, style: .continuous) + } else { + RoundedRectangle(cornerRadius: 5, style: .circular) + } + } + var body: some View { ZStack { if item.isSelectable { @@ -217,7 +225,12 @@ private struct SectionedListItemView: View { @ViewBuilder private var itemBackground: some View { - VisualEffectView(material: .selection, blendingMode: .withinWindow) - .clipShape(RoundedRectangle(cornerRadius: 5, style: .circular)) + if #available(macOS 26.0, *) { + backgroundShape + .fill(.tint) + } else { + VisualEffectView(material: .selection, blendingMode: .withinWindow) + .clipShape(backgroundShape) + } } } From 292556f0df61da0362da69ec576fc28dac97d99a Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 27 Jun 2025 15:24:08 -0600 Subject: [PATCH 18/80] Replace `mouseMoved` event monitor with an event tap This should fix some performance issues that occur during mouse tracking operations (e.g. highlighting a button on hover). --- Ice/Events/EventManager.swift | 43 +++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/Ice/Events/EventManager.swift b/Ice/Events/EventManager.swift index a5686798b..ebcbc43a4 100644 --- a/Ice/Events/EventManager.swift +++ b/Ice/Events/EventManager.swift @@ -57,10 +57,13 @@ final class EventManager { return event } - /// Monitor for mouse moved events. - private(set) lazy var mouseMovedMonitor = UniversalEventMonitor( - mask: .mouseMoved - ) { [weak self] event in + /// Tap for mouse moved events. + private(set) lazy var mouseMovedTap = EventTap( + options: .listenOnly, + location: .hidEventTap, + place: .tailAppendEventTap, + types: [.mouseMoved] + ) { [weak self] _, _, event in if let self, let appState, let screen = bestScreen(appState: appState) { handleShowOnHover(appState: appState, screen: screen) } @@ -79,12 +82,12 @@ final class EventManager { // MARK: All Monitors - /// All monitors maintained by the app. - private lazy var allMonitors = [ + /// All monitors maintained by the manager. + private lazy var allMonitors: [any EventMonitorProtocol] = [ mouseDownMonitor, mouseUpMonitor, mouseDraggedMonitor, - mouseMovedMonitor, + mouseMovedTap, scrollWheelMonitor, ] @@ -146,7 +149,7 @@ final class EventManager { } } -// MARK: - Handlers +// MARK: - Handler Methods extension EventManager { @@ -428,7 +431,7 @@ extension EventManager { } } -// MARK: - Helpers +// MARK: - Helper Methods extension EventManager { /// Returns the best screen to use for event manager calculations. @@ -542,3 +545,25 @@ extension EventManager { return iceIconFrame.contains(mouseLocation) } } + +// MARK: - EventMonitor Helpers + +/// Helper protocol to enable group operations across event +/// monitoring types. +@MainActor +private protocol EventMonitorProtocol { + func start() + func stop() +} + +extension UniversalEventMonitor: EventMonitorProtocol { } + +extension EventTap: EventMonitorProtocol { + fileprivate func start() { + enable() + } + + fileprivate func stop() { + disable() + } +} From 08e883eec64c2f3223013ad9d89b88e0b443cf55 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Mon, 30 Jun 2025 10:51:12 -0600 Subject: [PATCH 19/80] Lots of refactoring --- Ice/Events/EventManager.swift | 69 ++-- Ice/Hotkeys/Hotkey.swift | 98 +++--- Ice/Hotkeys/HotkeyAction.swift | 3 - Ice/Hotkeys/HotkeyRegistry.swift | 18 +- Ice/Hotkeys/KeyCode.swift | 2 +- Ice/Hotkeys/KeyCombination.swift | 33 +- Ice/Hotkeys/Modifiers.swift | 25 -- Ice/Main/AppState.swift | 96 +++--- .../MenuBarAppearanceEditor.swift | 27 +- .../Appearance/MenuBarAppearanceManager.swift | 16 +- .../Appearance/MenuBarOverlayPanel.swift | 38 +-- Ice/MenuBar/ControlItem/ControlItem.swift | 312 ++++++++++-------- Ice/{UI => MenuBar}/IceBar/IceBar.swift | 91 +++-- .../IceBar/IceBarColorManager.swift | 2 +- .../IceBar/IceBarLocation.swift | 0 Ice/{UI => MenuBar}/LayoutBar/LayoutBar.swift | 0 .../LayoutBar/LayoutBarContainer.swift | 0 .../LayoutBar/LayoutBarItemView.swift | 0 .../LayoutBar/LayoutBarPaddingView.swift | 4 +- .../LayoutBar/LayoutBarScrollView.swift | 0 .../MenuBarItems/MenuBarItemImageCache.swift | 23 +- .../MenuBarItems/MenuBarItemManager.swift | 8 +- Ice/MenuBar/MenuBarManager.swift | 54 +-- Ice/MenuBar/MenuBarSection.swift | 23 +- Ice/MenuBar/Search/MenuBarSearchPanel.swift | 10 +- Ice/Permissions/PermissionsManager.swift | 14 +- Ice/Permissions/PermissionsView.swift | 10 +- Ice/Permissions/PermissionsWindow.swift | 1 + .../AdvancedSettingsManager.swift | 92 +++--- .../GeneralSettingsManager.swift | 32 +- .../HotkeySettingsManager.swift | 7 +- .../SettingsManagers/SettingsManager.swift | 24 +- .../SettingsPanes/AdvancedSettingsPane.swift | 114 +++---- .../SettingsPanes/GeneralSettingsPane.swift | 44 ++- .../SettingsPanes/HotkeysSettingsPane.swift | 7 +- .../MenuBarAppearanceSettingsPane.swift | 5 - .../MenuBarLayoutSettingsPane.swift | 16 +- Ice/Settings/SettingsView.swift | 9 +- Ice/Settings/SettingsWindow.swift | 50 +++ Ice/UI/HotkeyRecorder/HotkeyRecorder.swift | 159 --------- .../HotkeyRecorder/HotkeyRecorderModel.swift | 85 ----- Ice/UI/IceUI/IceGroupBox.swift | 11 +- Ice/UI/IceUI/IceSection.swift | 4 +- Ice/UI/IceUI/IceSlider.swift | 14 +- Ice/UI/IceUI/IceWindow.swift | 5 +- Ice/UI/Views/AnnotationView.swift | 18 +- Ice/UI/Views/HotkeyRecorder.swift | 229 +++++++++++++ Ice/Updates/UpdatesManager.swift | 9 +- .../UserNotificationManager.swift | 11 +- Ice/Utilities/Defaults.swift | 16 +- Ice/Utilities/Logging.swift | 7 +- Ice/Utilities/MigrationManager.swift | 49 ++- Ice/Utilities/MouseHelpers.swift | 6 +- Ice/Utilities/RehideStrategy.swift | 27 -- .../Swizzling.swift} | 4 +- 55 files changed, 1020 insertions(+), 1011 deletions(-) rename Ice/{UI => MenuBar}/IceBar/IceBar.swift (85%) rename Ice/{UI => MenuBar}/IceBar/IceBarColorManager.swift (98%) rename Ice/{UI => MenuBar}/IceBar/IceBarLocation.swift (100%) rename Ice/{UI => MenuBar}/LayoutBar/LayoutBar.swift (100%) rename Ice/{UI => MenuBar}/LayoutBar/LayoutBarContainer.swift (100%) rename Ice/{UI => MenuBar}/LayoutBar/LayoutBarItemView.swift (100%) rename Ice/{UI => MenuBar}/LayoutBar/LayoutBarPaddingView.swift (97%) rename Ice/{UI => MenuBar}/LayoutBar/LayoutBarScrollView.swift (100%) delete mode 100644 Ice/UI/HotkeyRecorder/HotkeyRecorder.swift delete mode 100644 Ice/UI/HotkeyRecorder/HotkeyRecorderModel.swift create mode 100644 Ice/UI/Views/HotkeyRecorder.swift delete mode 100644 Ice/Utilities/RehideStrategy.swift rename Ice/{Swizzling/NSSplitViewItem+swizzledCanCollapse.swift => Utilities/Swizzling.swift} (88%) diff --git a/Ice/Events/EventManager.swift b/Ice/Events/EventManager.swift index ebcbc43a4..ac4c9c110 100644 --- a/Ice/Events/EventManager.swift +++ b/Ice/Events/EventManager.swift @@ -8,7 +8,10 @@ import Combine /// Manager for the various event monitors maintained by the app. @MainActor -final class EventManager { +final class EventManager: ObservableObject { + /// A Boolean value that indicates whether the user is dragging a menu bar item. + @Published private(set) var isDraggingMenuBarItem = false + /// The shared app state. private weak var appState: AppState? @@ -41,9 +44,7 @@ final class EventManager { private(set) lazy var mouseUpMonitor = UniversalEventMonitor( mask: .leftMouseUp ) { [weak self] event in - if let self, let appState { - handleLeftMouseUp(appState: appState) - } + self?.handleLeftMouseUp() return event } @@ -91,15 +92,11 @@ final class EventManager { scrollWheelMonitor, ] - // MARK: Initializers - - /// Creates an event manager with the given app state. - init(appState: AppState) { - self.appState = appState - } + // MARK: Setup /// Sets up the manager. - func performSetup() { + func performSetup(with appState: AppState) { + self.appState = appState startAll() configureCancellables() } @@ -166,20 +163,30 @@ extension EventManager { Task { // Short delay helps the toggle action feel more natural. try await Task.sleep(for: .milliseconds(50)) + if NSEvent.modifierFlags == .control { handleShowRightClickMenu(appState: appState, screen: screen) - } else if + return + } + + let targetSection: MenuBarSection + + if NSEvent.modifierFlags == .option, - appState.settingsManager.advancedSettingsManager.canToggleAlwaysHiddenSection + let alwaysHiddenSection = appState.menuBarManager.section(withName: .alwaysHidden), + alwaysHiddenSection.isEnabled { - if let alwaysHiddenSection = appState.menuBarManager.section(withName: .alwaysHidden) { - await alwaysHiddenSection.toggle() - } + targetSection = alwaysHiddenSection + } else if + let hiddenSection = appState.menuBarManager.section(withName: .hidden), + hiddenSection.isEnabled + { + targetSection = hiddenSection } else { - if let hiddenSection = appState.menuBarManager.section(withName: .hidden) { - await hiddenSection.toggle() - } + return } + + await targetSection.toggle() } } @@ -315,8 +322,8 @@ extension EventManager { // MARK: Handle Left Mouse Up - private func handleLeftMouseUp(appState: AppState) { - appState.appearanceManager.setIsDraggingMenuBarItem(false) + private func handleLeftMouseUp() { + isDraggingMenuBarItem = false } // MARK: Handle Left Mouse Dragged @@ -329,24 +336,12 @@ extension EventManager { return } - // Notify each overlay panel that a menu bar item is being dragged. - appState.appearanceManager.setIsDraggingMenuBarItem(true) + isDraggingMenuBarItem = true - // Don't continue if the setting to show the sections is disabled. - guard appState.settingsManager.advancedSettingsManager.showAllSectionsOnUserDrag else { - return - } - - // Show all items, including section dividers. - for section in appState.menuBarManager.sections { - section.controlItem.state = .showItems - guard - section.controlItem.isSectionDivider, - !section.controlItem.isVisible - else { - continue + if appState.settingsManager.advancedSettingsManager.showAllSectionsOnUserDrag { + for section in appState.menuBarManager.sections { + section.controlItem.state = .showItems } - section.controlItem.isVisible = true } } diff --git a/Ice/Hotkeys/Hotkey.swift b/Ice/Hotkeys/Hotkey.swift index 7851c325f..69dc4e55e 100644 --- a/Ice/Hotkeys/Hotkey.swift +++ b/Ice/Hotkeys/Hotkey.swift @@ -6,68 +6,80 @@ import Combine import OSLog +// MARK: - Hotkey + /// A combination of a key and modifiers that can be used to /// trigger actions on system-wide key-up or key-down events. final class Hotkey: ObservableObject { + /// The hotkey's key combination. + @Published var keyCombination: KeyCombination? + + /// The hotkey's action. + let action: HotkeyAction + + /// The shared app state. private weak var appState: AppState? + /// Manages the lifetime of the hotkey observation. private var listener: Listener? - let action: HotkeyAction - - @Published var keyCombination: KeyCombination? { - didSet { - enable() - } - } + /// Internal observer storage. + private var cancellable: AnyCancellable? - var isEnabled: Bool { - listener != nil - } + /// A Boolean value that indicates whether the hotkey is enabled. + var isEnabled: Bool { listener != nil } + /// Creates a hotkey with the given key combination and action. init(keyCombination: KeyCombination?, action: HotkeyAction) { self.keyCombination = keyCombination self.action = action + self.cancellable = $keyCombination.sink { [weak self] _ in + Task { + await self?.enable() + } + } } - func assignAppState(_ appState: AppState) { + /// Performs the initial setup of the hotkey. + @MainActor + func performSetup(with appState: AppState) { self.appState = appState enable() } + /// Enables the hotkey. + @MainActor func enable() { disable() - listener = Listener(hotkey: self, eventKind: .keyDown, appState: appState) + listener = Listener(hotkey: self, eventKind: .keyDown) } + /// Disables the hotkey. + @MainActor func disable() { listener?.invalidate() listener = nil } } +// MARK: - Hotkey Listener + extension Hotkey { - /// An object that manges the lifetime of a hotkey observation. + /// An object that manages the lifetime of a hotkey observation. private final class Listener { - private weak var appState: AppState? - + private weak var registry: HotkeyRegistry? private var id: UInt32? - var isValid: Bool { - id != nil - } - - init?(hotkey: Hotkey, eventKind: HotkeyRegistry.EventKind, appState: AppState?) { + @MainActor + init?(hotkey: Hotkey, eventKind: HotkeyRegistry.EventKind) { guard - let appState, + let appState = hotkey.appState, hotkey.keyCombination != nil else { return nil } - let id = appState.hotkeyRegistry.register( - hotkey: hotkey, - eventKind: eventKind - ) { [weak appState] in + let registry = appState.hotkeyRegistry + let id = registry.register(hotkey: hotkey, eventKind: eventKind) { [weak appState] in guard let appState else { return } @@ -78,7 +90,7 @@ extension Hotkey { guard let id else { return nil } - self.appState = appState + self.registry = registry self.id = id } @@ -87,45 +99,21 @@ extension Hotkey { } func invalidate() { - guard isValid else { + guard let id else { return } - guard let appState else { - Logger.default.error("Error invalidating hotkey: Missing app state") + guard let registry else { + Logger.hotkeys.error("Error invalidating hotkey: missing HotkeyRegistry") return } defer { - id = nil - } - if let id { - appState.hotkeyRegistry.unregister(id) + self.id = nil } + registry.unregister(id) } } } -// MARK: Hotkey: Codable -extension Hotkey: Codable { - private enum CodingKeys: CodingKey { - case keyCombination - case action - } - - convenience init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - try self.init( - keyCombination: container.decode(KeyCombination?.self, forKey: .keyCombination), - action: container.decode(HotkeyAction.self, forKey: .action) - ) - } - - func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(keyCombination, forKey: .keyCombination) - try container.encode(action, forKey: .action) - } -} - // MARK: Hotkey: Equatable extension Hotkey: Equatable { static func == (lhs: Hotkey, rhs: Hotkey) -> Bool { diff --git a/Ice/Hotkeys/HotkeyAction.swift b/Ice/Hotkeys/HotkeyAction.swift index 029067623..8643460ad 100644 --- a/Ice/Hotkeys/HotkeyAction.swift +++ b/Ice/Hotkeys/HotkeyAction.swift @@ -13,7 +13,6 @@ enum HotkeyAction: String, Codable, CaseIterable { // Other case enableIceBar = "EnableIceBar" - case showSectionDividers = "ShowSectionDividers" case toggleApplicationMenus = "ToggleApplicationMenus" @MainActor @@ -41,8 +40,6 @@ enum HotkeyAction: String, Codable, CaseIterable { await appState.menuBarManager.searchPanel.toggle() case .enableIceBar: appState.settingsManager.generalSettingsManager.useIceBar.toggle() - case .showSectionDividers: - appState.settingsManager.advancedSettingsManager.showSectionDividers.toggle() case .toggleApplicationMenus: appState.menuBarManager.toggleApplicationMenus() } diff --git a/Ice/Hotkeys/HotkeyRegistry.swift b/Ice/Hotkeys/HotkeyRegistry.swift index b5528b4e8..a488ec673 100644 --- a/Ice/Hotkeys/HotkeyRegistry.swift +++ b/Ice/Hotkeys/HotkeyRegistry.swift @@ -53,8 +53,6 @@ final class HotkeyRegistry { } } - private let logger = Logger(category: "HotkeyRegistry") - private let signature = OSType(1231250720) // OSType for Ice private var eventHandlerRef: EventHandlerRef? @@ -132,21 +130,21 @@ final class HotkeyRegistry { } guard let keyCombination = hotkey.keyCombination else { - logger.error("Hotkey does not have a valid key combination") + Logger.hotkeys.error("Hotkey does not have a valid key combination") return nil } var status = installIfNeeded() guard status == noErr else { - logger.error("Hotkey event handler installation failed with status \(status, privacy: .public)") + Logger.hotkeys.error("Hotkey event handler installation failed with status \(status, privacy: .public)") return nil } let id = Context.currentID guard registrations[id] == nil else { - logger.error("Hotkey already registered for id \(id, privacy: .public)") + Logger.hotkeys.error("Hotkey already registered for id \(id, privacy: .public)") return nil } @@ -162,12 +160,12 @@ final class HotkeyRegistry { ) guard status == noErr else { - logger.error("Hotkey registration failed with status \(status, privacy: .public)") + Logger.hotkeys.error("Hotkey registration failed with status \(status, privacy: .public)") return nil } guard let hotKeyRef else { - logger.error("Hotkey registration failed due to invalid EventHotKeyRef") + Logger.hotkeys.error("Hotkey registration failed due to invalid EventHotKeyRef") return nil } @@ -188,12 +186,12 @@ final class HotkeyRegistry { /// its registration in an inactive state. private func retainedUnregister(_ id: UInt32) { guard let registration = registrations[id] else { - logger.error("No registered key combination for id \(id, privacy: .public)") + Logger.hotkeys.error("No registered key combination for id \(id, privacy: .public)") return } let status = UnregisterEventHotKey(registration.hotKeyRef) guard status == noErr else { - logger.error("Hotkey unregistration failed with status \(status, privacy: .public)") + Logger.hotkeys.error("Hotkey unregistration failed with status \(status, privacy: .public)") return } registration.hotKeyRef = nil @@ -239,7 +237,7 @@ final class HotkeyRegistry { let hotKeyRef else { registrations.removeValue(forKey: registration.hotKeyID.id) - logger.error("Hotkey registration failed with status \(status, privacy: .public)") + Logger.hotkeys.error("Hotkey registration failed with status \(status, privacy: .public)") continue } diff --git a/Ice/Hotkeys/KeyCode.swift b/Ice/Hotkeys/KeyCode.swift index 0c82e8222..fe3b7d176 100644 --- a/Ice/Hotkeys/KeyCode.swift +++ b/Ice/Hotkeys/KeyCode.swift @@ -267,7 +267,7 @@ private let customStringMappings = [ // MARK: String Value extension KeyCode { - /// Custom string representation. + /// A custom string representation for the key. var stringValue: String { customStringMappings[self, default: keyEquivalent] } diff --git a/Ice/Hotkeys/KeyCombination.swift b/Ice/Hotkeys/KeyCombination.swift index 503d68f84..ad4082552 100644 --- a/Ice/Hotkeys/KeyCombination.swift +++ b/Ice/Hotkeys/KeyCombination.swift @@ -11,8 +11,16 @@ struct KeyCombination: Hashable { let key: KeyCode let modifiers: Modifiers - var stringValue: String { - modifiers.symbolicValue + key.stringValue + /// A string representation for the key combination suitable + /// for display. + var displayValue: String { + modifiers.symbolicValue + " " + key.stringValue.capitalized + } + + /// Returns a Boolean value that indicates whether this key + /// combination is reserved for system use. + var isSystemReserved: Bool { + getSystemReservedKeyCombinations().contains(self) } init(key: KeyCode, modifiers: Modifiers) { @@ -32,11 +40,11 @@ private func getSystemReservedKeyCombinations() -> [KeyCombination] { let status = CopySymbolicHotKeys(&symbolicHotkeys) guard status == noErr else { - Logger.serialization.error("CopySymbolicHotKeys returned invalid status: \(status, privacy: .public)") + Logger.hotkeys.error("CopySymbolicHotKeys returned invalid status: \(status, privacy: .public)") return [] } guard let reservedHotkeys = symbolicHotkeys?.takeRetainedValue() as? [[String: Any]] else { - Logger.serialization.error("Failed to serialize symbolic hotkeys") + Logger.hotkeys.error("Failed to retrieve symbolic hotkeys") return [] } @@ -55,24 +63,13 @@ private func getSystemReservedKeyCombinations() -> [KeyCombination] { } } -extension KeyCombination { - /// Returns a Boolean value that indicates whether this key - /// combination is reserved for system use. - var isReservedBySystem: Bool { - getSystemReservedKeyCombinations().contains(self) - } -} - +// MARK: KeyCombination: Codable extension KeyCombination: Codable { init(from decoder: any Decoder) throws { var container = try decoder.unkeyedContainer() guard container.count == 2 else { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Expected 2 encoded values, found \(container.count ?? 0)" - ) - ) + let description = "Expected 2 encoded values, found \(container.count ?? 0)" + throw DecodingError.dataCorruptedError(in: container, debugDescription: description) } self.key = try KeyCode(rawValue: container.decode(Int.self)) self.modifiers = try Modifiers(rawValue: container.decode(Int.self)) diff --git a/Ice/Hotkeys/Modifiers.swift b/Ice/Hotkeys/Modifiers.swift index b7116a5e7..9ac78d96b 100644 --- a/Ice/Hotkeys/Modifiers.swift +++ b/Ice/Hotkeys/Modifiers.swift @@ -39,31 +39,6 @@ extension Modifiers { return result } - /// A string representation of the modifiers that is - /// suitable for display in a label. - var labelValue: String { - var result = [String]() - if contains(.control) { - result.append("Control") - } - if contains(.option) { - result.append("Option") - } - if contains(.shift) { - result.append("Shift") - } - if contains(.command) { - result.append("Command") - } - return result.joined(separator: " + ") - } - - /// A combined string representation of the modifiers - /// that is suitable for display. - var combinedValue: String { - "\(labelValue) (\(symbolicValue))" - } - /// Cocoa flags. var nsEventFlags: NSEvent.ModifierFlags { var result: NSEvent.ModifierFlags = [] diff --git a/Ice/Main/AppState.swift b/Ice/Main/AppState.swift index c86ec05a8..c074899f4 100644 --- a/Ice/Main/AppState.swift +++ b/Ice/Main/AppState.swift @@ -13,41 +13,44 @@ final class AppState: ObservableObject { /// A Boolean value that indicates whether the active space is fullscreen. @Published private(set) var isActiveSpaceFullscreen = Bridging.isActiveSpaceFullscreen() - /// Manager for the menu bar's appearance. - private(set) lazy var appearanceManager = MenuBarAppearanceManager(appState: self) + /// A Boolean value that indicates whether the user is dragging a menu bar item. + @Published private(set) var isDraggingMenuBarItem = false - /// Manager for events received by the app. - private(set) lazy var eventManager = EventManager(appState: self) + /// Manager for the app's settings. + let settingsManager = SettingsManager() - /// Manager for menu bar items. - private(set) lazy var itemManager = MenuBarItemManager(appState: self) + /// Model for app-wide navigation. + let navigationState = AppNavigationState() /// Manager for the state of the menu bar. - private(set) lazy var menuBarManager = MenuBarManager(appState: self) - - /// Manager for app permissions. - private(set) lazy var permissionsManager = PermissionsManager(appState: self) + let menuBarManager = MenuBarManager() - /// Manager for the app's settings. - private(set) lazy var settingsManager = SettingsManager(appState: self) + /// Manager for the menu bar's appearance. + let appearanceManager = MenuBarAppearanceManager() - /// Manager for app updates. - private(set) lazy var updatesManager = UpdatesManager(appState: self) + /// Manager for menu bar item spacing. + let spacingManager = MenuBarItemSpacingManager() - /// Manager for user notifications. - private(set) lazy var userNotificationManager = UserNotificationManager(appState: self) + /// Manager for menu bar items. + let itemManager = MenuBarItemManager() /// Global cache for menu bar item images. - private(set) lazy var imageCache = MenuBarItemImageCache(appState: self) + let imageCache = MenuBarItemImageCache() - /// Manager for menu bar item spacing. - let spacingManager = MenuBarItemSpacingManager() + /// Manager for events received by the app. + let eventManager = EventManager() - /// Model for app-wide navigation. - let navigationState = AppNavigationState() + /// Manager for app permissions. + let permissionsManager = PermissionsManager() + + /// Manager for app updates. + let updatesManager = UpdatesManager() + + /// Manager for user notifications. + let userNotificationManager = UserNotificationManager() /// The app's hotkey registry. - nonisolated let hotkeyRegistry = HotkeyRegistry() + let hotkeyRegistry = HotkeyRegistry() /// Storage for internal observers. private var cancellables = Set() @@ -60,14 +63,14 @@ final class AppState: ObservableObject { logger.info("Running setup actions") configureCancellables() permissionsManager.stopAllChecks() - menuBarManager.performSetup() - appearanceManager.performSetup() - eventManager.performSetup() - settingsManager.performSetup() - itemManager.performSetup() - imageCache.performSetup() - updatesManager.performSetup() - userNotificationManager.performSetup() + menuBarManager.performSetup(with: self) + appearanceManager.performSetup(with: self) + eventManager.performSetup(with: self) + settingsManager.performSetup(with: self) + itemManager.performSetup(with: self) + imageCache.performSetup(with: self) + updatesManager.performSetup(with: self) + userNotificationManager.performSetup(with: self) }() /// Performs app state setup. @@ -107,35 +110,28 @@ final class AppState: ObservableObject { .replace(with: ()) ) .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self else { - return - } - isActiveSpaceFullscreen = Bridging.isActiveSpaceFullscreen() + .replace { + Bridging.isActiveSpaceFullscreen() } - .store(in: &c) + .removeDuplicates() + .assign(to: &$isActiveSpaceFullscreen) NSWorkspace.shared.publisher(for: \.frontmostApplication) .receive(on: DispatchQueue.main) - .sink { [weak self] frontmostApplication in - guard let self else { - return - } - navigationState.isAppFrontmost = frontmostApplication == .current - } - .store(in: &c) + .map { $0 == .current } + .removeDuplicates() + .assign(to: &navigationState.$isAppFrontmost) publisherForWindow(.settings) .flatMap { $0.publisher } // Short circuit if nil. .flatMap { $0.publisher(for: \.isVisible) } .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) - .sink { [weak self] isVisible in - guard let self else { - return - } - navigationState.isSettingsPresented = isVisible - } - .store(in: &c) + .removeDuplicates() + .assign(to: &navigationState.$isSettingsPresented) + + eventManager.$isDraggingMenuBarItem + .removeDuplicates() + .assign(to: &$isDraggingMenuBarItem) Publishers.CombineLatest( navigationState.$isAppFrontmost, diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift index af09aff2d..44b1736e7 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift @@ -59,6 +59,15 @@ struct MenuBarAppearanceEditor: View { @ViewBuilder private var mainForm: some View { IceForm(padding: mainFormPadding) { + if + case .settings = location, + appState.settingsManager.advancedSettingsManager.showContextMenuOnRightClick + { + CalloutBox( + "Tip: You can also edit these settings by right-clicking in an empty area of the menu bar.", + systemImage: "lightbulb" + ) + } IceSection { isDynamicToggle } @@ -72,20 +81,6 @@ struct MenuBarAppearanceEditor: View { shapePicker isInset } - if case .settings = location { - IceGroupBox { - AnnotationView( - alignment: .center, - font: .callout.bold() - ) { - Label { - Text("Tip: you can also edit these settings by right-clicking in an empty area of the menu bar") - } icon: { - Image(systemName: "lightbulb") - } - } - } - } if !appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults, appearanceManager.configuration != .defaultConfiguration @@ -102,12 +97,12 @@ struct MenuBarAppearanceEditor: View { @ViewBuilder private var isDynamicToggle: some View { Toggle("Use dynamic appearance", isOn: appearanceManager.bindings.configuration.isDynamic) - .annotation("Apply different settings based on the current system appearance") + .annotation("Apply different settings based on the current system appearance.") } @ViewBuilder private var cannotEdit: some View { - Text("Ice cannot edit the appearance of automatically hidden menu bars") + Text("Ice cannot edit the appearance of automatically hidden menu bars.") .font(.title3) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift index d8dda7d47..5e5ca2182 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift @@ -34,13 +34,9 @@ final class MenuBarAppearanceManager: ObservableObject { /// The amount to inset the menu bar if called for by the configuration. let menuBarInsetAmount: CGFloat = 5 - /// Creates a manager with the given app state. - init(appState: AppState) { - self.appState = appState - } - /// Performs initial setup of the manager. - func performSetup() { + func performSetup(with appState: AppState) { + self.appState = appState loadInitialState() configureCancellables() } @@ -145,14 +141,6 @@ final class MenuBarAppearanceManager: ObservableObject { self.overlayPanels = overlayPanels } - - /// Sets the value of ``MenuBarOverlayPanel/isDraggingMenuBarItem`` for each - /// of the manager's overlay panels. - func setIsDraggingMenuBarItem(_ isDragging: Bool) { - for panel in overlayPanels { - panel.isDraggingMenuBarItem = isDragging - } - } } // MARK: MenuBarAppearanceManager: BindingExposable diff --git a/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift b/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift index fa978e62c..6d3b4eb9a 100644 --- a/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift +++ b/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift @@ -58,9 +58,6 @@ final class MenuBarOverlayPanel: NSPanel { /// A Boolean value that indicates whether the panel needs to be shown. @Published var needsShow = false - /// A Boolean value that indicates whether the user is dragging a menu bar item. - @Published var isDraggingMenuBarItem = false - /// Flags representing the components of the panel currently in need of an update. @Published private(set) var updateFlags = Set() @@ -393,6 +390,18 @@ private final class MenuBarOverlayPanelContentView: NSView { .removeDuplicates() .assign(to: &$previewConfiguration) + // Fade out whenever a menu bar item is being dragged. + appState.$isDraggingMenuBarItem + .removeDuplicates() + .sink { [weak self] isDragging in + if isDragging { + self?.animator().alphaValue = 0 + } else { + self?.animator().alphaValue = 1 + } + } + .store(in: &c) + for section in appState.menuBarManager.sections { // Redraw whenever the window frame of a control item changes. // @@ -408,32 +417,9 @@ private final class MenuBarOverlayPanelContentView: NSView { self?.needsDisplay = true } .store(in: &c) - - // Redraw whenever the visibility of a control item changes. - // - // - NOTE: If the "ShowSectionDividers" setting is disabled, the window - // frame does not update when the section is hidden or shown, but the - // visibility does. We observe both to ensure the update occurs. - section.controlItem.$isVisible - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.needsDisplay = true - } - .store(in: &c) } } - // Fade out whenever a menu bar item is being dragged. - overlayPanel.$isDraggingMenuBarItem - .removeDuplicates() - .sink { [weak self] isDragging in - if isDragging { - self?.animator().alphaValue = 0 - } else { - self?.animator().alphaValue = 1 - } - } - .store(in: &c) // Redraw whenever the application menu frame changes. overlayPanel.$applicationMenuFrame .sink { [weak self] _ in diff --git a/Ice/MenuBar/ControlItem/ControlItem.swift b/Ice/MenuBar/ControlItem/ControlItem.swift index db97b2d78..fd81d4fd7 100644 --- a/Ice/MenuBar/ControlItem/ControlItem.swift +++ b/Ice/MenuBar/ControlItem/ControlItem.swift @@ -39,9 +39,6 @@ final class ControlItem { /// The control item's hiding state (`@Published`). @Published var state = HidingState.hideItems - /// A Boolean value that indicates whether the control item is visible (`@Published`). - @Published var isVisible = true - /// The control item's window (`@Published`). @Published private(set) var window: NSWindow? @@ -69,19 +66,6 @@ final class ControlItem { /// Storage for internal observers. private var cancellables = Set() - /// The menu bar section associated with the control item. - private weak var section: MenuBarSection? { - appState?.menuBarManager.sections.first { $0.controlItem === self } - } - -// /// The identifier of the control item's window. -// var windowID: CGWindowID? { -// guard let window else { -// return nil -// } -// return CGWindowID(window.windowNumber) -// } - /// A Boolean value that indicates whether the control item serves as /// a divider between sections. var isSectionDivider: Bool { @@ -94,8 +78,17 @@ final class ControlItem { statusItem.isVisible } - /// Creates a control item with the given identifier and app state. - init(identifier: Identifier, appState: AppState) { + /// The corresponding section name for the control item. + var sectionName: MenuBarSection.Name { + switch identifier { + case .iceIcon: .visible + case .hidden: .hidden + case .alwaysHidden: .alwaysHidden + } + } + + /// Creates a control item with the given identifier. + init(identifier: Identifier) { let autosaveName = identifier.rawValue // If the status item doesn't have a preferred position, set it @@ -114,7 +107,6 @@ final class ControlItem { self.statusItem = NSStatusBar.system.statusItem(withLength: 0) self.statusItem.autosaveName = autosaveName self.identifier = identifier - self.appState = appState if let button = statusItem.button { // This could break in a new macOS release, but we need this constraint in order to be @@ -140,11 +132,6 @@ final class ControlItem { } else { self.constraint = nil } - - updateStatusItem(with: state) - Task { - configureCancellables() - } } /// Removes the status item without clearing its stored position. @@ -157,6 +144,17 @@ final class ControlItem { StatusItemDefaults[.preferredPosition, autosaveName] = cached } + /// Performs the initial setup of the control item. + func performSetup(with appState: AppState) { + self.appState = appState + Task { + updateStatusItem(with: state) + Task { + configureCancellables() + } + } + } + /// Configures the internal observers for the control item. private func configureCancellables() { var c = Set() @@ -167,58 +165,17 @@ final class ControlItem { } .store(in: &c) - Publishers.CombineLatest($isVisible, $state) - .sink { [weak self] (isVisible, state) in - guard - let self, - let section - else { - return - } - if isVisible { - statusItem.length = switch section.name { - case .visible: Lengths.standard - case .hidden, .alwaysHidden: - switch state { - case .hideItems: Lengths.expanded - case .showItems: Lengths.standard - } - } - constraint?.isActive = true - } else { - statusItem.length = 0 - constraint?.isActive = false - if let window { - var size = window.frame.size - size.width = 1 - window.setContentSize(size) - } - } - } - .store(in: &c) - - constraint?.publisher(for: \.isActive) - .removeDuplicates() - .sink { [weak self] isActive in - self?.isVisible = isActive - } - .store(in: &c) - statusItem.publisher(for: \.isVisible) .receive(on: DispatchQueue.main) .sink { [weak self] isVisible in - guard - let self, - let appState, - let section - else { + guard let self, let appState else { return } let manager = appState.settingsManager.hotkeySettingsManager - let hotkey: Hotkey? = switch section.name { - case .visible: nil + let hotkey: Hotkey? = switch identifier { + case .iceIcon: nil case .hidden: manager.hotkey(withAction: .toggleHiddenSection) case .alwaysHidden: manager.hotkey(withAction: .toggleAlwaysHiddenSection) } @@ -279,6 +236,18 @@ final class ControlItem { .store(in: &c) if let appState { + appState.$isDraggingMenuBarItem + .receive(on: DispatchQueue.main) + .sink { [weak self] dragging in + guard let self else { + return + } + if dragging { + updateStatusItem(with: state) + } + } + .store(in: &c) + appState.settingsManager.generalSettingsManager.$useIceBar .receive(on: DispatchQueue.main) .sink { [weak self] useIceBar in @@ -353,15 +322,13 @@ final class ControlItem { } if isSectionDivider { - appState.settingsManager.advancedSettingsManager.$showSectionDividers + appState.settingsManager.advancedSettingsManager.$sectionDividerStyle .receive(on: DispatchQueue.main) - .sink { [weak self] shouldShow in + .sink { [weak self] _ in guard let self else { return } - if case .showItems = state { - isVisible = shouldShow - } + updateStatusItem(with: state) } .store(in: &c) } @@ -374,59 +341,140 @@ final class ControlItem { private func updateStatusItem(with state: HidingState) { guard let appState, - let section, let button = statusItem.button else { return } - switch section.name { - case .visible: - isVisible = true - // Enable the cell, as it may have been previously disabled. - button.cell?.isEnabled = true + button.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize) + button.title = "" + button.image = nil + + switch identifier { + case .iceIcon: + updateStatusItemVisibility(true, state: state) + updateButtonEnabledState(true) // Make sure button is enabled. + let icon = appState.settingsManager.generalSettingsManager.iceIcon - // We can usually just set the image directly from the icon. - button.image = switch state { + + // We can usually just create the image directly from the icon. + var image = switch state { case .hideItems: icon.hidden.nsImage(for: appState) case .showItems: icon.visible.nsImage(for: appState) } + if case .custom = icon.name, - let originalImage = button.image + let originalImage = image { // Custom icons need to be resized to fit inside the button. let originalWidth = originalImage.size.width let originalHeight = originalImage.size.height let ratio = max(originalWidth / 25, originalHeight / 17) let newSize = CGSize(width: originalWidth / ratio, height: originalHeight / ratio) - button.image = originalImage.resized(to: newSize) + image = originalImage.resized(to: newSize) } + + button.image = image case .hidden, .alwaysHidden: switch state { case .hideItems: - isVisible = true - // Prevent the cell from highlighting while expanded. - button.cell?.isEnabled = false - // Cell still sometimes briefly flashes on expansion unless manually unhighlighted. - button.isHighlighted = false - button.image = nil + updateStatusItemVisibility(true, state: state) + updateButtonEnabledState(false) // Keep button from highlighting. case .showItems: - isVisible = appState.settingsManager.advancedSettingsManager.showSectionDividers - // Enable the cell, as it may have been previously disabled. - button.cell?.isEnabled = true - // Set the image based on the section name and the hiding state. - switch section.name { - case .hidden: - button.image = ControlItemImage.builtin(.chevronLarge).nsImage(for: appState) - case .alwaysHidden: - button.image = ControlItemImage.builtin(.chevronSmall).nsImage(for: appState) - case .visible: break + switch appState.settingsManager.advancedSettingsManager.sectionDividerStyle { + case .noDivider: + updateStatusItemVisibility(false, state: state) + updateButtonEnabledState(false) // Keep button from highlighting. + + if appState.isDraggingMenuBarItem && appState.settingsManager.advancedSettingsManager.showAllSectionsOnUserDrag { + // We still want a subtle marker between sections. + button.title = "|" + } + case .chevron: + updateStatusItemVisibility(true, state: state) + updateButtonEnabledState(true) // Make sure button is enabled. + + button.image = switch identifier { + case .hidden: + ControlItemImage.builtin(.chevronLarge).nsImage(for: appState) + case .alwaysHidden: + ControlItemImage.builtin(.chevronSmall).nsImage(for: appState) + case .iceIcon: nil + } } } } } + /// Updates the visibility of the status item. + /// + /// The control item must be present in the menu bar so that Ice can determine + /// the items in its section. The status item's `isVisible` property completely + /// removes the item, and therefore cannot be used. Instead, this method sets + /// the status item's length to the appropriate value for the provided hiding + /// state, then either enables or disables a layout constraint on the item's + /// content view and adjusts the item's window if needed. + private func updateStatusItemVisibility(_ isVisible: Bool, state: HidingState) { + guard let appState else { + return + } + if isVisible { + statusItem.length = switch identifier { + case .iceIcon: Lengths.standard + case .hidden, .alwaysHidden: + switch state { + case .hideItems: Lengths.expanded + case .showItems: Lengths.standard + } + } + constraint?.isActive = true + } else { + let wider = appState.isDraggingMenuBarItem && appState.settingsManager.advancedSettingsManager.showAllSectionsOnUserDrag + statusItem.length = wider ? 3 : 0 + constraint?.isActive = false + if let window { + var size = window.frame.size + size.width = wider ? 3 : 1 + window.setContentSize(size) + } + } + } + + /// Adds the control item to the menu bar. + private func addToMenuBar() { + guard !isAddedToMenuBar else { + return + } + statusItem.isVisible = true + } + + /// Removes the control item from the menu bar. + private func removeFromMenuBar() { + guard isAddedToMenuBar else { + return + } + // Setting `statusItem.isVisible` to `false` has the unwanted side + // effect of deleting the preferredPosition. Cache and restore it. + let autosaveName = statusItem.autosaveName as String + let cached = StatusItemDefaults[.preferredPosition, autosaveName] + statusItem.isVisible = false + StatusItemDefaults[.preferredPosition, autosaveName] = cached + } + + /// Updates the enabled state of the status item's button. + private func updateButtonEnabledState(_ isEnabled: Bool) { + guard let button = statusItem.button else { + return + } + if isEnabled { + button.cell?.isEnabled = true + } else { + button.cell?.isEnabled = false + button.isHighlighted = false + } + } + /// Performs the control item's action. @objc private func performAction() { guard @@ -435,28 +483,38 @@ final class ControlItem { else { return } + switch event.type { case .leftMouseDown, .leftMouseUp: if NSEvent.modifierFlags == .control { - statusItem.showMenu(createMenu(with: appState)) - } else if + showMenu() + return + } + + let targetSection: MenuBarSection + + if NSEvent.modifierFlags == .option, - appState.settingsManager.advancedSettingsManager.canToggleAlwaysHiddenSection + let alwaysHiddenSection = appState.menuBarManager.section(withName: .alwaysHidden), + alwaysHiddenSection.isEnabled { - if let alwaysHiddenSection = appState.menuBarManager.section(withName: .alwaysHidden) { - Task { - await alwaysHiddenSection.toggle() - } - } + targetSection = alwaysHiddenSection + } else if + let section = appState.menuBarManager.section(withName: sectionName), + section.isEnabled + { + targetSection = section } else { - Task { - await section?.toggle() - } + return + } + + Task { + await targetSection.toggle() } case .rightMouseUp: - statusItem.showMenu(createMenu(with: appState)) + showMenu() default: - break + return } } @@ -559,6 +617,15 @@ final class ControlItem { return menu } + /// Shows the control item's menu. + private func showMenu() { + guard let appState else { + return + } + let menu = createMenu(with: appState) + statusItem.showMenu(menu) + } + /// Toggles the menu bar section associated with the given menu item. @objc private func toggleMenuBarSection(for menuItem: NSMenuItem) { guard let section = menuItem.representedObject as? MenuBarSection else { @@ -589,25 +656,4 @@ final class ControlItem { } appState.updatesManager.checkForUpdates() } - - /// Adds the control item to the menu bar. - func addToMenuBar() { - guard !isAddedToMenuBar else { - return - } - statusItem.isVisible = true - } - - /// Removes the control item from the menu bar. - func removeFromMenuBar() { - guard isAddedToMenuBar else { - return - } - // Setting `statusItem.isVisible` to `false` has the unwanted side - // effect of deleting the preferredPosition. Cache and restore it. - let autosaveName = statusItem.autosaveName as String - let cached = StatusItemDefaults[.preferredPosition, autosaveName] - statusItem.isVisible = false - StatusItemDefaults[.preferredPosition, autosaveName] = cached - } } diff --git a/Ice/UI/IceBar/IceBar.swift b/Ice/MenuBar/IceBar/IceBar.swift similarity index 85% rename from Ice/UI/IceBar/IceBar.swift rename to Ice/MenuBar/IceBar/IceBar.swift index 1d1db3703..df5c90f64 100644 --- a/Ice/UI/IceBar/IceBar.swift +++ b/Ice/MenuBar/IceBar/IceBar.swift @@ -9,22 +9,26 @@ import SwiftUI // MARK: - IceBarPanel final class IceBarPanel: NSPanel { + /// The shared app state. private weak var appState: AppState? - private(set) var currentSection: MenuBarSection.Name? + /// Manager for the Ice Bar's color. + private let colorManager = IceBarColorManager() - private lazy var colorManager = IceBarColorManager(iceBarPanel: self) + /// The currently displayed section. + private(set) var currentSection: MenuBarSection.Name? + /// Storage for internal observers. private var cancellables = Set() - init(appState: AppState) { + /// Creates a new Ice Bar panel. + init() { super.init( contentRect: .zero, styleMask: [.nonactivatingPanel, .fullSizeContentView, .borderless], backing: .buffered, defer: false ) - self.appState = appState self.title = "Ice Bar" self.titlebarAppearsTransparent = true self.isMovableByWindowBackground = true @@ -37,10 +41,14 @@ final class IceBarPanel: NSPanel { self.collectionBehavior = [.fullScreenAuxiliary, .ignoresCycle, .moveToActiveSpace] } - func performSetup() { + /// Sets up the panel. + func performSetup(with appState: AppState) { + self.appState = appState configureCancellables() + colorManager.performSetup(with: self) } + /// Configures the internal observers. private func configureCancellables() { var c = Set() @@ -93,6 +101,7 @@ final class IceBarPanel: NSPanel { cancellables = c } + /// Updates the panel's frame origin for display on the given screen. private func updateOrigin(for screen: NSScreen) { guard let appState else { return @@ -132,8 +141,8 @@ final class IceBarPanel: NSPanel { guard lowerBound <= upperBound, let iceIcon = appState.itemManager.itemCache.allItems.first(matching: .iceIcon), - // Bridging.getWindowBounds is more reliable than ControlItem.windowFrame, - // i.e. if the control item is offscreen. + // Bridging API is more reliable than ControlItem.frame + // in some cases (like if the control item is offscreen). let itemBounds = Bridging.getWindowBounds(for: iceIcon.windowID) else { return originForRightOfScreen @@ -146,12 +155,15 @@ final class IceBarPanel: NSPanel { setFrameOrigin(getOrigin(for: appState.settingsManager.generalSettingsManager.iceBarLocation)) } + /// Shows the panel on the given screen, displaying the given + /// menu bar section. func show(section: MenuBarSection.Name, on screen: NSScreen) async { guard let appState else { return } - // Important that we set the navigation state and current section before updating the cache. + // IMPORTANT: We must set the navigation state and current section + // before updating the cache. appState.navigationState.isIceBarPresented = true currentSection = section @@ -161,19 +173,27 @@ final class IceBarPanel: NSPanel { await appState.imageCache.updateCache() } - contentView = IceBarHostingView(appState: appState, colorManager: colorManager, screen: screen, section: section) + contentView = IceBarContentHostingView( + appState: appState, + colorManager: colorManager, + screen: screen, + section: section + ) updateOrigin(for: screen) - // Color manager must be updated after updating the panel's origin, but before it is shown. + // Color manager must be updated after updating the panel's origin, + // but before it is shown. // - // Color manager handles frame changes automatically, but does so on the main queue, so we - // need to update manually once before showing the panel to prevent the color from flashing. + // Color manager handles frame changes automatically, but does so on + // the main queue, so we need to update manually once before showing + // the panel to prevent the color from flashing. colorManager.updateAllProperties(with: frame, screen: screen) orderFrontRegardless() } + /// Hides the panel. func hide() { if let name = currentSection, @@ -192,9 +212,9 @@ final class IceBarPanel: NSPanel { } } -// MARK: - IceBarHostingView +// MARK: - IceBarContentHostingView -private final class IceBarHostingView: NSHostingView { +private final class IceBarContentHostingView: NSHostingView { override var safeAreaInsets: NSEdgeInsets { NSEdgeInsets() } @@ -205,15 +225,16 @@ private final class IceBarHostingView: NSHostingView { screen: NSScreen, section: MenuBarSection.Name ) { - super.init( - rootView: IceBarContentView(screen: screen, section: section) - .environmentObject(appState) - .environmentObject(appState.imageCache) - .environmentObject(appState.itemManager) - .environmentObject(appState.menuBarManager) - .environmentObject(colorManager) - .erasedToAnyView() + let rootView = IceBarContentView( + appState: appState, + colorManager: colorManager, + itemManager: appState.itemManager, + imageCache: appState.imageCache, + menuBarManager: appState.menuBarManager, + screen: screen, + section: section ) + super.init(rootView: rootView) } @available(*, unavailable) @@ -222,7 +243,7 @@ private final class IceBarHostingView: NSHostingView { } @available(*, unavailable) - required init(rootView: AnyView) { + required init(rootView: IceBarContentView) { fatalError("init(rootView:) has not been implemented") } @@ -234,11 +255,11 @@ private final class IceBarHostingView: NSHostingView { // MARK: - IceBarContentView private struct IceBarContentView: View { - @EnvironmentObject var appState: AppState - @EnvironmentObject var colorManager: IceBarColorManager - @EnvironmentObject var itemManager: MenuBarItemManager - @EnvironmentObject var imageCache: MenuBarItemImageCache - @EnvironmentObject var menuBarManager: MenuBarManager + @ObservedObject var appState: AppState + @ObservedObject var colorManager: IceBarColorManager + @ObservedObject var itemManager: MenuBarItemManager + @ObservedObject var imageCache: MenuBarItemImageCache + @ObservedObject var menuBarManager: MenuBarManager @State private var frame = CGRect.zero @State private var scrollIndicatorsFlashTrigger = 0 @@ -335,7 +356,13 @@ private struct IceBarContentView: View { ScrollView(.horizontal) { HStack(spacing: 0) { ForEach(items, id: \.windowID) { item in - IceBarItemView(item: item, section: section) + IceBarItemView( + imageCache: imageCache, + itemManager: itemManager, + menuBarManager: menuBarManager, + item: item, + section: section + ) } } } @@ -352,9 +379,9 @@ private struct IceBarContentView: View { // MARK: - IceBarItemView private struct IceBarItemView: View { - @EnvironmentObject var imageCache: MenuBarItemImageCache - @EnvironmentObject var itemManager: MenuBarItemManager - @EnvironmentObject var menuBarManager: MenuBarManager + @ObservedObject var imageCache: MenuBarItemImageCache + @ObservedObject var itemManager: MenuBarItemManager + @ObservedObject var menuBarManager: MenuBarManager let item: MenuBarItem let section: MenuBarSection.Name diff --git a/Ice/UI/IceBar/IceBarColorManager.swift b/Ice/MenuBar/IceBar/IceBarColorManager.swift similarity index 98% rename from Ice/UI/IceBar/IceBarColorManager.swift rename to Ice/MenuBar/IceBar/IceBarColorManager.swift index 3979e7479..566589f03 100644 --- a/Ice/UI/IceBar/IceBarColorManager.swift +++ b/Ice/MenuBar/IceBar/IceBarColorManager.swift @@ -20,7 +20,7 @@ final class IceBarColorManager: ObservableObject { private var cancellables = Set() - init(iceBarPanel: IceBarPanel) { + func performSetup(with iceBarPanel: IceBarPanel) { self.iceBarPanel = iceBarPanel configureCancellables() } diff --git a/Ice/UI/IceBar/IceBarLocation.swift b/Ice/MenuBar/IceBar/IceBarLocation.swift similarity index 100% rename from Ice/UI/IceBar/IceBarLocation.swift rename to Ice/MenuBar/IceBar/IceBarLocation.swift diff --git a/Ice/UI/LayoutBar/LayoutBar.swift b/Ice/MenuBar/LayoutBar/LayoutBar.swift similarity index 100% rename from Ice/UI/LayoutBar/LayoutBar.swift rename to Ice/MenuBar/LayoutBar/LayoutBar.swift diff --git a/Ice/UI/LayoutBar/LayoutBarContainer.swift b/Ice/MenuBar/LayoutBar/LayoutBarContainer.swift similarity index 100% rename from Ice/UI/LayoutBar/LayoutBarContainer.swift rename to Ice/MenuBar/LayoutBar/LayoutBarContainer.swift diff --git a/Ice/UI/LayoutBar/LayoutBarItemView.swift b/Ice/MenuBar/LayoutBar/LayoutBarItemView.swift similarity index 100% rename from Ice/UI/LayoutBar/LayoutBarItemView.swift rename to Ice/MenuBar/LayoutBar/LayoutBarItemView.swift diff --git a/Ice/UI/LayoutBar/LayoutBarPaddingView.swift b/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift similarity index 97% rename from Ice/UI/LayoutBar/LayoutBarPaddingView.swift rename to Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift index d5ad049e1..1206517ea 100644 --- a/Ice/UI/LayoutBar/LayoutBarPaddingView.swift +++ b/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift @@ -109,7 +109,7 @@ final class LayoutBarPaddingView: NSView { if let targetItem { move(item: draggingSource.item, to: .leftOfItem(targetItem)) } else { - Logger.default.error("No target item for layout bar drag") + Logger.general.error("No target item for layout bar drag") } } else if arrangedViews.indices.contains(index + 1) { // we have a view to the right of the dragging source @@ -135,7 +135,7 @@ final class LayoutBarPaddingView: NSView { try await appState.itemManager.slowMove(item: item, to: destination) appState.itemManager.removeTempShownItemFromCache(with: item.info) } catch { - Logger.default.error("Error moving menu bar item: \(error, privacy: .public)") + Logger.general.error("Error moving menu bar item: \(error, privacy: .public)") let alert = NSAlert(error: error) alert.runModal() } diff --git a/Ice/UI/LayoutBar/LayoutBarScrollView.swift b/Ice/MenuBar/LayoutBar/LayoutBarScrollView.swift similarity index 100% rename from Ice/UI/LayoutBar/LayoutBarScrollView.swift rename to Ice/MenuBar/LayoutBar/LayoutBarScrollView.swift diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift index b360f83b3..5c50f0058 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift @@ -9,12 +9,15 @@ import OSLog /// Cache for menu bar item images. final class MenuBarItemImageCache: ObservableObject { - /// Logger for the menu bar item image cache. - private static let logger = Logger(category: "MenuBarItemImageCache") - /// The cached item images. @Published private(set) var images = [MenuBarItemInfo: CGImage]() + /// Logger for the menu bar item image cache. + private let logger = Logger(category: "MenuBarItemImageCache") + + /// Queue to run cache operations. + private let queue = DispatchQueue(label: "MenuBarItemImageCache", qos: .background) + /// The screen of the cached item images. private(set) var screen: NSScreen? @@ -27,14 +30,10 @@ final class MenuBarItemImageCache: ObservableObject { /// Storage for internal observers. private var cancellables = Set() - /// Creates a cache with the given app state. - init(appState: AppState) { - self.appState = appState - } - /// Sets up the cache. @MainActor - func performSetup() { + func performSetup(with appState: AppState) { + self.appState = appState configureCancellables() } @@ -80,7 +79,7 @@ final class MenuBarItemImageCache: ObservableObject { /// Logs a reason for skipping the cache. private func logSkippingCache(reason: @escaping @autoclosure () -> String) { - MenuBarItemImageCache.logger.debug("Skipping menu bar item image cache as \(reason(), privacy: .public)") + logger.debug("Skipping menu bar item image cache as \(reason(), privacy: .public)") } /// Returns a Boolean value that indicates whether caching menu bar items failed for @@ -162,7 +161,7 @@ final class MenuBarItemImageCache: ObservableObject { images[itemInfo] = itemImage } } else { - MenuBarItemImageCache.logger.warning( + logger.warning( """ Composite capture failed for \(section.logString, privacy: .public). \ Attempting to capture each item individually. @@ -215,7 +214,7 @@ final class MenuBarItemImageCache: ObservableObject { } let sectionImages = await createImages(for: section, screen: screen) guard !sectionImages.isEmpty else { - MenuBarItemImageCache.logger.warning( + logger.warning( """ Failed to update cached menu bar item images for \ \(section.logString, privacy: .public) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index 13eb98e81..c5a73c243 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -138,13 +138,9 @@ final class MenuBarItemManager: ObservableObject { return Date.now.timeIntervalSince(lastItemMoveStartDate) <= 1 } - /// Creates a manager with the given app state. - init(appState: AppState) { - self.appState = appState - } - /// Sets up the manager. - func performSetup() { + func performSetup(with appState: AppState) { + self.appState = appState configureCancellables() } diff --git a/Ice/MenuBar/MenuBarManager.swift b/Ice/MenuBar/MenuBarManager.swift index c1c0fbfe0..1219ba345 100644 --- a/Ice/MenuBar/MenuBarManager.swift +++ b/Ice/MenuBar/MenuBarManager.swift @@ -41,14 +41,18 @@ final class MenuBarManager: ObservableObject { /// A Boolean value that indicates whether the application menus are hidden. private var isHidingApplicationMenus = false - /// The managed sections in the menu bar. - private(set) var sections = [MenuBarSection]() - /// The panel that contains the Ice Bar interface. - let iceBarPanel: IceBarPanel + let iceBarPanel = IceBarPanel() /// The panel that contains the menu bar search interface. - let searchPanel: MenuBarSearchPanel + let searchPanel = MenuBarSearchPanel() + + /// The managed sections in the menu bar. + let sections = [ + MenuBarSection(name: .visible), + MenuBarSection(name: .hidden), + MenuBarSection(name: .alwaysHidden), + ] /// A Boolean value that indicates whether the manager can update its stored /// information for the menu bar's average color. @@ -62,38 +66,15 @@ final class MenuBarManager: ObservableObject { sections.contains { !$0.isHidden } } - /// Initializes a new menu bar manager instance. - init(appState: AppState) { - self.iceBarPanel = IceBarPanel(appState: appState) - self.searchPanel = MenuBarSearchPanel(appState: appState) - self.appState = appState - } - /// Performs the initial setup of the menu bar manager. - func performSetup() { - initializeSections() + func performSetup(with appState: AppState) { + self.appState = appState configureCancellables() - iceBarPanel.performSetup() - } - - /// Performs the initial setup of the menu bar manager's sections. - private func initializeSections() { - // Make sure initialization can only happen once. - guard sections.isEmpty else { - logger.warning("Sections already initialized") - return - } - - guard let appState else { - logger.error("Error initializing menu bar sections: Missing app state") - return + iceBarPanel.performSetup(with: appState) + searchPanel.performSetup(with: appState) + for section in sections { + section.performSetup(with: appState) } - - sections = [ - MenuBarSection(name: .visible, appState: appState), - MenuBarSection(name: .hidden, appState: appState), - MenuBarSection(name: .alwaysHidden, appState: appState), - ] } /// Configures the internal observers for the manager. @@ -177,10 +158,7 @@ final class MenuBarManager: ObservableObject { Publishers.MergeMany(sections.map { $0.controlItem.$state }) .receive(on: DispatchQueue.main) .sink { [weak self] _ in - guard - let self, - let appState - else { + guard let self, let appState else { return } diff --git a/Ice/MenuBar/MenuBarSection.swift b/Ice/MenuBar/MenuBarSection.swift index 559d885a5..a2d7d77a0 100644 --- a/Ice/MenuBar/MenuBarSection.swift +++ b/Ice/MenuBar/MenuBarSection.swift @@ -107,24 +107,29 @@ final class MenuBarSection { return controlItem.isAddedToMenuBar } - /// Creates a section with the given name, control item, and app state. - init(name: Name, controlItem: ControlItem, appState: AppState) { + /// Creates a section with the given name and control item. + init(name: Name, controlItem: ControlItem) { self.name = name self.controlItem = controlItem - self.appState = appState } - /// Creates a section with the given name and app state. - convenience init(name: Name, appState: AppState) { + /// Creates a section with the given name. + convenience init(name: Name) { let controlItem = switch name { case .visible: - ControlItem(identifier: .iceIcon, appState: appState) + ControlItem(identifier: .iceIcon) case .hidden: - ControlItem(identifier: .hidden, appState: appState) + ControlItem(identifier: .hidden) case .alwaysHidden: - ControlItem(identifier: .alwaysHidden, appState: appState) + ControlItem(identifier: .alwaysHidden) } - self.init(name: name, controlItem: controlItem, appState: appState) + self.init(name: name, controlItem: controlItem) + } + + /// Performs the initial setup of the section. + func performSetup(with appState: AppState) { + self.appState = appState + controlItem.performSetup(with: appState) } /// Shows the section. diff --git a/Ice/MenuBar/Search/MenuBarSearchPanel.swift b/Ice/MenuBar/Search/MenuBarSearchPanel.swift index 22b309c2a..cd513310d 100644 --- a/Ice/MenuBar/Search/MenuBarSearchPanel.swift +++ b/Ice/MenuBar/Search/MenuBarSearchPanel.swift @@ -51,21 +51,25 @@ final class MenuBarSearchPanel: NSPanel { /// Overridden to always be `true`. override var canBecomeKey: Bool { true } - /// Creates a menu bar search panel with the given app state. - init(appState: AppState) { + /// Creates a menu bar search panel. + init() { super.init( contentRect: .zero, styleMask: [.titled, .fullSizeContentView, .nonactivatingPanel, .utilityWindow, .hudWindow], backing: .buffered, defer: false ) - self.appState = appState self.titlebarAppearsTransparent = true self.isMovableByWindowBackground = false self.animationBehavior = .none self.isFloatingPanel = true self.level = .floating self.collectionBehavior = [.fullScreenAuxiliary, .ignoresCycle, .moveToActiveSpace] + } + + /// Performs the initial setup of the panel. + func performSetup(with appState: AppState) { + self.appState = appState configureCancellables() } diff --git a/Ice/Permissions/PermissionsManager.swift b/Ice/Permissions/PermissionsManager.swift index 83c2ad52b..7f64ddccf 100644 --- a/Ice/Permissions/PermissionsManager.swift +++ b/Ice/Permissions/PermissionsManager.swift @@ -20,34 +20,30 @@ final class PermissionsManager: ObservableObject { /// The manager's logger. let logger = Logger(category: "Permissions") - /// The permission for "Accessibility" features. + /// The permission for Accessibility features. let accessibilityPermission = AccessibilityPermission() - /// The permission for "Screen Recording" features. + /// The permission for Screen Recording features. let screenRecordingPermission = ScreenRecordingPermission() /// The state of the app's granted permissions. @Published private(set) var permissionsState: PermissionsState = .missing - /// The shared app state. - private(set) weak var appState: AppState? - /// Storage for internal observers. private var cancellable: AnyCancellable? - /// All permissions the app asks for. + /// The permissions required for full app functionality. var allPermissions: [Permission] { [accessibilityPermission, screenRecordingPermission] } - /// The required permissions for basic app functionality. + /// The permissions required for basic app functionality. var requiredPermissions: [Permission] { allPermissions.filter { $0.isRequired } } /// Creates a new permissions manager. - init(appState: AppState) { - self.appState = appState + init() { self.updatePermissionsState() self.cancellable = Publishers.MergeMany(allPermissions.map { $0.$hasPermission }) .receive(on: DispatchQueue.main) diff --git a/Ice/Permissions/PermissionsView.swift b/Ice/Permissions/PermissionsView.swift index 256d25c5a..6f04b2209 100644 --- a/Ice/Permissions/PermissionsView.swift +++ b/Ice/Permissions/PermissionsView.swift @@ -6,7 +6,8 @@ import SwiftUI struct PermissionsView: View { - @EnvironmentObject private var manager: PermissionsManager + @EnvironmentObject var appState: AppState + @EnvironmentObject var manager: PermissionsManager private var continueButtonText: LocalizedStringKey { if case .hasRequired = manager.permissionsState { @@ -103,10 +104,6 @@ struct PermissionsView: View { @ViewBuilder private var continueButton: some View { Button { - guard let appState = manager.appState else { - return - } - appState.dismissWindow(.permissions) guard manager.permissionsState != .missing else { @@ -152,9 +149,6 @@ struct PermissionsView: View { } Button { - guard let appState = manager.appState else { - return - } permission.performRequest() Task { await permission.waitForPermission() diff --git a/Ice/Permissions/PermissionsWindow.swift b/Ice/Permissions/PermissionsWindow.swift index 4429a4208..f7512c1d4 100644 --- a/Ice/Permissions/PermissionsWindow.swift +++ b/Ice/Permissions/PermissionsWindow.swift @@ -29,6 +29,7 @@ struct PermissionsWindow: Scene { } .windowResizability(.contentSize) .windowStyle(.hiddenTitleBar) + .environmentObject(appState) .environmentObject(appState.permissionsManager) } } diff --git a/Ice/Settings/SettingsManagers/AdvancedSettingsManager.swift b/Ice/Settings/SettingsManagers/AdvancedSettingsManager.swift index 9585b31ab..b9a624cf4 100644 --- a/Ice/Settings/SettingsManagers/AdvancedSettingsManager.swift +++ b/Ice/Settings/SettingsManagers/AdvancedSettingsManager.swift @@ -4,7 +4,9 @@ // import Combine -import Foundation +import SwiftUI + +// MARK: - AdvancedSettingsManager @MainActor final class AdvancedSettingsManager: ObservableObject { @@ -12,17 +14,20 @@ final class AdvancedSettingsManager: ObservableObject { /// should be hidden if needed to show all menu bar items. @Published var hideApplicationMenus = true - /// A Boolean value that indicates whether section divider control - /// items should be shown. - @Published var showSectionDividers = false + /// A Boolean value that indicates whether to show a context menu + /// when the user right-clicks the menu bar. + @Published var showContextMenuOnRightClick = true /// A Boolean value that indicates whether the always-hidden section /// is enabled. @Published var enableAlwaysHiddenSection = false - /// A Boolean value that indicates whether the always-hidden section - /// can be toggled by holding down the Option key. - @Published var canToggleAlwaysHiddenSection = true + /// A Boolean value that indicates whether to show all sections when + /// the user is dragging items in the menu bar. + @Published var showAllSectionsOnUserDrag = true + + /// The display style for section divider control items. + @Published var sectionDividerStyle: SectionDividerStyle = .noDivider /// The delay before showing on hover. @Published var showOnHoverDelay: TimeInterval = 0.2 @@ -30,36 +35,31 @@ final class AdvancedSettingsManager: ObservableObject { /// Time interval to temporarily show items for. @Published var tempShowInterval: TimeInterval = 15 - /// A Boolean value that indicates whether to show all sections when - /// the user is dragging items in the menu bar. - @Published var showAllSectionsOnUserDrag = true - - @Published var showContextMenuOnRightClick = true - /// Storage for internal observers. private var cancellables = Set() /// The shared app state. private(set) weak var appState: AppState? - init(appState: AppState) { + func performSetup(with appState: AppState) { self.appState = appState - } - - func performSetup() { loadInitialState() configureCancellables() } private func loadInitialState() { Defaults.ifPresent(key: .hideApplicationMenus, assign: &hideApplicationMenus) - Defaults.ifPresent(key: .showSectionDividers, assign: &showSectionDividers) + Defaults.ifPresent(key: .showContextMenuOnRightClick, assign: &showContextMenuOnRightClick) Defaults.ifPresent(key: .enableAlwaysHiddenSection, assign: &enableAlwaysHiddenSection) - Defaults.ifPresent(key: .canToggleAlwaysHiddenSection, assign: &canToggleAlwaysHiddenSection) + Defaults.ifPresent(key: .showAllSectionsOnUserDrag, assign: &showAllSectionsOnUserDrag) Defaults.ifPresent(key: .showOnHoverDelay, assign: &showOnHoverDelay) Defaults.ifPresent(key: .tempShowInterval, assign: &tempShowInterval) - Defaults.ifPresent(key: .showAllSectionsOnUserDrag, assign: &showAllSectionsOnUserDrag) - Defaults.ifPresent(key: .showContextMenuOnRightClick, assign: &showContextMenuOnRightClick) + + Defaults.ifPresent(key: .sectionDividerStyle) { rawValue in + if let style = SectionDividerStyle(rawValue: rawValue) { + sectionDividerStyle = style + } + } } private func configureCancellables() { @@ -72,10 +72,10 @@ final class AdvancedSettingsManager: ObservableObject { } .store(in: &c) - $showSectionDividers + $showContextMenuOnRightClick .receive(on: DispatchQueue.main) - .sink { shouldShow in - Defaults.set(shouldShow, forKey: .showSectionDividers) + .sink { showAll in + Defaults.set(showAll, forKey: .showContextMenuOnRightClick) } .store(in: &c) @@ -86,10 +86,17 @@ final class AdvancedSettingsManager: ObservableObject { } .store(in: &c) - $canToggleAlwaysHiddenSection + $showAllSectionsOnUserDrag .receive(on: DispatchQueue.main) - .sink { canToggle in - Defaults.set(canToggle, forKey: .canToggleAlwaysHiddenSection) + .sink { showAll in + Defaults.set(showAll, forKey: .showAllSectionsOnUserDrag) + } + .store(in: &c) + + $sectionDividerStyle + .receive(on: DispatchQueue.main) + .sink { style in + Defaults.set(style.rawValue, forKey: .sectionDividerStyle) } .store(in: &c) @@ -107,23 +114,26 @@ final class AdvancedSettingsManager: ObservableObject { } .store(in: &c) - $showAllSectionsOnUserDrag - .receive(on: DispatchQueue.main) - .sink { showAll in - Defaults.set(showAll, forKey: .showAllSectionsOnUserDrag) - } - .store(in: &c) - - $showContextMenuOnRightClick - .receive(on: DispatchQueue.main) - .sink { showAll in - Defaults.set(showAll, forKey: .showContextMenuOnRightClick) - } - .store(in: &c) - cancellables = c } } // MARK: AdvancedSettingsManager: BindingExposable extension AdvancedSettingsManager: BindingExposable { } + +// MARK: - SectionDividerStyle + +enum SectionDividerStyle: Int, CaseIterable, Identifiable { + case noDivider = 0 + case chevron = 1 + + var id: Int { rawValue } + + /// Localized string key representation. + var localized: LocalizedStringKey { + switch self { + case .noDivider: "None" + case .chevron: "Chevron" + } + } +} diff --git a/Ice/Settings/SettingsManagers/GeneralSettingsManager.swift b/Ice/Settings/SettingsManagers/GeneralSettingsManager.swift index 7eba6d9eb..afe8dd180 100644 --- a/Ice/Settings/SettingsManagers/GeneralSettingsManager.swift +++ b/Ice/Settings/SettingsManagers/GeneralSettingsManager.swift @@ -4,8 +4,10 @@ // import Combine -import Foundation import OSLog +import SwiftUI + +// MARK: - GeneralSettingsManager @MainActor final class GeneralSettingsManager: ObservableObject { @@ -72,11 +74,8 @@ final class GeneralSettingsManager: ObservableObject { /// The shared app state. private(set) weak var appState: AppState? - init(appState: AppState) { + func performSetup(with appState: AppState) { self.appState = appState - } - - func performSetup() { loadInitialState() configureCancellables() } @@ -220,3 +219,26 @@ final class GeneralSettingsManager: ObservableObject { // MARK: GeneralSettingsManager: BindingExposable extension GeneralSettingsManager: BindingExposable { } + +// MARK: - RehideStrategy + +/// A type that determines how the auto-rehide feature works. +enum RehideStrategy: Int, CaseIterable, Identifiable { + /// Menu bar items are rehidden using a smart algorithm. + case smart = 0 + /// Menu bar items are rehidden after a given time interval. + case timed = 1 + /// Menu bar items are rehidden when the focused app changes. + case focusedApp = 2 + + var id: Int { rawValue } + + /// Localized string key representation. + var localized: LocalizedStringKey { + switch self { + case .smart: "Smart" + case .timed: "Timed" + case .focusedApp: "Focused app" + } + } +} diff --git a/Ice/Settings/SettingsManagers/HotkeySettingsManager.swift b/Ice/Settings/SettingsManagers/HotkeySettingsManager.swift index 0b49ff778..fcd0a01e2 100644 --- a/Ice/Settings/SettingsManagers/HotkeySettingsManager.swift +++ b/Ice/Settings/SettingsManagers/HotkeySettingsManager.swift @@ -26,11 +26,8 @@ final class HotkeySettingsManager: ObservableObject { /// The shared app state. private(set) weak var appState: AppState? - init(appState: AppState) { + func performSetup(with appState: AppState) { self.appState = appState - } - - func performSetup() { loadInitialState() configureCancellables() } @@ -63,7 +60,7 @@ final class HotkeySettingsManager: ObservableObject { } var dict = [String: Data]() for hotkey in hotkeys { - hotkey.assignAppState(appState) + hotkey.performSetup(with: appState) do { dict[hotkey.action.rawValue] = try self.encoder.encode(hotkey.keyCombination) } catch { diff --git a/Ice/Settings/SettingsManagers/SettingsManager.swift b/Ice/Settings/SettingsManagers/SettingsManager.swift index fca81bd95..81b4ea393 100644 --- a/Ice/Settings/SettingsManagers/SettingsManager.swift +++ b/Ice/Settings/SettingsManagers/SettingsManager.swift @@ -8,32 +8,22 @@ import Combine @MainActor final class SettingsManager: ObservableObject { /// The manager for general settings. - let generalSettingsManager: GeneralSettingsManager + let generalSettingsManager = GeneralSettingsManager() /// The manager for advanced settings. - let advancedSettingsManager: AdvancedSettingsManager + let advancedSettingsManager = AdvancedSettingsManager() /// The manager for hotkey settings. - let hotkeySettingsManager: HotkeySettingsManager + let hotkeySettingsManager = HotkeySettingsManager() /// Storage for internal observers. private var cancellables = Set() - /// The shared app state. - private(set) weak var appState: AppState? - - init(appState: AppState) { - self.generalSettingsManager = GeneralSettingsManager(appState: appState) - self.advancedSettingsManager = AdvancedSettingsManager(appState: appState) - self.hotkeySettingsManager = HotkeySettingsManager(appState: appState) - self.appState = appState - } - - func performSetup() { + func performSetup(with appState: AppState) { configureCancellables() - generalSettingsManager.performSetup() - advancedSettingsManager.performSetup() - hotkeySettingsManager.performSetup() + generalSettingsManager.performSetup(with: appState) + advancedSettingsManager.performSetup(with: appState) + hotkeySettingsManager.performSetup(with: appState) } private func configureCancellables() { diff --git a/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift b/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift index 47d19e5a3..1c7554570 100644 --- a/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift @@ -28,17 +28,14 @@ struct AdvancedSettingsPane: View { var body: some View { IceForm { - IceSection { - hideApplicationMenus - showSectionDividers - showAllSectionsOnUserDrag - showContextMenuOnRightClick - } - IceSection { + IceSection("Menu Bar Sections") { enableAlwaysHiddenSection - canToggleAlwaysHiddenSection + showAllSectionsOnUserDrag + sectionDividerStyle } - IceSection { + IceSection("Other") { + hideApplicationMenus + showContextMenuOnRightClick showOnHoverDelaySlider tempShowIntervalSlider } @@ -50,47 +47,62 @@ struct AdvancedSettingsPane: View { @ViewBuilder private var hideApplicationMenus: some View { - Toggle("Hide application menus when showing menu bar items", isOn: manager.bindings.hideApplicationMenus) - .annotation("Make more room in the menu bar by hiding the left application menus if needed") + Toggle( + "Hide application menus when showing menu bar items", + isOn: manager.bindings.hideApplicationMenus + ) + .annotation { + Text( + """ + Make more room in the menu bar by hiding the current app menus if \ + needed. macOS requires Ice to become visible in the Dock while this \ + setting is in effect. + """ + ) + .padding(.trailing, 75) + } } @ViewBuilder - private var showSectionDividers: some View { - Toggle("Show section dividers", isOn: manager.bindings.showSectionDividers) - .annotation { - HStack(spacing: 2) { - Text("Insert divider items") - if let nsImage = ControlItemImage.builtin(.chevronLarge).nsImage(for: appState) { - HStack(spacing: 0) { - Text("(") - .font(.body.monospaced().bold()) - Image(nsImage: nsImage) - .padding(.horizontal, -2) - Text(")") - .font(.body.monospaced().bold()) - } - } - Text("between sections") - } - } + private var showContextMenuOnRightClick: some View { + Toggle( + "Enable secondary context menu", + isOn: manager.bindings.showContextMenuOnRightClick + ) + .annotation { + Text( + """ + Right-clicking in an empty area of the menu bar displays a minimal \ + version of Ice's menu. Disable this setting if you're experiencing \ + conflicts with other apps. + """ + ) + .padding(.trailing, 75) + } } @ViewBuilder private var enableAlwaysHiddenSection: some View { - Toggle("Enable always-hidden section", isOn: manager.bindings.enableAlwaysHiddenSection) + Toggle( + "Enable always-hidden section", + isOn: manager.bindings.enableAlwaysHiddenSection + ) } @ViewBuilder - private var canToggleAlwaysHiddenSection: some View { - if manager.enableAlwaysHiddenSection { - Toggle("Always-hidden section can be shown", isOn: manager.bindings.canToggleAlwaysHiddenSection) - .annotation { - if appState.settingsManager.generalSettingsManager.showOnClick { - Text("Option + click one of Ice's menu bar items, or inside an empty area of the menu bar to show the section") - } else { - Text("Option + click one of Ice's menu bar items to show the section") - } - } + private var showAllSectionsOnUserDrag: some View { + Toggle( + "Show all sections when Command + dragging menu bar items", + isOn: manager.bindings.showAllSectionsOnUserDrag + ) + } + + @ViewBuilder + private var sectionDividerStyle: some View { + IcePicker("Section divider style", selection: manager.bindings.sectionDividerStyle) { + ForEach(SectionDividerStyle.allCases) { style in + Text(style.localized).tag(style) + } } } @@ -105,13 +117,12 @@ struct AdvancedSettingsPane: View { ) } label: { Text("Show on hover delay") - .frame(minHeight: .compactSliderMinHeight) .frame(minWidth: maxSliderLabelWidth, alignment: .leading) .onFrameChange { frame in maxSliderLabelWidth = max(maxSliderLabelWidth, frame.width) } } - .annotation("The amount of time to wait before showing on hover") + .annotation("The amount of time to wait before showing on hover.") } @ViewBuilder @@ -125,23 +136,12 @@ struct AdvancedSettingsPane: View { ) } label: { Text("Temporarily shown item delay") - .frame(minHeight: .compactSliderMinHeight) .frame(minWidth: maxSliderLabelWidth, alignment: .leading) .onFrameChange { frame in maxSliderLabelWidth = max(maxSliderLabelWidth, frame.width) } } - .annotation("The amount of time to wait before hiding temporarily shown menu bar items") - } - - @ViewBuilder - private var showAllSectionsOnUserDrag: some View { - Toggle("Show all sections when Command + dragging menu bar items", isOn: manager.bindings.showAllSectionsOnUserDrag) - } - - @ViewBuilder - private var showContextMenuOnRightClick: some View { - Toggle("Show context menu on right click", isOn: manager.bindings.showContextMenuOnRightClick) + .annotation("The amount of time to wait before hiding temporarily shown menu bar items.") } @ViewBuilder @@ -167,9 +167,3 @@ struct AdvancedSettingsPane: View { } } } - -#Preview { - AdvancedSettingsPane() - .fixedSize() - .environmentObject(AppState()) -} diff --git a/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift b/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift index 7991e0030..3d41710c6 100644 --- a/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift @@ -12,7 +12,7 @@ struct GeneralSettingsPane: View { @State private var isPresentingError = false @State private var presentedError: LocalizedErrorWrapper? @State private var isApplyingOffset = false - @State private var tempItemSpacingOffset: CGFloat = 0 // Temporary state for the slider + @State private var tempItemSpacingOffset: CGFloat = 0 private var manager: GeneralSettingsManager { appState.settingsManager.generalSettingsManager @@ -113,8 +113,8 @@ struct GeneralSettingsPane: View { private var iceIconOptions: some View { Toggle("Show Ice icon", isOn: manager.bindings.showIceIcon) .annotation { - if !manager.showIceIcon { - Text("You can still access Ice's settings by right-clicking an empty area in the menu bar") + if !manager.showIceIcon, appState.settingsManager.advancedSettingsManager.showContextMenuOnRightClick { + Text("You can still access Ice's settings by right-clicking in an empty area of the menu bar.") } } if manager.showIceIcon { @@ -148,7 +148,7 @@ struct GeneralSettingsPane: View { } title: { menuItem(for: manager.iceIcon) } - .annotation("Choose a custom icon to show in the menu bar") + .annotation("Choose a custom icon to show in the menu bar.") .fileImporter( isPresented: $isImportingCustomIceIcon, allowedContentTypes: [.image] @@ -168,7 +168,7 @@ struct GeneralSettingsPane: View { if case .custom = manager.iceIcon.name { Toggle("Apply system theme to icon", isOn: manager.bindings.customIceIconIsTemplate) - .annotation("Display the icon as a monochrome image matching the system appearance") + .annotation("Display the icon as a monochrome image matching the system appearance.") } } } @@ -184,7 +184,7 @@ struct GeneralSettingsPane: View { @ViewBuilder private var useIceBar: some View { Toggle("Use Ice Bar", isOn: manager.bindings.useIceBar) - .annotation("Show hidden menu bar items in a separate bar below the menu bar") + .annotation("Show hidden menu bar items in a separate bar below the menu bar.") } @ViewBuilder @@ -197,11 +197,11 @@ struct GeneralSettingsPane: View { .annotation { switch manager.iceBarLocation { case .dynamic: - Text("The Ice Bar's location changes based on context") + Text("The Ice Bar's location changes based on context.") case .mousePointer: - Text("The Ice Bar is centered below the mouse pointer") + Text("The Ice Bar is centered below the mouse pointer.") case .iceIcon: - Text("The Ice Bar is centered below the Ice icon") + Text("The Ice Bar is centered below the Ice icon.") } } } @@ -209,19 +209,19 @@ struct GeneralSettingsPane: View { @ViewBuilder private var showOnClick: some View { Toggle("Show on click", isOn: manager.bindings.showOnClick) - .annotation("Click inside an empty area of the menu bar to show hidden menu bar items") + .annotation("Click inside an empty area of the menu bar to show hidden menu bar items.") } @ViewBuilder private var showOnHover: some View { Toggle("Show on hover", isOn: manager.bindings.showOnHover) - .annotation("Hover over an empty area of the menu bar to show hidden menu bar items") + .annotation("Hover over an empty area of the menu bar to show hidden menu bar items.") } @ViewBuilder private var showOnScroll: some View { Toggle("Show on scroll", isOn: manager.bindings.showOnScroll) - .annotation("Scroll or swipe in the menu bar to toggle hidden menu bar items") + .annotation("Scroll or swipe in the menu bar to toggle hidden menu bar items.") } @ViewBuilder @@ -268,15 +268,11 @@ struct GeneralSettingsPane: View { "Applying this setting will relaunch all apps with menu bar items. Some apps may need to be manually relaunched.", spacing: 2 ) - .annotation(spacing: 10, font: .callout.bold()) { - IceGroupBox { - Label { - Text("Note: You may need to log out and back in for this setting to apply properly.") - } icon: { - Image(systemName: "exclamationmark.circle") - } - .frame(maxWidth: .infinity) - } + .annotation(spacing: 10) { + CalloutBox( + "Note: You may need to log out and back in for this setting to apply properly.", + systemImage: "exclamationmark.circle" + ) } .onAppear { tempItemSpacingOffset = manager.itemSpacingOffset @@ -293,11 +289,11 @@ struct GeneralSettingsPane: View { .annotation { switch manager.rehideStrategy { case .smart: - Text("Menu bar items are rehidden using a smart algorithm") + Text("Menu bar items are rehidden using a smart algorithm.") case .timed: - Text("Menu bar items are rehidden after a fixed amount of time") + Text("Menu bar items are rehidden after a fixed amount of time.") case .focusedApp: - Text("Menu bar items are rehidden when the focused app changes") + Text("Menu bar items are rehidden when the focused app changes.") } } } diff --git a/Ice/Settings/SettingsPanes/HotkeysSettingsPane.swift b/Ice/Settings/SettingsPanes/HotkeysSettingsPane.swift index b05cd89e9..0c33c966f 100644 --- a/Ice/Settings/SettingsPanes/HotkeysSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/HotkeysSettingsPane.swift @@ -8,7 +8,7 @@ import SwiftUI struct HotkeysSettingsPane: View { @EnvironmentObject var appState: AppState - private var hotkeySettingsManager: HotkeySettingsManager { + private var manager: HotkeySettingsManager { appState.settingsManager.hotkeySettingsManager } @@ -23,7 +23,6 @@ struct HotkeysSettingsPane: View { } IceSection("Other") { hotkeyRecorder(forAction: .enableIceBar) - hotkeyRecorder(forAction: .showSectionDividers) hotkeyRecorder(forAction: .toggleApplicationMenus) } } @@ -31,7 +30,7 @@ struct HotkeysSettingsPane: View { @ViewBuilder private func hotkeyRecorder(forAction action: HotkeyAction) -> some View { - if let hotkey = hotkeySettingsManager.hotkey(withAction: action) { + if let hotkey = manager.hotkey(withAction: action) { HotkeyRecorder(hotkey: hotkey) { switch action { case .toggleHiddenSection: @@ -42,8 +41,6 @@ struct HotkeysSettingsPane: View { Text("Search menu bar items") case .enableIceBar: Text("Enable the Ice Bar") - case .showSectionDividers: - Text("Show section dividers") case .toggleApplicationMenus: Text("Toggle application menus") } diff --git a/Ice/Settings/SettingsPanes/MenuBarAppearanceSettingsPane.swift b/Ice/Settings/SettingsPanes/MenuBarAppearanceSettingsPane.swift index ca28b13c0..657885d33 100644 --- a/Ice/Settings/SettingsPanes/MenuBarAppearanceSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/MenuBarAppearanceSettingsPane.swift @@ -13,8 +13,3 @@ struct MenuBarAppearanceSettingsPane: View { .environmentObject(appState.appearanceManager) } } - -#Preview { - MenuBarAppearanceSettingsPane() - .environmentObject(AppState()) -} diff --git a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift index b10382488..18eed68b1 100644 --- a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift @@ -26,18 +26,10 @@ struct MenuBarLayoutSettingsPane: View { Text("Drag to arrange your menu bar items") .font(.title2) - IceGroupBox { - AnnotationView( - alignment: .center, - font: .callout.bold() - ) { - Label { - Text("Tip: you can also arrange menu bar items by Command + dragging them in the menu bar") - } icon: { - Image(systemName: "lightbulb") - } - } - } + CalloutBox( + "Tip: You can also arrange menu bar items by Command + dragging them in the menu bar.", + systemImage: "lightbulb" + ) } @ViewBuilder diff --git a/Ice/Settings/SettingsView.swift b/Ice/Settings/SettingsView.swift index ab2122975..56da2fce8 100644 --- a/Ice/Settings/SettingsView.swift +++ b/Ice/Settings/SettingsView.swift @@ -7,6 +7,7 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var navigationState: AppNavigationState + @Environment(\.appearsActive) var appearsActive @Environment(\.sidebarRowSize) var sidebarRowSize private var sidebarWidth: CGFloat { @@ -20,9 +21,9 @@ struct SettingsView: View { } else { switch sidebarRowSize { case .small: 190 - case .medium: 210 + case .medium: 215 case .large: 230 - @unknown default: 210 + @unknown default: 215 } } } @@ -67,8 +68,8 @@ struct SettingsView: View { } } header: { Text("Ice") - .font(.system(size: 36, weight: .medium)) - .foregroundStyle(.primary) + .font(.system(size: 40, weight: .medium)) + .foregroundStyle(appearsActive ? .primary : .tertiary) .padding(.bottom, 10) } .collapsible(false) diff --git a/Ice/Settings/SettingsWindow.swift b/Ice/Settings/SettingsWindow.swift index f84637a45..7a34661bf 100644 --- a/Ice/Settings/SettingsWindow.swift +++ b/Ice/Settings/SettingsWindow.swift @@ -3,14 +3,21 @@ // Ice // +import Combine import SwiftUI +// MARK: - SettingsWindow + struct SettingsWindow: Scene { @ObservedObject var appState: AppState + @StateObject private var model = SettingsWindowModel() var body: some Scene { IceWindow(id: .settings) { settingsView + .onWindowChange { window in + model.observeWindowToolbar(window) + } .frame(minWidth: 825, minHeight: 500) } .commandsRemoved() @@ -30,3 +37,46 @@ struct SettingsWindow: Scene { } } } + +// MARK: - SettingsWindowModel + +@MainActor +private final class SettingsWindowModel: ObservableObject { + /// Storage for internal observers. + private var cancellables = Set() + + /// Configures observers for the window's toolbar. + func observeWindowToolbar(_ window: NSWindow?) { + for cancellable in cancellables { + cancellable.cancel() + } + cancellables.removeAll() + + guard let window else { + return + } + + if #available(macOS 15.0, *) { + // TODO: Switch to the SwiftUI equivalent once we're targeting macOS 15. + // + // Performing availability checks in @SceneBuilder is annoyingly difficult, + // so we're cheating for now and doing it here. + // + // SwiftUI seems to create a new toolbar each time the window is opened, so + // we're using KVO to make sure the values stay set. + // + // - FOR FUTURE REFERENCE: Add `.windowToolbarLabelStyle(fixed: .iconOnly)` + // to the body of `SettingsWindow` and remove this publisher. + Publishers.CombineLatest3( + window.publisher(for: \.toolbar), + window.publisher(for: \.toolbar?.displayMode), + window.publisher(for: \.toolbar?.allowsDisplayModeCustomization) + ) + .sink { toolbar, _, _ in + toolbar?.displayMode = .iconOnly + toolbar?.allowsDisplayModeCustomization = false + } + .store(in: &cancellables) + } + } +} diff --git a/Ice/UI/HotkeyRecorder/HotkeyRecorder.swift b/Ice/UI/HotkeyRecorder/HotkeyRecorder.swift deleted file mode 100644 index 7d1ca2b55..000000000 --- a/Ice/UI/HotkeyRecorder/HotkeyRecorder.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// HotkeyRecorder.swift -// Ice -// - -import SwiftUI - -struct HotkeyRecorder: View { - @StateObject private var model: HotkeyRecorderModel - - private let label: Label - - init(hotkey: Hotkey, @ViewBuilder label: () -> Label) { - self._model = StateObject(wrappedValue: HotkeyRecorderModel(hotkey: hotkey)) - self.label = label() - } - - var body: some View { - IceLabeledContent { - HStack(spacing: 1) { - leadingSegment - trailingSegment - } - .frame(width: 132, height: 24) - .alignmentGuide(.firstTextBaseline) { dimension in - dimension[VerticalAlignment.center] - } - } label: { - label - .alignmentGuide(.firstTextBaseline) { dimension in - dimension[VerticalAlignment.center] - } - } - .alert( - "Hotkey is reserved by macOS", - isPresented: $model.isPresentingReservedByMacOSError - ) { - Button("OK") { - model.isPresentingReservedByMacOSError = false - } - } - } - - @ViewBuilder - private var leadingSegment: some View { - Button { - model.startRecording() - } label: { - leadingSegmentLabel - } - .buttonStyle( - HotkeyRecorderSegmentButtonStyle( - segment: .leading, - isHighlighted: model.isRecording - ) - ) - } - - @ViewBuilder - private var trailingSegment: some View { - Button { - if model.isRecording { - model.stopRecording() - } else if model.hotkey.isEnabled { - model.hotkey.keyCombination = nil - } else { - model.startRecording() - } - } label: { - trailingSegmentLabel - } - .buttonStyle( - HotkeyRecorderSegmentButtonStyle( - segment: .trailing, - isHighlighted: false - ) - ) - .aspectRatio(1, contentMode: .fit) - } - - @ViewBuilder - private var leadingSegmentLabel: some View { - if model.isRecording { - Text("Type Hotkey") - } else if model.hotkey.isEnabled { - if let keyCombination = model.hotkey.keyCombination { - HStack(spacing: 0) { - Text(keyCombination.modifiers.symbolicValue) - Text(keyCombination.key.stringValue.capitalized) - } - } else { - Text("ERROR") - } - } else { - Text("Record Hotkey") - } - } - - @ViewBuilder - private var trailingSegmentLabel: some View { - let symbolString = if model.isRecording { - "escape" - } else if model.hotkey.isEnabled { - "xmark.circle.fill" - } else { - "record.circle" - } - Image(systemName: symbolString) - .resizable() - .aspectRatio(contentMode: .fill) - .padding(2) - } -} - -private struct HotkeyRecorderSegmentButtonStyle: PrimitiveButtonStyle { - enum Segment { - case leading - case trailing - } - - @State private var frame = CGRect.zero - @State private var isPressed = false - - var segment: Segment - var isHighlighted: Bool - - private var radii: RectangleCornerRadii { - switch segment { - case .leading: - RectangleCornerRadii(topLeading: 5, bottomLeading: 5) - case .trailing: - RectangleCornerRadii(bottomTrailing: 5, topTrailing: 5) - } - } - - func makeBody(configuration: Configuration) -> some View { - UnevenRoundedRectangle(cornerRadii: radii, style: .circular) - .fill(isHighlighted || isPressed ? .tertiary : .quaternary) - .overlay { - configuration.label - .lineLimit(1) - .foregroundStyle(.primary) - .padding(EdgeInsets(top: 3, leading: 8, bottom: 3, trailing: 8)) - } - .simultaneousGesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - isPressed = frame.contains(value.location) - } - .onEnded { value in - isPressed = false - if frame.contains(value.location) { - configuration.trigger() - } - } - ) - .onFrameChange(update: $frame) - } -} diff --git a/Ice/UI/HotkeyRecorder/HotkeyRecorderModel.swift b/Ice/UI/HotkeyRecorder/HotkeyRecorderModel.swift deleted file mode 100644 index 0617f97b0..000000000 --- a/Ice/UI/HotkeyRecorder/HotkeyRecorderModel.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// HotkeyRecorderModel.swift -// Ice -// - -import Combine -import SwiftUI - -@MainActor -final class HotkeyRecorderModel: ObservableObject { - @EnvironmentObject private var appState: AppState - - @Published private(set) var isRecording = false - - @Published var isPresentingReservedByMacOSError = false - - let hotkey: Hotkey - - private lazy var monitor = LocalEventMonitor(mask: .keyDown) { [weak self] event in - guard let self else { - return event - } - handleKeyDown(event: event) - return nil - } - - private var cancellables = Set() - - init(hotkey: Hotkey) { - self.hotkey = hotkey - configureCancellables() - } - - private func configureCancellables() { - var c = Set() - - hotkey.objectWillChange - .sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &c) - - cancellables = c - } - - func startRecording() { - guard !isRecording else { - return - } - hotkey.disable() - monitor.start() - isRecording = true - } - - func stopRecording() { - guard isRecording else { - return - } - monitor.stop() - hotkey.enable() - isRecording = false - } - - private func handleKeyDown(event: NSEvent) { - let keyCombination = KeyCombination(event: event) - guard !keyCombination.modifiers.isEmpty else { - if keyCombination.key == .escape { - stopRecording() - } else { - NSSound.beep() - } - return - } - guard keyCombination.modifiers != .shift else { - NSSound.beep() - return - } - guard !keyCombination.isReservedBySystem else { - isPresentingReservedByMacOSError = true - return - } - hotkey.keyCombination = keyCombination - stopRecording() - } -} diff --git a/Ice/UI/IceUI/IceGroupBox.swift b/Ice/UI/IceUI/IceGroupBox.swift index 7ab0323f0..8bc120bac 100644 --- a/Ice/UI/IceUI/IceGroupBox.swift +++ b/Ice/UI/IceUI/IceGroupBox.swift @@ -157,25 +157,26 @@ struct IceGroupBox: View { var body: some View { VStack(alignment: .leading) { header + .padding(.leading, padding.leading) + VStack { content } .padding(padding) .background { backgroundShape - .fill(.quinary) + .fill(.quinary.opacity(0.67)) .strokeBorder(.quaternary) } .containerShape(backgroundShape) + footer + .padding(.leading, padding.leading) } } } extension EdgeInsets { /// The default padding for an ``IceGroupBox``. - static let iceGroupBoxDefaultPadding: EdgeInsets = { - let padding: CGFloat = if #available(macOS 26.0, *) { 12 } else { 10 } - return EdgeInsets(all: padding) - }() + static let iceGroupBoxDefaultPadding = EdgeInsets(all: 12) } diff --git a/Ice/UI/IceUI/IceSection.swift b/Ice/UI/IceUI/IceSection.swift index fe2392f31..15d0f79ce 100644 --- a/Ice/UI/IceUI/IceSection.swift +++ b/Ice/UI/IceUI/IceSection.swift @@ -148,7 +148,5 @@ private struct IceSectionLayout: _VariadicView_UnaryViewRoot { extension CGFloat { /// The default spacing for an ``IceSection``. - static let iceSectionDefaultSpacing: CGFloat = { - if #available(macOS 26.0, *) { 11 } else { 10 } - }() + static let iceSectionDefaultSpacing: CGFloat = if #available(macOS 26.0, *) { 11 } else { 10 } } diff --git a/Ice/UI/IceUI/IceSlider.swift b/Ice/UI/IceUI/IceSlider.swift index 4d2f911ea..0b92dd151 100644 --- a/Ice/UI/IceUI/IceSlider.swift +++ b/Ice/UI/IceUI/IceSlider.swift @@ -44,16 +44,28 @@ struct IceSlider: Scene { Window(id.titleKey, id: id.rawValue) { content.onWindowChange { window in - guard let window else { - return - } - window.collectionBehavior.insert(.moveToActiveSpace) + window?.collectionBehavior.insert(.moveToActiveSpace) } } } diff --git a/Ice/UI/Views/AnnotationView.swift b/Ice/UI/Views/AnnotationView.swift index 54aaeae26..ec96d5217 100644 --- a/Ice/UI/Views/AnnotationView.swift +++ b/Ice/UI/Views/AnnotationView.swift @@ -25,7 +25,7 @@ struct AnnotationView: /// - content: The content view of the annotation. init( alignment: HorizontalAlignment = .leading, - spacing: CGFloat = 0, + spacing: CGFloat = .annotationDefaultSpacing, font: Font? = .subheadline, foregroundStyle: ForegroundStyle = .secondary, @ViewBuilder parent: () -> Parent, @@ -51,7 +51,7 @@ struct AnnotationView: init( _ titleKey: LocalizedStringKey, alignment: HorizontalAlignment = .leading, - spacing: CGFloat = 0, + spacing: CGFloat = .annotationDefaultSpacing, font: Font? = .subheadline, foregroundStyle: ForegroundStyle = .secondary, @ViewBuilder parent: () -> Parent @@ -78,7 +78,7 @@ struct AnnotationView: /// - content: The content view of the annotation. init( alignment: HorizontalAlignment = .leading, - spacing: CGFloat = 0, + spacing: CGFloat = .annotationDefaultSpacing, font: Font? = .subheadline, foregroundStyle: ForegroundStyle = .secondary, @ViewBuilder content: () -> Content @@ -106,7 +106,7 @@ struct AnnotationView: init( _ titleKey: LocalizedStringKey, alignment: HorizontalAlignment = .leading, - spacing: CGFloat = 0, + spacing: CGFloat = .annotationDefaultSpacing, font: Font? = .subheadline, foregroundStyle: ForegroundStyle = .secondary ) where Parent == EmptyView, Content == Text { @@ -129,6 +129,7 @@ struct AnnotationView: .foregroundStyle(foregroundStyle) } .frame(maxWidth: .infinity, alignment: Alignment(horizontal: alignment, vertical: .center)) + .fixedSize(horizontal: false, vertical: true) } } @@ -143,7 +144,7 @@ extension View { /// - content: A view builder that creates the annotation content. func annotation( alignment: HorizontalAlignment = .leading, - spacing: CGFloat = 0, + spacing: CGFloat = .annotationDefaultSpacing, font: Font? = .subheadline, foregroundStyle: ForegroundStyle = .secondary, @ViewBuilder content: () -> Content @@ -171,7 +172,7 @@ extension View { func annotation( _ titleKey: LocalizedStringKey, alignment: HorizontalAlignment = .leading, - spacing: CGFloat = 0, + spacing: CGFloat = .annotationDefaultSpacing, font: Font? = .subheadline, foregroundStyle: ForegroundStyle = .secondary ) -> some View { @@ -186,3 +187,8 @@ extension View { } } } + +extension CGFloat { + /// The default spacing for an ``IceForm``. + static let annotationDefaultSpacing: CGFloat = 2 +} diff --git a/Ice/UI/Views/HotkeyRecorder.swift b/Ice/UI/Views/HotkeyRecorder.swift new file mode 100644 index 000000000..8e7eb3842 --- /dev/null +++ b/Ice/UI/Views/HotkeyRecorder.swift @@ -0,0 +1,229 @@ +// +// HotkeyRecorder.swift +// Ice +// + +import Combine +import SwiftUI + +// MARK: - HotkeyRecorder + +struct HotkeyRecorder: View { + @StateObject private var model: HotkeyRecorderModel + + private let label: Label + + init(hotkey: Hotkey, @ViewBuilder label: () -> Label) { + self._model = StateObject(wrappedValue: HotkeyRecorderModel(hotkey: hotkey)) + self.label = label() + } + + var body: some View { + IceLabeledContent { + HStack(spacing: 1) { + leadingSegment + trailingSegment + } + .frame(width: 132, height: 24) + } label: { + label + } + .alert( + "Hotkey is reserved by macOS", + isPresented: $model.isPresentingSystemReservedError + ) { + Button("OK") { + model.isPresentingSystemReservedError = false + } + } + } + + @ViewBuilder + private var leadingSegment: some View { + Button { + model.startRecording() + } label: { + leadingSegmentLabel + } + .buttonStyle( + HotkeyRecorderButtonStyle( + segment: .leading, + isHighlighted: model.isRecording + ) + ) + } + + @ViewBuilder + private var trailingSegment: some View { + Button { + if model.isRecording { + model.stopRecording() + } else if model.hotkey.isEnabled { + model.hotkey.keyCombination = nil + } else { + model.startRecording() + } + } label: { + trailingSegmentLabel + } + .buttonStyle( + HotkeyRecorderButtonStyle( + segment: .trailing, + isHighlighted: false + ) + ) + .aspectRatio(1, contentMode: .fit) + } + + @ViewBuilder + private var leadingSegmentLabel: some View { + if model.isRecording { + Text("Type Hotkey") + } else if model.hotkey.isEnabled { + if let keyCombination = model.hotkey.keyCombination { + Text(keyCombination.displayValue) + } else { + Text("ERROR") + } + } else { + Text("Record Hotkey") + } + } + + @ViewBuilder + private var trailingSegmentLabel: some View { + let (name, label, padding, weight) = if model.isRecording { + ("escape", "Cancel", 5.5, Font.Weight.regular) + } else if model.hotkey.isEnabled { + ("xmark", "Clear", 7.5, Font.Weight.medium) + } else { + ("record.circle", "Record", 5.5, Font.Weight.regular) + } + Image(systemName: name) + .resizable() + .aspectRatio(1, contentMode: .fit) + .padding(padding) + .foregroundStyle(.secondary) + .fontWeight(weight) + .accessibilityLabel(label) + } +} + +// MARK: - HotkeyRecorderModel + +@MainActor +private final class HotkeyRecorderModel: ObservableObject { + @EnvironmentObject private var appState: AppState + + @Published private(set) var isRecording = false + + @Published var isPresentingSystemReservedError = false + + let hotkey: Hotkey + + private lazy var monitor = LocalEventMonitor(mask: .keyDown) { [weak self] event in + guard let self else { + return event + } + handleKeyDown(event: event) + return nil + } + + private var cancellables = Set() + + init(hotkey: Hotkey) { + self.hotkey = hotkey + configureCancellables() + } + + private func configureCancellables() { + var c = Set() + + hotkey.objectWillChange + .sink { [weak self] in + self?.objectWillChange.send() + } + .store(in: &c) + + cancellables = c + } + + func startRecording() { + guard !isRecording else { + return + } + hotkey.disable() + monitor.start() + isRecording = true + } + + func stopRecording() { + guard isRecording else { + return + } + monitor.stop() + hotkey.enable() + isRecording = false + } + + private func handleKeyDown(event: NSEvent) { + let keyCombination = KeyCombination(event: event) + guard !keyCombination.modifiers.isEmpty else { + if keyCombination.key == .escape { + stopRecording() + } else { + NSSound.beep() + } + return + } + guard keyCombination.modifiers != .shift else { + NSSound.beep() + return + } + guard !keyCombination.isSystemReserved else { + isPresentingSystemReservedError = true + return + } + hotkey.keyCombination = keyCombination + stopRecording() + } +} + +// MARK: - HotkeyRecorderButtonStyle + +private struct HotkeyRecorderButtonStyle: ButtonStyle { + enum Segment { + case leading + case trailing + } + + var segment: Segment + var isHighlighted: Bool + + private var radii: RectangleCornerRadii { + let r: CGFloat = if #available(macOS 26.0, *) { 6 } else { 5 } + return switch segment { + case .leading: RectangleCornerRadii(topLeading: r, bottomLeading: r) + case .trailing: RectangleCornerRadii(bottomTrailing: r, topTrailing: r) + } + } + + private var borderShape: some InsettableShape { + if #available(macOS 26.0, *) { + UnevenRoundedRectangle(cornerRadii: radii, style: .continuous) + } else { + UnevenRoundedRectangle(cornerRadii: radii, style: .circular) + } + } + + func makeBody(configuration: Configuration) -> some View { + borderShape + .fill(isHighlighted || configuration.isPressed ? .tertiary : .quaternary) + .overlay { + configuration.label + .lineLimit(1) + .foregroundStyle(.primary) + } + .contentShape([.interaction, .focusEffect], borderShape) + } +} diff --git a/Ice/Updates/UpdatesManager.swift b/Ice/Updates/UpdatesManager.swift index ee5f80d72..faa2333fc 100644 --- a/Ice/Updates/UpdatesManager.swift +++ b/Ice/Updates/UpdatesManager.swift @@ -52,14 +52,9 @@ final class UpdatesManager: NSObject, ObservableObject { } } - /// Creates an updates manager with the given app state. - init(appState: AppState) { + /// Performs the initial setup of the manager. + func performSetup(with appState: AppState) { self.appState = appState - super.init() - } - - /// Sets up the manager. - func performSetup() { _ = updaterController configureCancellables() } diff --git a/Ice/UserNotifications/UserNotificationManager.swift b/Ice/UserNotifications/UserNotificationManager.swift index 42890e121..56c4b3d1d 100644 --- a/Ice/UserNotifications/UserNotificationManager.swift +++ b/Ice/UserNotifications/UserNotificationManager.swift @@ -15,14 +15,9 @@ final class UserNotificationManager: NSObject { /// The current notification center. var notificationCenter: UNUserNotificationCenter { .current() } - /// Creates a user notification manager with the given app state. - init(appState: AppState) { + /// Performs the initial setup of the manager. + func performSetup(with appState: AppState) { self.appState = appState - super.init() - } - - /// Sets up the manager. - func performSetup() { notificationCenter.delegate = self } @@ -32,7 +27,7 @@ final class UserNotificationManager: NSObject { do { try await notificationCenter.requestAuthorization(options: [.badge, .alert, .sound]) } catch { - Logger.default.error("Failed to request notification authorization: \(error)") + Logger.general.error("Failed to request notification authorization: \(error)") } } } diff --git a/Ice/Utilities/Defaults.swift b/Ice/Utilities/Defaults.swift index 8349b99e6..d74efc41f 100644 --- a/Ice/Utilities/Defaults.swift +++ b/Ice/Utilities/Defaults.swift @@ -159,13 +159,12 @@ extension Defaults { // MARK: Advanced Settings case hideApplicationMenus = "HideApplicationMenus" - case showSectionDividers = "ShowSectionDividers" case enableAlwaysHiddenSection = "EnableAlwaysHiddenSection" - case canToggleAlwaysHiddenSection = "CanToggleAlwaysHiddenSection" case showOnHoverDelay = "ShowOnHoverDelay" case tempShowInterval = "TempShowInterval" case showAllSectionsOnUserDrag = "ShowAllSectionsOnUserDrag" case showContextMenuOnRightClick = "ShowContextMenuOnRightClick" + case sectionDividerStyle = "SectionDividerStyle" // MARK: Menu Bar Appearance Settings @@ -182,10 +181,9 @@ extension Defaults { case hasMigrated0_10_0 = "hasMigrated0_10_0" case hasMigrated0_10_1 = "hasMigrated0_10_1" case hasMigrated0_11_10 = "hasMigrated0_11_10" + case hasMigrated0_11_13 = "hasMigrated0_11_13" - // MARK: Deprecated - - case sections = "Sections" + // MARK: Deprecated (Menu Bar Appearance) case menuBarHasBorder = "MenuBarHasBorder" case menuBarBorderColor = "MenuBarBorderColor" case menuBarBorderWidth = "MenuBarBorderWidth" @@ -197,5 +195,13 @@ extension Defaults { case menuBarFullShapeInfo = "MenuBarFullShapeInfo" case menuBarSplitShapeInfo = "MenuBarSplitShapeInfo" case menuBarAppearanceConfiguration = "MenuBarAppearanceConfiguration" + + // MARK: Deprecated (Advanced) + case showSectionDividers = "ShowSectionDividers" + case canToggleAlwaysHiddenSection = "CanToggleAlwaysHiddenSection" + + // MARK: Deprecated (Other) + + case sections = "Sections" } } diff --git a/Ice/Utilities/Logging.swift b/Ice/Utilities/Logging.swift index 18d0251a1..4819f0d11 100644 --- a/Ice/Utilities/Logging.swift +++ b/Ice/Utilities/Logging.swift @@ -15,8 +15,11 @@ extension Logger { // MARK: - Shared Loggers extension Logger { - /// The default logger. - static let `default` = Logger(.default) + /// The general purpose logger. + static let general = Logger(category: "General") + + /// The logger for hotkey operations. + static let hotkeys = Logger(category: "Hotkeys") /// The logger for serialization operations. static let serialization = Logger(category: "Serialization") diff --git a/Ice/Utilities/MigrationManager.swift b/Ice/Utilities/MigrationManager.swift index cb689bc4c..f0ad28b2e 100644 --- a/Ice/Utilities/MigrationManager.swift +++ b/Ice/Utilities/MigrationManager.swift @@ -38,6 +38,7 @@ extension MigrationManager { results += [ migrate0_10_1(), migrate0_11_10(), + migrate0_11_13(), ] for result in results { @@ -183,20 +184,19 @@ extension MigrationManager { // MARK: - Migrate 0.10.0 extension MigrationManager { - /// Performs all migrations for the `0.10.0` release, catching any thrown - /// errors and rethrowing them as a combined error. - private func migrate0_10_0() throws { + /// Performs all migrations for the `0.10.0` release. + private func migrate0_10_0() { guard !Defaults.bool(forKey: .hasMigrated0_10_0) else { return } - try performAll(blocks: [ - migrateControlItems0_10_0, - ]) + + migrateControlItems0_10_0() + Defaults.set(true, forKey: .hasMigrated0_10_0) logger.info("Successfully migrated to 0.10.0 settings") } - private func migrateControlItems0_10_0() throws { + private func migrateControlItems0_10_0() { for identifier in ControlItem.Identifier.allCases { StatusItemDefaults.migrate( key: .preferredPosition, @@ -279,7 +279,7 @@ extension MigrationManager { private func migrateAppearanceConfiguration0_11_10() -> MigrationResult { guard let oldData = Defaults.data(forKey: .menuBarAppearanceConfiguration) else { if Defaults.object(forKey: .menuBarAppearanceConfiguration) != nil { - logger.warning("Previous menu bar appearance data is corrupted.") + logger.warning("Previous menu bar appearance data is corrupted") } // This is either the first launch, or the data is malformed. // Either way, not much to do here. @@ -314,6 +314,39 @@ extension MigrationManager { } } +// MARK: - Migrate 0.11.13 + +extension MigrationManager { + /// Performs all migrations for the `0.11.13` release. + private func migrate0_11_13() -> MigrationResult { + guard !Defaults.bool(forKey: .hasMigrated0_11_13) else { + return .success + } + + migrateAppearanceConfiguration0_11_13() + migrateSectionDividers0_11_13() + + Defaults.set(true, forKey: .hasMigrated0_11_13) + logger.info("Successfully migrated to 0.11.13 settings") + + return .success + } + + private func migrateAppearanceConfiguration0_11_13() { + Defaults.removeObject(forKey: .menuBarAppearanceConfiguration) + } + + private func migrateSectionDividers0_11_13() { + let style = if Defaults.bool(forKey: .showSectionDividers) { + SectionDividerStyle.chevron + } else { + SectionDividerStyle.noDivider + } + Defaults.set(style.rawValue, forKey: .sectionDividerStyle) + Defaults.removeObject(forKey: .showSectionDividers) + } +} + // MARK: - Helpers extension MigrationManager { diff --git a/Ice/Utilities/MouseHelpers.swift b/Ice/Utilities/MouseHelpers.swift index 8a261b6ed..ec3b19814 100644 --- a/Ice/Utilities/MouseHelpers.swift +++ b/Ice/Utilities/MouseHelpers.swift @@ -24,7 +24,7 @@ enum MouseCursor { static func hide() { let result = CGDisplayHideCursor(CGMainDisplayID()) if result != .success { - Logger.default.error("CGDisplayHideCursor failed with error \(result.logString, privacy: .public)") + Logger.general.error("CGDisplayHideCursor failed with error \(result.logString, privacy: .public)") } } @@ -32,7 +32,7 @@ enum MouseCursor { static func show() { let result = CGDisplayShowCursor(CGMainDisplayID()) if result != .success { - Logger.default.error("CGDisplayShowCursor failed with error \(result.logString, privacy: .public)") + Logger.general.error("CGDisplayShowCursor failed with error \(result.logString, privacy: .public)") } } @@ -42,7 +42,7 @@ enum MouseCursor { static func warp(to point: CGPoint) { let result = CGWarpMouseCursorPosition(point) if result != .success { - Logger.default.error("CGWarpMouseCursorPosition failed with error \(result.logString, privacy: .public)") + Logger.general.error("CGWarpMouseCursorPosition failed with error \(result.logString, privacy: .public)") } } } diff --git a/Ice/Utilities/RehideStrategy.swift b/Ice/Utilities/RehideStrategy.swift deleted file mode 100644 index 89620acfb..000000000 --- a/Ice/Utilities/RehideStrategy.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// RehideStrategy.swift -// Ice -// - -import SwiftUI - -/// A type that determines how the auto-rehide feature works. -enum RehideStrategy: Int, CaseIterable, Identifiable { - /// Menu bar items are rehidden using a smart algorithm. - case smart = 0 - /// Menu bar items are rehidden after a given time interval. - case timed = 1 - /// Menu bar items are rehidden when the focused app changes. - case focusedApp = 2 - - var id: Int { rawValue } - - /// Localized string key representation. - var localized: LocalizedStringKey { - switch self { - case .smart: "Smart" - case .timed: "Timed" - case .focusedApp: "Focused app" - } - } -} diff --git a/Ice/Swizzling/NSSplitViewItem+swizzledCanCollapse.swift b/Ice/Utilities/Swizzling.swift similarity index 88% rename from Ice/Swizzling/NSSplitViewItem+swizzledCanCollapse.swift rename to Ice/Utilities/Swizzling.swift index b0409a71f..90d2fa557 100644 --- a/Ice/Swizzling/NSSplitViewItem+swizzledCanCollapse.swift +++ b/Ice/Utilities/Swizzling.swift @@ -1,5 +1,5 @@ // -// NSSplitViewItem+swizzledCanCollapse.swift +// Swizzling.swift // Ice // @@ -23,7 +23,7 @@ extension NSSplitViewItem { @objc private var swizzledCanCollapse: Bool { if let window = viewController.view.window, - window.identifier?.rawValue == Constants.settingsWindowID + window.identifier?.rawValue == IceWindowIdentifier.settings.rawValue { return false } From 239f47c2a0f16ae3bc09c40d0dc01f32cbbfd009 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Mon, 30 Jun 2025 18:16:27 -0600 Subject: [PATCH 20/80] Settings reworks --- Ice/Events/EventManager.swift | 12 ++--- .../MenuBarAppearanceEditor.swift | 2 +- Ice/MenuBar/MenuBarManager.swift | 4 +- .../AdvancedSettingsManager.swift | 48 +++++++++---------- .../SettingsPanes/AdvancedSettingsPane.swift | 32 ++++++------- .../SettingsPanes/GeneralSettingsPane.swift | 7 +-- .../MenuBarLayoutSettingsPane.swift | 2 +- Ice/UI/IceUI/IceGroupBox.swift | 2 - Ice/Utilities/Defaults.swift | 8 ++-- 9 files changed, 56 insertions(+), 61 deletions(-) diff --git a/Ice/Events/EventManager.swift b/Ice/Events/EventManager.swift index ac4c9c110..6b4e9ab60 100644 --- a/Ice/Events/EventManager.swift +++ b/Ice/Events/EventManager.swift @@ -32,7 +32,7 @@ final class EventManager: ObservableObject { handleShowOnClick(appState: appState, screen: screen) handleSmartRehide(with: event, appState: appState, screen: screen) case .rightMouseDown: - handleShowRightClickMenu(appState: appState, screen: screen) + handleShowSecondaryContextMenu(appState: appState, screen: screen) default: return event } @@ -165,7 +165,7 @@ extension EventManager { try await Task.sleep(for: .milliseconds(50)) if NSEvent.modifierFlags == .control { - handleShowRightClickMenu(appState: appState, screen: screen) + handleShowSecondaryContextMenu(appState: appState, screen: screen) return } @@ -266,17 +266,17 @@ extension EventManager { } } - // MARK: Handle Show Right Click Menu + // MARK: Handle Show Secondary Context Menu - private func handleShowRightClickMenu(appState: AppState, screen: NSScreen) { + private func handleShowSecondaryContextMenu(appState: AppState, screen: NSScreen) { guard - appState.settingsManager.advancedSettingsManager.showContextMenuOnRightClick, + appState.settingsManager.advancedSettingsManager.enableSecondaryContextMenu, isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen), let mouseLocation = MouseCursor.locationAppKit else { return } - appState.menuBarManager.showRightClickMenu(at: mouseLocation) + appState.menuBarManager.showSecondaryContextMenu(at: mouseLocation) } // MARK: Handle Prevent Show On Hover diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift index 44b1736e7..8ce789e49 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift @@ -61,7 +61,7 @@ struct MenuBarAppearanceEditor: View { IceForm(padding: mainFormPadding) { if case .settings = location, - appState.settingsManager.advancedSettingsManager.showContextMenuOnRightClick + appState.settingsManager.advancedSettingsManager.enableSecondaryContextMenu { CalloutBox( "Tip: You can also edit these settings by right-clicking in an empty area of the menu bar.", diff --git a/Ice/MenuBar/MenuBarManager.swift b/Ice/MenuBar/MenuBarManager.swift index 1219ba345..654db11ab 100644 --- a/Ice/MenuBar/MenuBarManager.swift +++ b/Ice/MenuBar/MenuBarManager.swift @@ -344,8 +344,8 @@ final class MenuBarManager: ObservableObject { return applicationMenuFrame } - /// Shows the right-click menu. - func showRightClickMenu(at point: CGPoint) { + /// Shows the secondary context menu. + func showSecondaryContextMenu(at point: CGPoint) { let menu = NSMenu(title: "Ice") let editItem = NSMenuItem( diff --git a/Ice/Settings/SettingsManagers/AdvancedSettingsManager.swift b/Ice/Settings/SettingsManagers/AdvancedSettingsManager.swift index b9a624cf4..c920063d1 100644 --- a/Ice/Settings/SettingsManagers/AdvancedSettingsManager.swift +++ b/Ice/Settings/SettingsManagers/AdvancedSettingsManager.swift @@ -10,14 +10,6 @@ import SwiftUI @MainActor final class AdvancedSettingsManager: ObservableObject { - /// A Boolean value that indicates whether the application menus - /// should be hidden if needed to show all menu bar items. - @Published var hideApplicationMenus = true - - /// A Boolean value that indicates whether to show a context menu - /// when the user right-clicks the menu bar. - @Published var showContextMenuOnRightClick = true - /// A Boolean value that indicates whether the always-hidden section /// is enabled. @Published var enableAlwaysHiddenSection = false @@ -29,6 +21,14 @@ final class AdvancedSettingsManager: ObservableObject { /// The display style for section divider control items. @Published var sectionDividerStyle: SectionDividerStyle = .noDivider + /// A Boolean value that indicates whether the application menus + /// should be hidden if needed to show all menu bar items. + @Published var hideApplicationMenus = true + + /// A Boolean value that indicates whether to show a context menu + /// when the user right-clicks the menu bar. + @Published var enableSecondaryContextMenu = true + /// The delay before showing on hover. @Published var showOnHoverDelay: TimeInterval = 0.2 @@ -48,10 +48,10 @@ final class AdvancedSettingsManager: ObservableObject { } private func loadInitialState() { - Defaults.ifPresent(key: .hideApplicationMenus, assign: &hideApplicationMenus) - Defaults.ifPresent(key: .showContextMenuOnRightClick, assign: &showContextMenuOnRightClick) Defaults.ifPresent(key: .enableAlwaysHiddenSection, assign: &enableAlwaysHiddenSection) Defaults.ifPresent(key: .showAllSectionsOnUserDrag, assign: &showAllSectionsOnUserDrag) + Defaults.ifPresent(key: .hideApplicationMenus, assign: &hideApplicationMenus) + Defaults.ifPresent(key: .enableSecondaryContextMenu, assign: &enableSecondaryContextMenu) Defaults.ifPresent(key: .showOnHoverDelay, assign: &showOnHoverDelay) Defaults.ifPresent(key: .tempShowInterval, assign: &tempShowInterval) @@ -65,20 +65,6 @@ final class AdvancedSettingsManager: ObservableObject { private func configureCancellables() { var c = Set() - $hideApplicationMenus - .receive(on: DispatchQueue.main) - .sink { shouldHide in - Defaults.set(shouldHide, forKey: .hideApplicationMenus) - } - .store(in: &c) - - $showContextMenuOnRightClick - .receive(on: DispatchQueue.main) - .sink { showAll in - Defaults.set(showAll, forKey: .showContextMenuOnRightClick) - } - .store(in: &c) - $enableAlwaysHiddenSection .receive(on: DispatchQueue.main) .sink { enable in @@ -100,6 +86,20 @@ final class AdvancedSettingsManager: ObservableObject { } .store(in: &c) + $hideApplicationMenus + .receive(on: DispatchQueue.main) + .sink { shouldHide in + Defaults.set(shouldHide, forKey: .hideApplicationMenus) + } + .store(in: &c) + + $enableSecondaryContextMenu + .receive(on: DispatchQueue.main) + .sink { enable in + Defaults.set(enable, forKey: .enableSecondaryContextMenu) + } + .store(in: &c) + $showOnHoverDelay .receive(on: DispatchQueue.main) .sink { delay in diff --git a/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift b/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift index 1c7554570..8f976bcd2 100644 --- a/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift @@ -35,9 +35,9 @@ struct AdvancedSettingsPane: View { } IceSection("Other") { hideApplicationMenus - showContextMenuOnRightClick - showOnHoverDelaySlider - tempShowIntervalSlider + enableSecondaryContextMenu + showOnHoverDelay + tempShowInterval } IceSection("Permissions") { allPermissions @@ -48,15 +48,15 @@ struct AdvancedSettingsPane: View { @ViewBuilder private var hideApplicationMenus: some View { Toggle( - "Hide application menus when showing menu bar items", + "Hide app menus when showing menu bar items", isOn: manager.bindings.hideApplicationMenus ) .annotation { Text( """ Make more room in the menu bar by hiding the current app menus if \ - needed. macOS requires Ice to become visible in the Dock while this \ - setting is in effect. + needed. macOS requires Ice to be visible in the Dock while this setting \ + is in effect. """ ) .padding(.trailing, 75) @@ -64,17 +64,17 @@ struct AdvancedSettingsPane: View { } @ViewBuilder - private var showContextMenuOnRightClick: some View { + private var enableSecondaryContextMenu: some View { Toggle( "Enable secondary context menu", - isOn: manager.bindings.showContextMenuOnRightClick + isOn: manager.bindings.enableSecondaryContextMenu ) .annotation { Text( """ - Right-clicking in an empty area of the menu bar displays a minimal \ - version of Ice's menu. Disable this setting if you're experiencing \ - conflicts with other apps. + Right-click in an empty area of the menu bar to display a minimal \ + version of Ice's menu. Disable this if you experience conflicts with \ + other apps. """ ) .padding(.trailing, 75) @@ -84,7 +84,7 @@ struct AdvancedSettingsPane: View { @ViewBuilder private var enableAlwaysHiddenSection: some View { Toggle( - "Enable always-hidden section", + "Enable the \(MenuBarSection.Name.alwaysHidden.displayString) Section", isOn: manager.bindings.enableAlwaysHiddenSection ) } @@ -92,7 +92,7 @@ struct AdvancedSettingsPane: View { @ViewBuilder private var showAllSectionsOnUserDrag: some View { Toggle( - "Show all sections when Command + dragging menu bar items", + "Show all sections when ⌘ Command + dragging menu bar items", isOn: manager.bindings.showAllSectionsOnUserDrag ) } @@ -107,7 +107,7 @@ struct AdvancedSettingsPane: View { } @ViewBuilder - private var showOnHoverDelaySlider: some View { + private var showOnHoverDelay: some View { IceLabeledContent { IceSlider( formattedToSeconds(manager.showOnHoverDelay), @@ -126,12 +126,12 @@ struct AdvancedSettingsPane: View { } @ViewBuilder - private var tempShowIntervalSlider: some View { + private var tempShowInterval: some View { IceLabeledContent { IceSlider( formattedToSeconds(manager.tempShowInterval), value: manager.bindings.tempShowInterval, - in: 0...30, + in: 0...60, step: 1 ) } label: { diff --git a/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift b/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift index 3d41710c6..61da0b3e0 100644 --- a/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift @@ -112,11 +112,8 @@ struct GeneralSettingsPane: View { @ViewBuilder private var iceIconOptions: some View { Toggle("Show Ice icon", isOn: manager.bindings.showIceIcon) - .annotation { - if !manager.showIceIcon, appState.settingsManager.advancedSettingsManager.showContextMenuOnRightClick { - Text("You can still access Ice's settings by right-clicking in an empty area of the menu bar.") - } - } + .annotation("Click to show hidden menu bar items. Right-click to access Ice's settings.") + if manager.showIceIcon { IceMenu("Ice icon") { Picker("Ice icon", selection: manager.bindings.iceIcon) { diff --git a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift index 18eed68b1..65f60e15d 100644 --- a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift @@ -27,7 +27,7 @@ struct MenuBarLayoutSettingsPane: View { .font(.title2) CalloutBox( - "Tip: You can also arrange menu bar items by Command + dragging them in the menu bar.", + "Tip: You can also arrange menu bar items by ⌘ Command + dragging them in the menu bar.", systemImage: "lightbulb" ) } diff --git a/Ice/UI/IceUI/IceGroupBox.swift b/Ice/UI/IceUI/IceGroupBox.swift index 8bc120bac..c33177f39 100644 --- a/Ice/UI/IceUI/IceGroupBox.swift +++ b/Ice/UI/IceUI/IceGroupBox.swift @@ -157,7 +157,6 @@ struct IceGroupBox: View { var body: some View { VStack(alignment: .leading) { header - .padding(.leading, padding.leading) VStack { content @@ -171,7 +170,6 @@ struct IceGroupBox: View { .containerShape(backgroundShape) footer - .padding(.leading, padding.leading) } } } diff --git a/Ice/Utilities/Defaults.swift b/Ice/Utilities/Defaults.swift index d74efc41f..c82c13507 100644 --- a/Ice/Utilities/Defaults.swift +++ b/Ice/Utilities/Defaults.swift @@ -158,13 +158,13 @@ extension Defaults { // MARK: Advanced Settings - case hideApplicationMenus = "HideApplicationMenus" case enableAlwaysHiddenSection = "EnableAlwaysHiddenSection" - case showOnHoverDelay = "ShowOnHoverDelay" - case tempShowInterval = "TempShowInterval" case showAllSectionsOnUserDrag = "ShowAllSectionsOnUserDrag" - case showContextMenuOnRightClick = "ShowContextMenuOnRightClick" case sectionDividerStyle = "SectionDividerStyle" + case hideApplicationMenus = "HideApplicationMenus" + case enableSecondaryContextMenu = "EnableSecondaryContextMenu" + case showOnHoverDelay = "ShowOnHoverDelay" + case tempShowInterval = "TempShowInterval" // MARK: Menu Bar Appearance Settings From 75cfc1c8112d578ea89708858e6b672c12013893 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Mon, 30 Jun 2025 23:12:23 -0600 Subject: [PATCH 21/80] Don't create control items until app setup We also store the status item and layout constraint in a separate storage class. Not sure I like this, but the idea is to convey the tightly coupled relationship between the constraint and status item, and to ensure that they are initialized (and deinitialized) at the same time. --- Ice/MenuBar/ControlItem/ControlItem.swift | 144 +++++++++++++--------- 1 file changed, 86 insertions(+), 58 deletions(-) diff --git a/Ice/MenuBar/ControlItem/ControlItem.swift b/Ice/MenuBar/ControlItem/ControlItem.swift index fd81d4fd7..10abb1e0e 100644 --- a/Ice/MenuBar/ControlItem/ControlItem.swift +++ b/Ice/MenuBar/ControlItem/ControlItem.swift @@ -36,6 +36,79 @@ final class ControlItem { static let expanded: CGFloat = 10_000 } + /// Storage for a control item's underlying status item. + private final class StatusItemStorage { + let statusItem: NSStatusItem + let constraint: NSLayoutConstraint? + + /// Creates a new storage instance. + @MainActor + init(controlItem: ControlItem) { + let autosaveName = controlItem.identifier.rawValue + + if StatusItemDefaults[.preferredPosition, autosaveName] == nil { + // Ice icon and hidden control item should be added before + // existing items in the status bar. + switch controlItem.identifier { + case .iceIcon: + StatusItemDefaults[.preferredPosition, autosaveName] = 0 + case .hidden: + StatusItemDefaults[.preferredPosition, autosaveName] = 1 + case .alwaysHidden: + break + } + } + + if StatusItemDefaults[.visible, autosaveName] == nil { + // The status item should be visible by default. We change + // this after finishing setup, if needed. + StatusItemDefaults[.visible, autosaveName] = true + } + + self.statusItem = NSStatusBar.system.statusItem(withLength: 0) + self.statusItem.autosaveName = autosaveName + + if let button = statusItem.button { + // This could break in a new macOS release, but we need this constraint in order to be + // able to hide the control item when the `ShowSectionDividers` setting is disabled. A + // previous implementation used the status item's `isVisible` property, which was more + // robust, but would completely remove the control item. With the current set of + // features, we need to be able to accurately retrieve the items for each section, so + // we need the control item to always be present to act as a delimiter. The new solution + // is to remove the constraint that prevents status items from having a length of zero, + // then resize the content view. FIXME: Find a replacement for this. + if + let constraints = button.window?.contentView?.constraintsAffectingLayout(for: .horizontal), + let constraint = constraints.first(where: Predicates.controlItemConstraint(button: button)) + { + assert(constraints.filter(Predicates.controlItemConstraint(button: button)).count == 1) + self.constraint = constraint + } else { + self.constraint = nil + } + + button.target = controlItem + button.action = #selector(controlItem.performAction) + } else { + self.constraint = nil + } + } + + deinit { + removeStatusItem() + } + + /// Removes the status item from the status bar. + private func removeStatusItem() { + // Removing the status item has the unwanted side effect of + // deleting the preferred position. Cache and restore it. + let autosaveName = statusItem.autosaveName as String + let cached = StatusItemDefaults[.preferredPosition, autosaveName] + NSStatusBar.system.removeStatusItem(statusItem) + StatusItemDefaults[.preferredPosition, autosaveName] = cached + } + } + /// The control item's hiding state (`@Published`). @Published var state = HidingState.hideItems @@ -54,17 +127,24 @@ final class ControlItem { /// The control item's identifier. let identifier: Identifier + /// Storage for the control item's underlying status item. + private lazy var storage = StatusItemStorage(controlItem: self) + /// The shared app state. private weak var appState: AppState? + /// Storage for internal observers. + private var cancellables = Set() + /// The control item's underlying status item. - private let statusItem: NSStatusItem + private var statusItem: NSStatusItem { + storage.statusItem + } /// A horizontal constraint for the control item's content view. - private let constraint: NSLayoutConstraint? - - /// Storage for internal observers. - private var cancellables = Set() + private var constraint: NSLayoutConstraint? { + storage.constraint + } /// A Boolean value that indicates whether the control item serves as /// a divider between sections. @@ -89,59 +169,7 @@ final class ControlItem { /// Creates a control item with the given identifier. init(identifier: Identifier) { - let autosaveName = identifier.rawValue - - // If the status item doesn't have a preferred position, set it - // according to the identifier. - if StatusItemDefaults[.preferredPosition, autosaveName] == nil { - switch identifier { - case .iceIcon: - StatusItemDefaults[.preferredPosition, autosaveName] = 0 - case .hidden: - StatusItemDefaults[.preferredPosition, autosaveName] = 1 - case .alwaysHidden: - break - } - } - - self.statusItem = NSStatusBar.system.statusItem(withLength: 0) - self.statusItem.autosaveName = autosaveName self.identifier = identifier - - if let button = statusItem.button { - // This could break in a new macOS release, but we need this constraint in order to be - // able to hide the control item when the `ShowSectionDividers` setting is disabled. A - // previous implementation used the status item's `isVisible` property, which was more - // robust, but would completely remove the control item. With the current set of - // features, we need to be able to accurately retrieve the items for each section, so - // we need the control item to always be present to act as a delimiter. The new solution - // is to remove the constraint that prevents status items from having a length of zero, - // then resize the content view. FIXME: Find a replacement for this. - if - let constraints = button.window?.contentView?.constraintsAffectingLayout(for: .horizontal), - let constraint = constraints.first(where: Predicates.controlItemConstraint(button: button)) - { - assert(constraints.filter(Predicates.controlItemConstraint(button: button)).count == 1) - self.constraint = constraint - } else { - self.constraint = nil - } - - button.target = self - button.action = #selector(performAction) - } else { - self.constraint = nil - } - } - - /// Removes the status item without clearing its stored position. - deinit { - // Removing the status item has the unwanted side effect of deleting - // the preferredPosition. Cache and restore it. - let autosaveName = statusItem.autosaveName as String - let cached = StatusItemDefaults[.preferredPosition, autosaveName] - NSStatusBar.system.removeStatusItem(statusItem) - StatusItemDefaults[.preferredPosition, autosaveName] = cached } /// Performs the initial setup of the control item. @@ -455,7 +483,7 @@ final class ControlItem { return } // Setting `statusItem.isVisible` to `false` has the unwanted side - // effect of deleting the preferredPosition. Cache and restore it. + // effect of deleting the preferred position. Cache and restore it. let autosaveName = statusItem.autosaveName as String let cached = StatusItemDefaults[.preferredPosition, autosaveName] statusItem.isVisible = false From 30a072e792f56cb484202420c8282ac721b9f068 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Wed, 2 Jul 2025 18:56:48 -0600 Subject: [PATCH 22/80] Permissions interface changes --- Ice/Main/AppState.swift | 1 + Ice/Permissions/Permission.swift | 2 +- Ice/Permissions/PermissionsView.swift | 29 +++++++++++++------------ Ice/Permissions/PermissionsWindow.swift | 4 +++- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/Ice/Main/AppState.swift b/Ice/Main/AppState.swift index c074899f4..f7c673477 100644 --- a/Ice/Main/AppState.swift +++ b/Ice/Main/AppState.swift @@ -85,6 +85,7 @@ final class AppState: ObservableObject { // Delay to prevent conflicts with the app delegate. try await Task.sleep(for: .milliseconds(100)) activate(withPolicy: .regular) + dismissWindow(.settings) // Shouldn't be open anyway. openWindow(.permissions) } } diff --git a/Ice/Permissions/Permission.swift b/Ice/Permissions/Permission.swift index dd82abd18..62d2b7813 100644 --- a/Ice/Permissions/Permission.swift +++ b/Ice/Permissions/Permission.swift @@ -142,7 +142,7 @@ final class ScreenRecordingPermission: Permission { super.init( title: "Screen Recording", details: [ - "Edit the menu bar's appearance.", + "Change the menu bar's appearance.", "Display images of individual menu bar items.", ], isRequired: false, diff --git a/Ice/Permissions/PermissionsView.swift b/Ice/Permissions/PermissionsView.swift index 6f04b2209..225d4a788 100644 --- a/Ice/Permissions/PermissionsView.swift +++ b/Ice/Permissions/PermissionsView.swift @@ -33,13 +33,13 @@ struct PermissionsView: View { headerView .padding(.vertical) - explanationView - permissionsGroupStack + permissionsStack footerView .padding(.vertical) } .padding(.horizontal) + .frame(width: 550) .fixedSize() } @@ -47,35 +47,36 @@ struct PermissionsView: View { private var headerView: some View { Label { Text("Permissions") - .font(.system(size: 36)) + .font(.system(size: 40, weight: .medium)) } icon: { if let nsImage = NSImage(named: NSImage.applicationIconName) { Image(nsImage: nsImage) .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 75, height: 75) + .frame(width: 85, height: 85) } } } @ViewBuilder - private var explanationView: some View { + private var explanationBox: some View { IceSection { VStack { - Text("Ice needs permission to manage the menu bar.") + Text("Ice needs your permission to manage the menu bar.") + .fontWeight(.medium) Text("Absolutely no personal information is collected or stored.") .bold() - .foregroundStyle(.red) + .foregroundStyle(Color(red: 0.5, green: 0.75, blue: 1)) } .padding() } .font(.title3) - .padding(.bottom, 10) } @ViewBuilder - private var permissionsGroupStack: some View { - VStack(spacing: 7.5) { + private var permissionsStack: some View { + VStack { + explanationBox ForEach(manager.allPermissions) { permission in permissionBox(permission) } @@ -128,12 +129,12 @@ struct PermissionsView: View { @ViewBuilder private func permissionBox(_ permission: Permission) -> some View { IceSection { - VStack(spacing: 10) { + VStack(spacing: 12) { Text(permission.title) - .font(.title) + .font(.title.weight(.medium)) .underline() - VStack(spacing: 0) { + VStack(spacing: 2) { Text("Ice needs this to:") .font(.title3) .bold() @@ -142,7 +143,7 @@ struct PermissionsView: View { ForEach(permission.details, id: \.self) { detail in HStack { Text("•").bold() - Text(detail) + Text(detail).fontWeight(.medium) } } } diff --git a/Ice/Permissions/PermissionsWindow.swift b/Ice/Permissions/PermissionsWindow.swift index f7512c1d4..5cec890c3 100644 --- a/Ice/Permissions/PermissionsWindow.swift +++ b/Ice/Permissions/PermissionsWindow.swift @@ -15,7 +15,9 @@ struct PermissionsWindow: Scene { guard let window else { return } - window.styleMask.remove([.closable, .miniaturizable]) + window.standardWindowButton(.closeButton)?.isHidden = true + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true if let contentView = window.contentView { with(contentView.safeAreaInsets) { insets in insets.bottom = -insets.bottom From 407e894b0481179c54c45c54d0e3c396c1dded2e Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Wed, 2 Jul 2025 18:58:33 -0600 Subject: [PATCH 23/80] Change `IceWindow` presentation behavior --- Ice/UI/IceUI/IceWindow.swift | 46 +++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/Ice/UI/IceUI/IceWindow.swift b/Ice/UI/IceUI/IceWindow.swift index 761ce2e83..b19c56a3c 100644 --- a/Ice/UI/IceUI/IceWindow.swift +++ b/Ice/UI/IceUI/IceWindow.swift @@ -29,20 +29,50 @@ struct IceWindow: Scene { } var body: some Scene { - MenuBarExtra("", isInserted: .constant(false)) { }.once { - initializeWindow() + windowScene.once { + // SwiftUI waits to create the underlying NSWindow until the scene + // is first presented. We may need a valid window reference before + // that point, so we open the window and immediately dismiss it. + // + // - Note: Both actions are called during the same run loop cycle, + // so the window isn't actually opened. + openWindow(id: id) + dismissWindow(id: id) } + } + + @ViewBuilder + private var windowContentView: some View { + content.onWindowChange { window in + window?.collectionBehavior.insert(.moveToActiveSpace) + } + } + private var windowScene: some Scene { + if #available(macOS 15.0, *) { + return windowSceneModern + } else { + return windowSceneLegacy + } + } + + @available(macOS 15.0, *) + private var windowSceneModern: some Scene { Window(id.titleKey, id: id.rawValue) { - content.onWindowChange { window in - window?.collectionBehavior.insert(.moveToActiveSpace) - } + windowContentView } + .defaultLaunchBehavior(.suppressed) } - private func initializeWindow() { - openWindow(id: id) - dismissWindow(id: id) + private var windowSceneLegacy: some Scene { + Window(id.titleKey, id: id.rawValue) { + windowContentView.once { + // On launch, SwiftUI tries to show the first scene provided + // to the app. Override this behavior and dismiss the window + // the first time it is shown. + dismissWindow(id: id) + } + } } } From e36a6a9c36f1913b1ead297b1b3b1922f9af5fa8 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Wed, 2 Jul 2025 19:07:30 -0600 Subject: [PATCH 24/80] Refactoring --- Ice/Main/AppDelegate.swift | 6 +-- Ice/MenuBar/IceBar/IceBar.swift | 8 ++-- .../LayoutBar/LayoutBarScrollView.swift | 1 - Ice/UI/Shapes/AnyInsettableShape.swift | 26 ------------- Ice/UI/ViewModifiers/BottomBar.swift | 22 ----------- Ice/UI/ViewModifiers/OnWindowChange.swift | 37 ++++++++----------- 6 files changed, 24 insertions(+), 76 deletions(-) delete mode 100644 Ice/UI/Shapes/AnyInsettableShape.swift delete mode 100644 Ice/UI/ViewModifiers/BottomBar.swift diff --git a/Ice/Main/AppDelegate.swift b/Ice/Main/AppDelegate.swift index 40c6a1d09..8dab5d72d 100644 --- a/Ice/Main/AppDelegate.swift +++ b/Ice/Main/AppDelegate.swift @@ -74,9 +74,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Opens the settings window and activates the app. @objc func openSettingsWindow() { // Small delay makes this more reliable. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.appState.activate(withPolicy: .regular) - self.appState.openWindow(.settings) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [appState] in + appState.activate(withPolicy: .regular) + appState.openWindow(.settings) } } } diff --git a/Ice/MenuBar/IceBar/IceBar.swift b/Ice/MenuBar/IceBar/IceBar.swift index df5c90f64..f3b9f0379 100644 --- a/Ice/MenuBar/IceBar/IceBar.swift +++ b/Ice/MenuBar/IceBar/IceBar.swift @@ -292,11 +292,13 @@ private struct IceBarContentView: View { return menuBarHeight } - private var clipShape: AnyInsettableShape { + private var clipShape: some InsettableShape { if configuration.hasRoundedShape { - AnyInsettableShape(Capsule()) + RoundedRectangle(cornerRadius: frame.height / 2, style: .circular) + } else if #available(macOS 26.0, *) { + RoundedRectangle(cornerRadius: frame.height / 4, style: .continuous) } else { - AnyInsettableShape(RoundedRectangle(cornerRadius: frame.height / 5, style: .continuous)) + RoundedRectangle(cornerRadius: frame.height / 5, style: .continuous) } } diff --git a/Ice/MenuBar/LayoutBar/LayoutBarScrollView.swift b/Ice/MenuBar/LayoutBar/LayoutBarScrollView.swift index 6d5e0e6d8..1e8b53489 100644 --- a/Ice/MenuBar/LayoutBar/LayoutBarScrollView.swift +++ b/Ice/MenuBar/LayoutBar/LayoutBarScrollView.swift @@ -41,7 +41,6 @@ final class LayoutBarScrollView: NSScrollView { self.autohidesScrollers = true self.verticalScrollElasticity = .none - self.horizontalScrollElasticity = .none self.drawsBackground = false diff --git a/Ice/UI/Shapes/AnyInsettableShape.swift b/Ice/UI/Shapes/AnyInsettableShape.swift deleted file mode 100644 index 970fe3e16..000000000 --- a/Ice/UI/Shapes/AnyInsettableShape.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// AnyInsettableShape.swift -// Ice -// - -import SwiftUI - -/// A type-erased insettable shape. -struct AnyInsettableShape: InsettableShape { - typealias InsetShape = AnyInsettableShape - - private let base: any InsettableShape - - /// Creates a type-erased insettable shape. - init(_ shape: any InsettableShape) { - self.base = shape - } - - func path(in rect: CGRect) -> Path { - base.path(in: rect) - } - - func inset(by amount: CGFloat) -> AnyInsettableShape { - AnyInsettableShape(base.inset(by: amount)) - } -} diff --git a/Ice/UI/ViewModifiers/BottomBar.swift b/Ice/UI/ViewModifiers/BottomBar.swift deleted file mode 100644 index c742cb83c..000000000 --- a/Ice/UI/ViewModifiers/BottomBar.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// BottomBar.swift -// Ice -// - -import SwiftUI - -extension View { - /// Adds the given view as a bottom bar to the current view. - /// - /// - Parameter content: A view to be added as a bottom bar to the current view. - func bottomBar(@ViewBuilder content: () -> Content) -> some View { - safeAreaInset(edge: .bottom) { - content() - .background { - Rectangle() - .fill(.quinary.shadow(.inner(radius: 2))) - .shadow(radius: 2) - } - } - } -} diff --git a/Ice/UI/ViewModifiers/OnWindowChange.swift b/Ice/UI/ViewModifiers/OnWindowChange.swift index 36fe1c29b..3ee3336cd 100644 --- a/Ice/UI/ViewModifiers/OnWindowChange.swift +++ b/Ice/UI/ViewModifiers/OnWindowChange.swift @@ -5,35 +5,30 @@ import SwiftUI -private nonisolated struct WindowReaderView: NSViewRepresentable { - final class Represented: NSView { - let action: (NSWindow?) -> Void - - init(action: @escaping (NSWindow?) -> Void) { - self.action = action - super.init(frame: .zero) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } +private struct WindowReaderView: NSViewRepresentable { + private final class Represented: NSView { + var action: ((NSWindow?) -> Void)? override func viewDidMoveToWindow() { super.viewDidMoveToWindow() - Task { - action(window) + if let action { + // Wrap the action in a Task to prevent SwiftUI update conflicts. + Task { + action(window) + } } } } - let action: (NSWindow?) -> Void + var action: (NSWindow?) -> Void - func makeNSView(context: Context) -> Represented { - return Represented(action: action) + func makeNSView(context: Context) -> NSView { + let view = Represented() + view.action = action + return view } - func updateNSView(_ nsView: Represented, context: Context) { } + func updateNSView(_: NSView, context: Context) { } } extension View { @@ -42,7 +37,7 @@ extension View { /// - Parameter action: The action to perform when the view's window /// changes. The closure passes the new window as a parameter. The /// new window can be `nil`. - nonisolated func onWindowChange(perform action: @escaping (_ window: NSWindow?) -> Void) -> some View { + func onWindowChange(perform action: @escaping (_ window: NSWindow?) -> Void) -> some View { background { WindowReaderView(action: action) } @@ -52,7 +47,7 @@ extension View { /// /// - Parameter binding: The binding to update when the view's window /// changes. The new window can be `nil`. - nonisolated func onWindowChange(update binding: Binding) -> some View { + func onWindowChange(update binding: Binding) -> some View { onWindowChange { window in binding.wrappedValue = window } From cf15b07db822d3771369d6ff62c2c1c67ba0a57c Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Thu, 3 Jul 2025 06:38:42 -0600 Subject: [PATCH 25/80] Explicitly handle reopen Relying on the default behavior seemed to work fine, but is now broken in the macOS 26 Developer Beta. Probably better to handle it explicitly, even if it is just a beta bug. --- Ice/Main/AppDelegate.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Ice/Main/AppDelegate.swift b/Ice/Main/AppDelegate.swift index 8dab5d72d..db0b2d108 100644 --- a/Ice/Main/AppDelegate.swift +++ b/Ice/Main/AppDelegate.swift @@ -53,6 +53,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { + logger.debug("Handling reopen") + openSettingsWindow() + return true + } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { if sender.isActive, From 09b4888489a17edc5ccea34c37056f57637ad68f Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 4 Jul 2025 06:56:52 -0600 Subject: [PATCH 26/80] Update `Hotkey` implementation --- Ice/Hotkeys/Hotkey.swift | 22 ++++++++-------------- Ice/Hotkeys/HotkeyRegistry.swift | 1 + 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/Ice/Hotkeys/Hotkey.swift b/Ice/Hotkeys/Hotkey.swift index 69dc4e55e..87032091a 100644 --- a/Ice/Hotkeys/Hotkey.swift +++ b/Ice/Hotkeys/Hotkey.swift @@ -10,9 +10,14 @@ import OSLog /// A combination of a key and modifiers that can be used to /// trigger actions on system-wide key-up or key-down events. +@MainActor final class Hotkey: ObservableObject { /// The hotkey's key combination. - @Published var keyCombination: KeyCombination? + @Published var keyCombination: KeyCombination? { + didSet { + enable() + } + } /// The hotkey's action. let action: HotkeyAction @@ -23,9 +28,6 @@ final class Hotkey: ObservableObject { /// Manages the lifetime of the hotkey observation. private var listener: Listener? - /// Internal observer storage. - private var cancellable: AnyCancellable? - /// A Boolean value that indicates whether the hotkey is enabled. var isEnabled: Bool { listener != nil } @@ -33,29 +35,21 @@ final class Hotkey: ObservableObject { init(keyCombination: KeyCombination?, action: HotkeyAction) { self.keyCombination = keyCombination self.action = action - self.cancellable = $keyCombination.sink { [weak self] _ in - Task { - await self?.enable() - } - } } /// Performs the initial setup of the hotkey. - @MainActor func performSetup(with appState: AppState) { self.appState = appState enable() } /// Enables the hotkey. - @MainActor func enable() { disable() listener = Listener(hotkey: self, eventKind: .keyDown) } /// Disables the hotkey. - @MainActor func disable() { listener?.invalidate() listener = nil @@ -115,7 +109,7 @@ extension Hotkey { } // MARK: Hotkey: Equatable -extension Hotkey: Equatable { +extension Hotkey: @MainActor Equatable { static func == (lhs: Hotkey, rhs: Hotkey) -> Bool { lhs.keyCombination == rhs.keyCombination && lhs.action == rhs.action @@ -123,7 +117,7 @@ extension Hotkey: Equatable { } // MARK: Hotkey: Hashable -extension Hotkey: Hashable { +extension Hotkey: @MainActor Hashable { func hash(into hasher: inout Hasher) { hasher.combine(keyCombination) hasher.combine(action) diff --git a/Ice/Hotkeys/HotkeyRegistry.swift b/Ice/Hotkeys/HotkeyRegistry.swift index a488ec673..9e0fba2c9 100644 --- a/Ice/Hotkeys/HotkeyRegistry.swift +++ b/Ice/Hotkeys/HotkeyRegistry.swift @@ -120,6 +120,7 @@ final class HotkeyRegistry { /// the event kind specified by `eventKind`. /// /// - Returns: The registration's identifier on success, `nil` on failure. + @MainActor func register(hotkey: Hotkey, eventKind: EventKind, handler: @escaping () -> Void) -> UInt32? { enum Context { static var currentID: UInt32 = 0 From 4c9ecb6c8ecbc5801aec2d1df7d7912e03b3b799 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 4 Jul 2025 17:09:39 -0600 Subject: [PATCH 27/80] UI changes --- .../xcshareddata/swiftpm/Package.resolved | 12 ++--- .../SettingsPanes/AdvancedSettingsPane.swift | 50 ++++++++--------- Ice/UI/IceUI/IceSlider.swift | 53 ++++++++++--------- Ice/UI/Views/HotkeyRecorder.swift | 37 ++++++++----- 4 files changed, 83 insertions(+), 69 deletions(-) diff --git a/Ice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Ice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e45c27317..797d6ea30 100644 --- a/Ice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Ice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/buh/CompactSlider", "state" : { - "revision" : "abe4d1df6f0c85dcb133266cc07c2a5d08295726", - "version" : "1.1.6" + "revision" : "e5219ff353613b6493bfe5a3333c3bfa2d1e4d57", + "version" : "1.2.1" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ukushu/Ifrit", "state" : { - "revision" : "e610cdf4eddec1e76a9c7ae5db37738c7f73150b", - "version" : "2.0.3" + "revision" : "3f961f6d39cd2188305671f2ec65914d297571d0", + "version" : "2.0.6" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "0ef1ee0220239b3776f433314515fd849025673f", - "version" : "2.6.4" + "revision" : "df074165274afaa39539c05d57b0832620775b11", + "version" : "2.7.1" } } ], diff --git a/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift b/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift index 8f976bcd2..a86f8b21b 100644 --- a/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift @@ -45,6 +45,31 @@ struct AdvancedSettingsPane: View { } } + @ViewBuilder + private var enableAlwaysHiddenSection: some View { + Toggle( + "Enable the always-hidden section", + isOn: manager.bindings.enableAlwaysHiddenSection + ) + } + + @ViewBuilder + private var showAllSectionsOnUserDrag: some View { + Toggle( + "Show all sections when ⌘ Command + dragging menu bar items", + isOn: manager.bindings.showAllSectionsOnUserDrag + ) + } + + @ViewBuilder + private var sectionDividerStyle: some View { + IcePicker("Section divider style", selection: manager.bindings.sectionDividerStyle) { + ForEach(SectionDividerStyle.allCases) { style in + Text(style.localized).tag(style) + } + } + } + @ViewBuilder private var hideApplicationMenus: some View { Toggle( @@ -81,31 +106,6 @@ struct AdvancedSettingsPane: View { } } - @ViewBuilder - private var enableAlwaysHiddenSection: some View { - Toggle( - "Enable the \(MenuBarSection.Name.alwaysHidden.displayString) Section", - isOn: manager.bindings.enableAlwaysHiddenSection - ) - } - - @ViewBuilder - private var showAllSectionsOnUserDrag: some View { - Toggle( - "Show all sections when ⌘ Command + dragging menu bar items", - isOn: manager.bindings.showAllSectionsOnUserDrag - ) - } - - @ViewBuilder - private var sectionDividerStyle: some View { - IcePicker("Section divider style", selection: manager.bindings.sectionDividerStyle) { - ForEach(SectionDividerStyle.allCases) { style in - Text(style.localized).tag(style) - } - } - } - @ViewBuilder private var showOnHoverDelay: some View { IceLabeledContent { diff --git a/Ice/UI/IceUI/IceSlider.swift b/Ice/UI/IceUI/IceSlider.swift index 0b92dd151..c956ebae2 100644 --- a/Ice/UI/IceUI/IceSlider.swift +++ b/Ice/UI/IceUI/IceSlider.swift @@ -6,42 +6,35 @@ import CompactSlider import SwiftUI -struct IceSlider: View { - private let value: Binding +struct IceSlider: View { + @Binding private var value: Value + private let bounds: ClosedRange - private let step: Value + private let step: Value? private let valueLabel: ValueLabel - private let valueLabelSelectability: ValueLabelSelectability init( value: Binding, - in bounds: ClosedRange = 0...1, - step: Value = 0, - valueLabelSelectability: ValueLabelSelectability = .disabled, + in bounds: ClosedRange, + step: Value? = nil, @ViewBuilder valueLabel: () -> ValueLabel ) { - self.value = value + self._value = value self.bounds = bounds self.step = step self.valueLabel = valueLabel() - self.valueLabelSelectability = valueLabelSelectability } init( _ valueLabelKey: LocalizedStringKey, - valueLabelSelectability: ValueLabelSelectability = .disabled, value: Binding, - in bounds: ClosedRange = 0...1, - step: Value = 0 + in bounds: ClosedRange, + step: Value? = nil ) where ValueLabel == Text { - self.init( - value: value, - in: bounds, - step: step, - valueLabelSelectability: valueLabelSelectability - ) { - Text(valueLabelKey) - } + self._value = value + self.bounds = bounds + self.step = step + self.valueLabel = Text(valueLabelKey) } private var borderShape: some InsettableShape { @@ -52,19 +45,27 @@ struct IceSlider: View { private let label: Label + private var size: CGSize { + if #available(macOS 26.0, *) { + CGSize(width: 140, height: 24) + } else { + CGSize(width: 132, height: 24) + } + } + init(hotkey: Hotkey, @ViewBuilder label: () -> Label) { self._model = StateObject(wrappedValue: HotkeyRecorderModel(hotkey: hotkey)) self.label = label() @@ -20,11 +28,7 @@ struct HotkeyRecorder: View { var body: some View { IceLabeledContent { - HStack(spacing: 1) { - leadingSegment - trailingSegment - } - .frame(width: 132, height: 24) + segmentStack } label: { label } @@ -38,6 +42,15 @@ struct HotkeyRecorder: View { } } + @ViewBuilder + private var segmentStack: some View { + HStack(spacing: 1) { + leadingSegment + trailingSegment + } + .frame(width: size.width, height: size.height) + } + @ViewBuilder private var leadingSegment: some View { Button { @@ -92,19 +105,17 @@ struct HotkeyRecorder: View { @ViewBuilder private var trailingSegmentLabel: some View { - let (name, label, padding, weight) = if model.isRecording { - ("escape", "Cancel", 5.5, Font.Weight.regular) + let (name, label, padding) = if model.isRecording { + ("escape", "Cancel", 6.0) } else if model.hotkey.isEnabled { - ("xmark", "Clear", 7.5, Font.Weight.medium) + ("xmark", "Clear", 7.5) } else { - ("record.circle", "Record", 5.5, Font.Weight.regular) + ("record.circle", "Record", 5.5) } Image(systemName: name) .resizable() .aspectRatio(1, contentMode: .fit) .padding(padding) - .foregroundStyle(.secondary) - .fontWeight(weight) .accessibilityLabel(label) } } @@ -217,8 +228,10 @@ private struct HotkeyRecorderButtonStyle: ButtonStyle { } func makeBody(configuration: Configuration) -> some View { + let isProminent = isHighlighted || configuration.isPressed borderShape - .fill(isHighlighted || configuration.isPressed ? .tertiary : .quaternary) + .fill(isProminent ? .tertiary : .quaternary) + .opacity(isProminent ? 0.5 : 0.75) .overlay { configuration.label .lineLimit(1) From 4b57b4a1eaf5d7bb5eb3dea80159801824737898 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Sat, 5 Jul 2025 16:18:37 -0600 Subject: [PATCH 28/80] Fix `MenuBarItem` image caching issues --- .../MenuBarItems/MenuBarItemImageCache.swift | 35 +++++-------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift index 5c50f0058..7d02794fe 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift @@ -113,18 +113,16 @@ final class MenuBarItemImageCache: ObservableObject { let backingScaleFactor = screen.backingScaleFactor let displayBounds = CGDisplayBounds(screen.displayID) let option: CGWindowImageOption = [.boundsIgnoreFraming, .bestResolution] - let defaultItemThickness = NSStatusBar.system.thickness * backingScaleFactor var itemInfosDict = [CGWindowID: MenuBarItemInfo]() var itemBoundsDict = [CGWindowID: CGRect]() var windowIDs = [CGWindowID]() - var allBounds = CGRect.null + var combinedBounds = CGRect.null for item in items { let windowID = item.windowID guard - // Use the most up-to-date window bounds. - let itemBounds = Bridging.getWindowBounds(for: windowID), + let itemBounds = Bridging.getWindowBounds(for: windowID), // Get latest bounds. itemBounds.minY == displayBounds.minY else { continue @@ -132,12 +130,12 @@ final class MenuBarItemImageCache: ObservableObject { itemInfosDict[windowID] = item.info itemBoundsDict[windowID] = itemBounds windowIDs.append(windowID) - allBounds = allBounds.union(itemBounds) + combinedBounds = combinedBounds.union(itemBounds) } if let compositeImage = ScreenCapture.captureWindows(windowIDs, option: option), - CGFloat(compositeImage.width) == allBounds.width * backingScaleFactor + CGFloat(compositeImage.width) == combinedBounds.width * backingScaleFactor { for windowID in windowIDs { guard @@ -148,8 +146,8 @@ final class MenuBarItemImageCache: ObservableObject { } let frame = CGRect( - x: (itemBounds.origin.x - allBounds.origin.x) * backingScaleFactor, - y: (itemBounds.origin.y - allBounds.origin.y) * backingScaleFactor, + x: (itemBounds.origin.x - combinedBounds.origin.x) * backingScaleFactor, + y: (itemBounds.origin.y - combinedBounds.origin.y) * backingScaleFactor, width: itemBounds.width * backingScaleFactor, height: itemBounds.height * backingScaleFactor ) @@ -164,33 +162,18 @@ final class MenuBarItemImageCache: ObservableObject { logger.warning( """ Composite capture failed for \(section.logString, privacy: .public). \ - Attempting to capture each item individually. + Attempting to capture items individually. """ ) for windowID in windowIDs { guard let itemInfo = itemInfosDict[windowID], - let itemBounds = itemBoundsDict[windowID] - else { - continue - } - - let frame = CGRect( - x: 0, - y: ((itemBounds.height * backingScaleFactor) / 2) - (defaultItemThickness / 2), - width: itemBounds.width * backingScaleFactor, - height: defaultItemThickness - ) - - guard - let itemImage = ScreenCapture.captureWindow(windowID, option: option), - let croppedImage = itemImage.cropping(to: frame) + let itemImage = ScreenCapture.captureWindow(windowID, option: option) else { continue } - - images[itemInfo] = croppedImage + images[itemInfo] = itemImage } } From b77730edfc7728119bb17f355a5e0887eba4a6fe Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Mon, 7 Jul 2025 20:47:20 -0600 Subject: [PATCH 29/80] Refactor settings models --- Ice/Events/EventManager.swift | 20 ++-- Ice/Hotkeys/Hotkey.swift | 18 ++-- Ice/Hotkeys/HotkeyAction.swift | 2 +- Ice/Main/AppState.swift | 11 +-- .../MenuBarAppearanceEditor.swift | 2 +- Ice/MenuBar/ControlItem/ControlItem.swift | 29 +++--- .../ControlItem/ControlItemImage.swift | 3 +- Ice/MenuBar/IceBar/IceBar.swift | 2 +- .../MenuBarItems/MenuBarItemManager.swift | 2 +- Ice/MenuBar/MenuBarManager.swift | 6 +- Ice/MenuBar/MenuBarSection.swift | 8 +- .../AdvancedSettings.swift} | 13 +-- Ice/Settings/Models/AppSettings.swift | 52 +++++++++++ .../GeneralSettings.swift} | 13 +-- Ice/Settings/Models/HotkeysSettings.swift | 93 +++++++++++++++++++ .../HotkeySettingsManager.swift | 80 ---------------- .../SettingsManagers/SettingsManager.swift | 53 ----------- .../SettingsPanes/AdvancedSettingsPane.swift | 23 ++--- .../SettingsPanes/GeneralSettingsPane.swift | 67 +++++++------ .../SettingsPanes/HotkeysSettingsPane.swift | 7 +- Ice/Settings/SettingsView.swift | 7 +- Ice/Utilities/MigrationManager.swift | 6 +- 22 files changed, 260 insertions(+), 257 deletions(-) rename Ice/Settings/{SettingsManagers/AdvancedSettingsManager.swift => Models/AdvancedSettings.swift} (93%) create mode 100644 Ice/Settings/Models/AppSettings.swift rename Ice/Settings/{SettingsManagers/GeneralSettingsManager.swift => Models/GeneralSettings.swift} (96%) create mode 100644 Ice/Settings/Models/HotkeysSettings.swift delete mode 100644 Ice/Settings/SettingsManagers/HotkeySettingsManager.swift delete mode 100644 Ice/Settings/SettingsManagers/SettingsManager.swift diff --git a/Ice/Events/EventManager.swift b/Ice/Events/EventManager.swift index 6b4e9ab60..8dc894d2e 100644 --- a/Ice/Events/EventManager.swift +++ b/Ice/Events/EventManager.swift @@ -154,7 +154,7 @@ extension EventManager { private func handleShowOnClick(appState: AppState, screen: NSScreen) { guard - appState.settingsManager.generalSettingsManager.showOnClick, + appState.settings.general.showOnClick, isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen) else { return @@ -194,8 +194,8 @@ extension EventManager { private func handleSmartRehide(with event: NSEvent, appState: AppState, screen: NSScreen) { guard - appState.settingsManager.generalSettingsManager.autoRehide, - case .smart = appState.settingsManager.generalSettingsManager.rehideStrategy + appState.settings.general.autoRehide, + case .smart = appState.settings.general.rehideStrategy else { return } @@ -270,7 +270,7 @@ extension EventManager { private func handleShowSecondaryContextMenu(appState: AppState, screen: NSScreen) { guard - appState.settingsManager.advancedSettingsManager.enableSecondaryContextMenu, + appState.settings.advanced.enableSecondaryContextMenu, isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen), let mouseLocation = MouseCursor.locationAppKit else { @@ -283,8 +283,8 @@ extension EventManager { private func handlePreventShowOnHover(with event: NSEvent, appState: AppState, screen: NSScreen) { guard - appState.settingsManager.generalSettingsManager.showOnHover, - !appState.settingsManager.generalSettingsManager.useIceBar + appState.settings.general.showOnHover, + !appState.settings.general.useIceBar else { return } @@ -338,7 +338,7 @@ extension EventManager { isDraggingMenuBarItem = true - if appState.settingsManager.advancedSettingsManager.showAllSectionsOnUserDrag { + if appState.settings.advanced.showAllSectionsOnUserDrag { for section in appState.menuBarManager.sections { section.controlItem.state = .showItems } @@ -350,7 +350,7 @@ extension EventManager { private func handleShowOnHover(appState: AppState, screen: NSScreen) { // Make sure the "ShowOnHover" feature is enabled and allowed. guard - appState.settingsManager.generalSettingsManager.showOnHover, + appState.settings.general.showOnHover, appState.menuBarManager.showOnHoverAllowed else { return @@ -361,7 +361,7 @@ extension EventManager { return } - let delay = appState.settingsManager.advancedSettingsManager.showOnHoverDelay + let delay = appState.settings.advanced.showOnHoverDelay if hiddenSection.isHidden { guard isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen) else { @@ -400,7 +400,7 @@ extension EventManager { private func handleShowOnScroll(with event: NSEvent, appState: AppState, screen: NSScreen) { // Make sure the "ShowOnScroll" feature is enabled. - guard appState.settingsManager.generalSettingsManager.showOnScroll else { + guard appState.settings.general.showOnScroll else { return } diff --git a/Ice/Hotkeys/Hotkey.swift b/Ice/Hotkeys/Hotkey.swift index 87032091a..0266d8183 100644 --- a/Ice/Hotkeys/Hotkey.swift +++ b/Ice/Hotkeys/Hotkey.swift @@ -19,22 +19,24 @@ final class Hotkey: ObservableObject { } } - /// The hotkey's action. - let action: HotkeyAction - /// The shared app state. private weak var appState: AppState? /// Manages the lifetime of the hotkey observation. private var listener: Listener? + /// The hotkey's action. + let action: HotkeyAction + /// A Boolean value that indicates whether the hotkey is enabled. - var isEnabled: Bool { listener != nil } + var isEnabled: Bool { + listener != nil + } - /// Creates a hotkey with the given key combination and action. - init(keyCombination: KeyCombination?, action: HotkeyAction) { - self.keyCombination = keyCombination + /// Creates a hotkey with the given action and key combination. + init(action: HotkeyAction, keyCombination: KeyCombination? = nil) { self.action = action + self.keyCombination = keyCombination } /// Performs the initial setup of the hotkey. @@ -72,7 +74,7 @@ extension Hotkey { else { return nil } - let registry = appState.hotkeyRegistry + let registry = appState.settings.hotkeys.registry let id = registry.register(hotkey: hotkey, eventKind: eventKind) { [weak appState] in guard let appState else { return diff --git a/Ice/Hotkeys/HotkeyAction.swift b/Ice/Hotkeys/HotkeyAction.swift index 8643460ad..ab4c02202 100644 --- a/Ice/Hotkeys/HotkeyAction.swift +++ b/Ice/Hotkeys/HotkeyAction.swift @@ -39,7 +39,7 @@ enum HotkeyAction: String, Codable, CaseIterable { case .searchMenuBarItems: await appState.menuBarManager.searchPanel.toggle() case .enableIceBar: - appState.settingsManager.generalSettingsManager.useIceBar.toggle() + appState.settings.general.useIceBar.toggle() case .toggleApplicationMenus: appState.menuBarManager.toggleApplicationMenus() } diff --git a/Ice/Main/AppState.swift b/Ice/Main/AppState.swift index f7c673477..e5cb24fb0 100644 --- a/Ice/Main/AppState.swift +++ b/Ice/Main/AppState.swift @@ -16,8 +16,8 @@ final class AppState: ObservableObject { /// A Boolean value that indicates whether the user is dragging a menu bar item. @Published private(set) var isDraggingMenuBarItem = false - /// Manager for the app's settings. - let settingsManager = SettingsManager() + /// Model for the app's settings. + let settings = AppSettings() /// Model for app-wide navigation. let navigationState = AppNavigationState() @@ -49,9 +49,6 @@ final class AppState: ObservableObject { /// Manager for user notifications. let userNotificationManager = UserNotificationManager() - /// The app's hotkey registry. - let hotkeyRegistry = HotkeyRegistry() - /// Storage for internal observers. private var cancellables = Set() @@ -66,7 +63,7 @@ final class AppState: ObservableObject { menuBarManager.performSetup(with: self) appearanceManager.performSetup(with: self) eventManager.performSetup(with: self) - settingsManager.performSetup(with: self) + settings.performSetup(with: self) itemManager.performSetup(with: self) imageCache.performSetup(with: self) updatesManager.performSetup(with: self) @@ -163,7 +160,7 @@ final class AppState: ObservableObject { self?.objectWillChange.send() } .store(in: &c) - settingsManager.objectWillChange + settings.objectWillChange .sink { [weak self] in self?.objectWillChange.send() } diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift index 8ce789e49..3eaa7299a 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift @@ -61,7 +61,7 @@ struct MenuBarAppearanceEditor: View { IceForm(padding: mainFormPadding) { if case .settings = location, - appState.settingsManager.advancedSettingsManager.enableSecondaryContextMenu + appState.settings.advanced.enableSecondaryContextMenu { CalloutBox( "Tip: You can also edit these settings by right-clicking in an empty area of the menu bar.", diff --git a/Ice/MenuBar/ControlItem/ControlItem.swift b/Ice/MenuBar/ControlItem/ControlItem.swift index 10abb1e0e..3b8fd083c 100644 --- a/Ice/MenuBar/ControlItem/ControlItem.swift +++ b/Ice/MenuBar/ControlItem/ControlItem.swift @@ -200,12 +200,12 @@ final class ControlItem { return } - let manager = appState.settingsManager.hotkeySettingsManager + let hotkeysSettings = appState.settings.hotkeys let hotkey: Hotkey? = switch identifier { case .iceIcon: nil - case .hidden: manager.hotkey(withAction: .toggleHiddenSection) - case .alwaysHidden: manager.hotkey(withAction: .toggleAlwaysHiddenSection) + case .hidden: hotkeysSettings.hotkey(withAction: .toggleHiddenSection) + case .alwaysHidden: hotkeysSettings.hotkey(withAction: .toggleAlwaysHiddenSection) } guard let hotkey else { @@ -276,7 +276,7 @@ final class ControlItem { } .store(in: &c) - appState.settingsManager.generalSettingsManager.$useIceBar + appState.settings.general.$useIceBar .receive(on: DispatchQueue.main) .sink { [weak self] useIceBar in guard @@ -294,7 +294,7 @@ final class ControlItem { .store(in: &c) if identifier == .iceIcon { - appState.settingsManager.generalSettingsManager.$showIceIcon + appState.settings.general.$showIceIcon .combineLatest(statusItem.publisher(for: \.isVisible)) .removeDuplicates { $0 == $1 } .receive(on: DispatchQueue.main) @@ -310,7 +310,7 @@ final class ControlItem { } .store(in: &c) - appState.settingsManager.generalSettingsManager.$iceIcon + appState.settings.general.$iceIcon .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self else { @@ -320,7 +320,7 @@ final class ControlItem { } .store(in: &c) - appState.settingsManager.generalSettingsManager.$customIceIconIsTemplate + appState.settings.general.$customIceIconIsTemplate .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self else { @@ -332,7 +332,7 @@ final class ControlItem { } if identifier == .alwaysHidden { - appState.settingsManager.advancedSettingsManager.$enableAlwaysHiddenSection + appState.settings.advanced.$enableAlwaysHiddenSection .combineLatest(statusItem.publisher(for: \.isVisible)) .removeDuplicates { $0 == $1 } .receive(on: DispatchQueue.main) @@ -350,7 +350,7 @@ final class ControlItem { } if isSectionDivider { - appState.settingsManager.advancedSettingsManager.$sectionDividerStyle + appState.settings.advanced.$sectionDividerStyle .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self else { @@ -383,7 +383,7 @@ final class ControlItem { updateStatusItemVisibility(true, state: state) updateButtonEnabledState(true) // Make sure button is enabled. - let icon = appState.settingsManager.generalSettingsManager.iceIcon + let icon = appState.settings.general.iceIcon // We can usually just create the image directly from the icon. var image = switch state { @@ -410,12 +410,12 @@ final class ControlItem { updateStatusItemVisibility(true, state: state) updateButtonEnabledState(false) // Keep button from highlighting. case .showItems: - switch appState.settingsManager.advancedSettingsManager.sectionDividerStyle { + switch appState.settings.advanced.sectionDividerStyle { case .noDivider: updateStatusItemVisibility(false, state: state) updateButtonEnabledState(false) // Keep button from highlighting. - if appState.isDraggingMenuBarItem && appState.settingsManager.advancedSettingsManager.showAllSectionsOnUserDrag { + if appState.isDraggingMenuBarItem && appState.settings.advanced.showAllSectionsOnUserDrag { // We still want a subtle marker between sections. button.title = "|" } @@ -458,7 +458,7 @@ final class ControlItem { } constraint?.isActive = true } else { - let wider = appState.isDraggingMenuBarItem && appState.settingsManager.advancedSettingsManager.showAllSectionsOnUserDrag + let wider = appState.isDraggingMenuBarItem && appState.settings.advanced.showAllSectionsOnUserDrag statusItem.length = wider ? 3 : 0 constraint?.isActive = false if let window { @@ -549,8 +549,7 @@ final class ControlItem { /// Creates a menu to show under the control item. private func createMenu(with appState: AppState) -> NSMenu { func hotkey(withAction action: HotkeyAction) -> Hotkey? { - let hotkeySettingsManager = appState.settingsManager.hotkeySettingsManager - return hotkeySettingsManager.hotkey(withAction: action) + appState.settings.hotkeys.hotkey(withAction: action) } let menu = NSMenu(title: "Ice") diff --git a/Ice/MenuBar/ControlItem/ControlItemImage.swift b/Ice/MenuBar/ControlItem/ControlItemImage.swift index af5dcdd10..d447d0c8c 100644 --- a/Ice/MenuBar/ControlItem/ControlItemImage.swift +++ b/Ice/MenuBar/ControlItem/ControlItemImage.swift @@ -40,8 +40,7 @@ enum ControlItemImage: Codable, Hashable { return originalImage.resized(to: newSize) case .data(let data): let image = NSImage(data: data) - let generalSettingsManager = appState.settingsManager.generalSettingsManager - image?.isTemplate = generalSettingsManager.customIceIconIsTemplate + image?.isTemplate = appState.settings.general.customIceIconIsTemplate return image } } diff --git a/Ice/MenuBar/IceBar/IceBar.swift b/Ice/MenuBar/IceBar/IceBar.swift index f3b9f0379..b5efb2470 100644 --- a/Ice/MenuBar/IceBar/IceBar.swift +++ b/Ice/MenuBar/IceBar/IceBar.swift @@ -152,7 +152,7 @@ final class IceBarPanel: NSPanel { } } - setFrameOrigin(getOrigin(for: appState.settingsManager.generalSettingsManager.iceBarLocation)) + setFrameOrigin(getOrigin(for: appState.settings.general.iceBarLocation)) } /// Shows the panel on the given screen, displaying the given diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index c5a73c243..463399b80 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -1452,7 +1452,7 @@ extension MenuBarItemManager { do { let context = try await contextTask.value tempShownItemContexts.append(context) - runTempShownItemTimer(for: appState.settingsManager.advancedSettingsManager.tempShowInterval) + runTempShownItemTimer(for: appState.settings.advanced.tempShowInterval) } catch { logger.error("ERROR: \(error, privacy: .public)") } diff --git a/Ice/MenuBar/MenuBarManager.swift b/Ice/MenuBar/MenuBarManager.swift index 654db11ab..1af94966e 100644 --- a/Ice/MenuBar/MenuBarManager.swift +++ b/Ice/MenuBar/MenuBarManager.swift @@ -119,7 +119,7 @@ final class MenuBarManager: ObservableObject { if let self, let appState, - case .focusedApp = appState.settingsManager.generalSettingsManager.rehideStrategy, + case .focusedApp = appState.settings.general.rehideStrategy, let hiddenSection = section(withName: .hidden), let screen = appState.eventManager.bestScreen(appState: appState), !appState.eventManager.isMouseInsideMenuBar(appState: appState, screen: screen) @@ -169,8 +169,8 @@ final class MenuBarManager: ObservableObject { // * The active space is fullscreen. // * The settings window is visible. guard - appState.settingsManager.advancedSettingsManager.hideApplicationMenus, - !appState.settingsManager.generalSettingsManager.useIceBar, + appState.settings.advanced.hideApplicationMenus, + !appState.settings.general.useIceBar, !isMenuBarHiddenBySystem, !appState.isActiveSpaceFullscreen, !appState.navigationState.isSettingsPresented diff --git a/Ice/MenuBar/MenuBarSection.swift b/Ice/MenuBar/MenuBarSection.swift index a2d7d77a0..85aa4d3ba 100644 --- a/Ice/MenuBar/MenuBarSection.swift +++ b/Ice/MenuBar/MenuBarSection.swift @@ -51,7 +51,7 @@ final class MenuBarSection { /// A Boolean value that indicates whether the Ice Bar should be used. private var useIceBar: Bool { - appState?.settingsManager.generalSettingsManager.useIceBar ?? false + appState?.settings.general.useIceBar ?? false } /// A weak reference to the menu bar manager. @@ -249,8 +249,8 @@ final class MenuBarSection { guard let appState, - appState.settingsManager.generalSettingsManager.autoRehide, - case .timed = appState.settingsManager.generalSettingsManager.rehideStrategy + appState.settings.general.autoRehide, + case .timed = appState.settings.general.rehideStrategy else { return } @@ -265,7 +265,7 @@ final class MenuBarSection { if NSEvent.mouseLocation.y < screen.visibleFrame.maxY { if rehideTimer == nil { rehideTimer = .scheduledTimer( - withTimeInterval: appState.settingsManager.generalSettingsManager.rehideInterval, + withTimeInterval: appState.settings.general.rehideInterval, repeats: false ) { [weak self] _ in guard diff --git a/Ice/Settings/SettingsManagers/AdvancedSettingsManager.swift b/Ice/Settings/Models/AdvancedSettings.swift similarity index 93% rename from Ice/Settings/SettingsManagers/AdvancedSettingsManager.swift rename to Ice/Settings/Models/AdvancedSettings.swift index c920063d1..2a84b55e3 100644 --- a/Ice/Settings/SettingsManagers/AdvancedSettingsManager.swift +++ b/Ice/Settings/Models/AdvancedSettings.swift @@ -1,15 +1,16 @@ // -// AdvancedSettingsManager.swift +// AdvancedSettings.swift // Ice // import Combine import SwiftUI -// MARK: - AdvancedSettingsManager +// MARK: - AdvancedSettings +/// Model for the app's Advanced settings. @MainActor -final class AdvancedSettingsManager: ObservableObject { +final class AdvancedSettings: ObservableObject { /// A Boolean value that indicates whether the always-hidden section /// is enabled. @Published var enableAlwaysHiddenSection = false @@ -41,12 +42,14 @@ final class AdvancedSettingsManager: ObservableObject { /// The shared app state. private(set) weak var appState: AppState? + /// Performs the initial setup of the model. func performSetup(with appState: AppState) { self.appState = appState loadInitialState() configureCancellables() } + /// Loads the model's initial state. private func loadInitialState() { Defaults.ifPresent(key: .enableAlwaysHiddenSection, assign: &enableAlwaysHiddenSection) Defaults.ifPresent(key: .showAllSectionsOnUserDrag, assign: &showAllSectionsOnUserDrag) @@ -62,6 +65,7 @@ final class AdvancedSettingsManager: ObservableObject { } } + /// Configures the internal observers for the model. private func configureCancellables() { var c = Set() @@ -118,9 +122,6 @@ final class AdvancedSettingsManager: ObservableObject { } } -// MARK: AdvancedSettingsManager: BindingExposable -extension AdvancedSettingsManager: BindingExposable { } - // MARK: - SectionDividerStyle enum SectionDividerStyle: Int, CaseIterable, Identifiable { diff --git a/Ice/Settings/Models/AppSettings.swift b/Ice/Settings/Models/AppSettings.swift new file mode 100644 index 000000000..495714cd0 --- /dev/null +++ b/Ice/Settings/Models/AppSettings.swift @@ -0,0 +1,52 @@ +// +// AppSettings.swift +// Ice +// + +import Combine + +/// Top-level model for the app's settings. +@MainActor +final class AppSettings: ObservableObject { + /// The model for the app's Advanced settings. + let advanced = AdvancedSettings() + + /// The model for the app's General settings. + let general = GeneralSettings() + + /// The model for the app's Hotkeys settings. + let hotkeys = HotkeysSettings() + + /// Storage for internal observers. + private var cancellables = Set() + + /// Performs the initial setup of the settings model. + func performSetup(with appState: AppState) { + advanced.performSetup(with: appState) + general.performSetup(with: appState) + hotkeys.performSetup(with: appState) + configureCancellables() + } + + private func configureCancellables() { + var c = Set() + + advanced.objectWillChange + .sink { [weak self] in + self?.objectWillChange.send() + } + .store(in: &c) + general.objectWillChange + .sink { [weak self] in + self?.objectWillChange.send() + } + .store(in: &c) + hotkeys.objectWillChange + .sink { [weak self] in + self?.objectWillChange.send() + } + .store(in: &c) + + cancellables = c + } +} diff --git a/Ice/Settings/SettingsManagers/GeneralSettingsManager.swift b/Ice/Settings/Models/GeneralSettings.swift similarity index 96% rename from Ice/Settings/SettingsManagers/GeneralSettingsManager.swift rename to Ice/Settings/Models/GeneralSettings.swift index afe8dd180..31c61b1d3 100644 --- a/Ice/Settings/SettingsManagers/GeneralSettingsManager.swift +++ b/Ice/Settings/Models/GeneralSettings.swift @@ -1,5 +1,5 @@ // -// GeneralSettingsManager.swift +// GeneralSettings.swift // Ice // @@ -7,10 +7,11 @@ import Combine import OSLog import SwiftUI -// MARK: - GeneralSettingsManager +// MARK: - GeneralSettings +/// Model for the app's General settings. @MainActor -final class GeneralSettingsManager: ObservableObject { +final class GeneralSettings: ObservableObject { /// A Boolean value that indicates whether the Ice icon /// should be shown. @Published var showIceIcon = true @@ -74,12 +75,14 @@ final class GeneralSettingsManager: ObservableObject { /// The shared app state. private(set) weak var appState: AppState? + /// Performs the initial setup of the model. func performSetup(with appState: AppState) { self.appState = appState loadInitialState() configureCancellables() } + /// Loads the model's initial state. private func loadInitialState() { Defaults.ifPresent(key: .showIceIcon, assign: &showIceIcon) Defaults.ifPresent(key: .customIceIconIsTemplate, assign: &customIceIconIsTemplate) @@ -114,6 +117,7 @@ final class GeneralSettingsManager: ObservableObject { } } + /// Configures the internal observers for the model. private func configureCancellables() { var c = Set() @@ -217,9 +221,6 @@ final class GeneralSettingsManager: ObservableObject { } } -// MARK: GeneralSettingsManager: BindingExposable -extension GeneralSettingsManager: BindingExposable { } - // MARK: - RehideStrategy /// A type that determines how the auto-rehide feature works. diff --git a/Ice/Settings/Models/HotkeysSettings.swift b/Ice/Settings/Models/HotkeysSettings.swift new file mode 100644 index 000000000..a77eaa01a --- /dev/null +++ b/Ice/Settings/Models/HotkeysSettings.swift @@ -0,0 +1,93 @@ +// +// HotkeysSettings.swift +// Ice +// + +import Combine +import Foundation +import OSLog + +/// Model for the app's Hotkeys settings. +@MainActor +final class HotkeysSettings: ObservableObject { + /// The app's hotkey registry. + let registry = HotkeyRegistry() + + /// The app's hotkeys. + let hotkeys = HotkeyAction.allCases.map { action in + Hotkey(action: action) + } + + /// Encoder for properties. + private let encoder = JSONEncoder() + + /// Decoder for properties. + private let decoder = JSONDecoder() + + /// Storage for internal observers. + private var cancellables = Set() + + /// The shared app state. + private(set) weak var appState: AppState? + + /// Performs the initial setup of the model. + func performSetup(with appState: AppState) { + self.appState = appState + for hotkey in hotkeys { + hotkey.performSetup(with: appState) + } + loadInitialState() + configureCancellables() + } + + /// Loads the model's initial state. + private func loadInitialState() { + guard + let dictionary = Defaults.dictionary(forKey: .hotkeys) as? [String: Data], + !dictionary.isEmpty + else { + return + } + for hotkey in hotkeys { + guard let data = dictionary[hotkey.action.rawValue] else { + continue + } + do { + if let keyCombination = try decoder.decode(KeyCombination?.self, from: data) { + hotkey.keyCombination = keyCombination + } + } catch { + Logger.serialization.error("Error decoding hotkey: \(error, privacy: .public)") + } + } + } + + /// Configures the internal observers for the model. + private func configureCancellables() { + var c = Set() + + for hotkey in hotkeys { + hotkey.$keyCombination + .encode(encoder: encoder) + .receive(on: DispatchQueue.main) + .sink { completion in + if case .failure(let error) = completion { + Logger.serialization.error("Error encoding hotkey: \(error, privacy: .public)") + } + } receiveValue: { data in + with(Defaults.dictionary(forKey: .hotkeys) ?? [:]) { dictionary in + dictionary[hotkey.action.rawValue] = data + Defaults.set(dictionary, forKey: .hotkeys) + } + } + .store(in: &c) + } + + cancellables = c + } + + /// Returns the hotkey with the given action. + func hotkey(withAction action: HotkeyAction) -> Hotkey? { + hotkeys.first { $0.action == action } + } +} diff --git a/Ice/Settings/SettingsManagers/HotkeySettingsManager.swift b/Ice/Settings/SettingsManagers/HotkeySettingsManager.swift deleted file mode 100644 index fcd0a01e2..000000000 --- a/Ice/Settings/SettingsManagers/HotkeySettingsManager.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// HotkeySettingsManager.swift -// Ice -// - -import Combine -import Foundation -import OSLog - -@MainActor -final class HotkeySettingsManager: ObservableObject { - /// All hotkeys. - @Published private(set) var hotkeys = HotkeyAction.allCases.map { action in - Hotkey(keyCombination: nil, action: action) - } - - /// Encoder for hotkeys. - private let encoder = JSONEncoder() - - /// Decoder for hotkeys. - private let decoder = JSONDecoder() - - /// Storage for internal observers. - private var cancellables = Set() - - /// The shared app state. - private(set) weak var appState: AppState? - - func performSetup(with appState: AppState) { - self.appState = appState - loadInitialState() - configureCancellables() - } - - private func loadInitialState() { - if let dict = Defaults.dictionary(forKey: .hotkeys) as? [String: Data] { - for hotkey in hotkeys { - if let data = dict[hotkey.action.rawValue] { - do { - hotkey.keyCombination = try decoder.decode(KeyCombination?.self, from: data) - } catch { - Logger.serialization.error("Error decoding hotkey: \(error, privacy: .public)") - } - } - } - } - } - - private func configureCancellables() { - var c = Set() - - $hotkeys.combineLatest(Publishers.MergeMany(hotkeys.map { $0.$keyCombination })) - .receive(on: DispatchQueue.main) - .sink { [weak self] hotkeys, _ in - guard - let self, - let appState - else { - return - } - var dict = [String: Data]() - for hotkey in hotkeys { - hotkey.performSetup(with: appState) - do { - dict[hotkey.action.rawValue] = try self.encoder.encode(hotkey.keyCombination) - } catch { - Logger.serialization.error("Error encoding hotkey: \(error, privacy: .public)") - } - } - Defaults.set(dict, forKey: .hotkeys) - } - .store(in: &c) - - cancellables = c - } - - func hotkey(withAction action: HotkeyAction) -> Hotkey? { - hotkeys.first { $0.action == action } - } -} diff --git a/Ice/Settings/SettingsManagers/SettingsManager.swift b/Ice/Settings/SettingsManagers/SettingsManager.swift deleted file mode 100644 index 81b4ea393..000000000 --- a/Ice/Settings/SettingsManagers/SettingsManager.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// SettingsManager.swift -// Ice -// - -import Combine - -@MainActor -final class SettingsManager: ObservableObject { - /// The manager for general settings. - let generalSettingsManager = GeneralSettingsManager() - - /// The manager for advanced settings. - let advancedSettingsManager = AdvancedSettingsManager() - - /// The manager for hotkey settings. - let hotkeySettingsManager = HotkeySettingsManager() - - /// Storage for internal observers. - private var cancellables = Set() - - func performSetup(with appState: AppState) { - configureCancellables() - generalSettingsManager.performSetup(with: appState) - advancedSettingsManager.performSetup(with: appState) - hotkeySettingsManager.performSetup(with: appState) - } - - private func configureCancellables() { - var c = Set() - - generalSettingsManager.objectWillChange - .sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &c) - advancedSettingsManager.objectWillChange - .sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &c) - hotkeySettingsManager.objectWillChange - .sink { [weak self] in - self?.objectWillChange.send() - } - .store(in: &c) - - cancellables = c - } -} - -// MARK: SettingsManager: BindingExposable -extension SettingsManager: BindingExposable { } diff --git a/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift b/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift index a86f8b21b..8d4603f4d 100644 --- a/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift @@ -7,16 +7,13 @@ import SwiftUI struct AdvancedSettingsPane: View { @EnvironmentObject var appState: AppState + @ObservedObject var settings: AdvancedSettings @State private var maxSliderLabelWidth: CGFloat = 0 private var menuBarManager: MenuBarManager { appState.menuBarManager } - private var manager: AdvancedSettingsManager { - appState.settingsManager.advancedSettingsManager - } - private func formattedToSeconds(_ interval: TimeInterval) -> LocalizedStringKey { let formatted = interval.formatted() return if interval == 1 { @@ -49,7 +46,7 @@ struct AdvancedSettingsPane: View { private var enableAlwaysHiddenSection: some View { Toggle( "Enable the always-hidden section", - isOn: manager.bindings.enableAlwaysHiddenSection + isOn: $settings.enableAlwaysHiddenSection ) } @@ -57,13 +54,13 @@ struct AdvancedSettingsPane: View { private var showAllSectionsOnUserDrag: some View { Toggle( "Show all sections when ⌘ Command + dragging menu bar items", - isOn: manager.bindings.showAllSectionsOnUserDrag + isOn: $settings.showAllSectionsOnUserDrag ) } @ViewBuilder private var sectionDividerStyle: some View { - IcePicker("Section divider style", selection: manager.bindings.sectionDividerStyle) { + IcePicker("Section divider style", selection: $settings.sectionDividerStyle) { ForEach(SectionDividerStyle.allCases) { style in Text(style.localized).tag(style) } @@ -74,7 +71,7 @@ struct AdvancedSettingsPane: View { private var hideApplicationMenus: some View { Toggle( "Hide app menus when showing menu bar items", - isOn: manager.bindings.hideApplicationMenus + isOn: $settings.hideApplicationMenus ) .annotation { Text( @@ -92,7 +89,7 @@ struct AdvancedSettingsPane: View { private var enableSecondaryContextMenu: some View { Toggle( "Enable secondary context menu", - isOn: manager.bindings.enableSecondaryContextMenu + isOn: $settings.enableSecondaryContextMenu ) .annotation { Text( @@ -110,8 +107,8 @@ struct AdvancedSettingsPane: View { private var showOnHoverDelay: some View { IceLabeledContent { IceSlider( - formattedToSeconds(manager.showOnHoverDelay), - value: manager.bindings.showOnHoverDelay, + formattedToSeconds(settings.showOnHoverDelay), + value: $settings.showOnHoverDelay, in: 0...1, step: 0.1 ) @@ -129,8 +126,8 @@ struct AdvancedSettingsPane: View { private var tempShowInterval: some View { IceLabeledContent { IceSlider( - formattedToSeconds(manager.tempShowInterval), - value: manager.bindings.tempShowInterval, + formattedToSeconds(settings.tempShowInterval), + value: $settings.tempShowInterval, in: 0...60, step: 1 ) diff --git a/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift b/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift index 61da0b3e0..aa99f60f6 100644 --- a/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift @@ -8,18 +8,15 @@ import SwiftUI struct GeneralSettingsPane: View { @EnvironmentObject var appState: AppState + @ObservedObject var settings: GeneralSettings @State private var isImportingCustomIceIcon = false @State private var isPresentingError = false @State private var presentedError: LocalizedErrorWrapper? @State private var isApplyingOffset = false @State private var tempItemSpacingOffset: CGFloat = 0 - private var manager: GeneralSettingsManager { - appState.settingsManager.generalSettingsManager - } - private var itemSpacingOffset: LocalizedStringKey { - localizedOffsetString(for: manager.itemSpacingOffset) + localizedOffsetString(for: settings.itemSpacingOffset) } private func localizedOffsetString(for offset: CGFloat) -> LocalizedStringKey { @@ -36,8 +33,8 @@ struct GeneralSettingsPane: View { } private var rehideIntervalKey: LocalizedStringKey { - let formatted = manager.rehideInterval.formatted() - if manager.rehideInterval == 1 { + let formatted = settings.rehideInterval.formatted() + if settings.rehideInterval == 1 { return LocalizedStringKey(formatted + " second") } else { return LocalizedStringKey(formatted + " seconds") @@ -45,11 +42,11 @@ struct GeneralSettingsPane: View { } private var hasSpacingSliderValueChanged: Bool { - tempItemSpacingOffset != manager.itemSpacingOffset + tempItemSpacingOffset != settings.itemSpacingOffset } private var isActualOffsetDifferentFromDefault: Bool { - manager.itemSpacingOffset != 0 + settings.itemSpacingOffset != 0 } var body: some View { @@ -111,23 +108,23 @@ struct GeneralSettingsPane: View { @ViewBuilder private var iceIconOptions: some View { - Toggle("Show Ice icon", isOn: manager.bindings.showIceIcon) + Toggle("Show Ice icon", isOn: $settings.showIceIcon) .annotation("Click to show hidden menu bar items. Right-click to access Ice's settings.") - if manager.showIceIcon { + if settings.showIceIcon { IceMenu("Ice icon") { - Picker("Ice icon", selection: manager.bindings.iceIcon) { + Picker("Ice icon", selection: $settings.iceIcon) { ForEach(ControlItemImageSet.userSelectableIceIcons) { imageSet in Button { - manager.iceIcon = imageSet + settings.iceIcon = imageSet } label: { menuItem(for: imageSet) } .tag(imageSet) } - if let lastCustomIceIcon = manager.lastCustomIceIcon { + if let lastCustomIceIcon = settings.lastCustomIceIcon { Button { - manager.iceIcon = lastCustomIceIcon + settings.iceIcon = lastCustomIceIcon } label: { menuItem(for: lastCustomIceIcon) } @@ -143,7 +140,7 @@ struct GeneralSettingsPane: View { isImportingCustomIceIcon = true } } title: { - menuItem(for: manager.iceIcon) + menuItem(for: settings.iceIcon) } .annotation("Choose a custom icon to show in the menu bar.") .fileImporter( @@ -155,7 +152,7 @@ struct GeneralSettingsPane: View { if url.startAccessingSecurityScopedResource() { defer { url.stopAccessingSecurityScopedResource() } let data = try Data(contentsOf: url) - manager.iceIcon = ControlItemImageSet(name: .custom, image: .data(data)) + settings.iceIcon = ControlItemImageSet(name: .custom, image: .data(data)) } } catch { presentedError = LocalizedErrorWrapper(error) @@ -163,8 +160,8 @@ struct GeneralSettingsPane: View { } } - if case .custom = manager.iceIcon.name { - Toggle("Apply system theme to icon", isOn: manager.bindings.customIceIconIsTemplate) + if case .custom = settings.iceIcon.name { + Toggle("Apply system theme to icon", isOn: $settings.customIceIconIsTemplate) .annotation("Display the icon as a monochrome image matching the system appearance.") } } @@ -173,26 +170,26 @@ struct GeneralSettingsPane: View { @ViewBuilder private var iceBarOptions: some View { useIceBar - if manager.useIceBar { + if settings.useIceBar { iceBarLocationPicker } } @ViewBuilder private var useIceBar: some View { - Toggle("Use Ice Bar", isOn: manager.bindings.useIceBar) + Toggle("Use Ice Bar", isOn: $settings.useIceBar) .annotation("Show hidden menu bar items in a separate bar below the menu bar.") } @ViewBuilder private var iceBarLocationPicker: some View { - IcePicker("Location", selection: manager.bindings.iceBarLocation) { + IcePicker("Location", selection: $settings.iceBarLocation) { ForEach(IceBarLocation.allCases) { location in Text(location.localized).tag(location) } } .annotation { - switch manager.iceBarLocation { + switch settings.iceBarLocation { case .dynamic: Text("The Ice Bar's location changes based on context.") case .mousePointer: @@ -205,19 +202,19 @@ struct GeneralSettingsPane: View { @ViewBuilder private var showOnClick: some View { - Toggle("Show on click", isOn: manager.bindings.showOnClick) + Toggle("Show on click", isOn: $settings.showOnClick) .annotation("Click inside an empty area of the menu bar to show hidden menu bar items.") } @ViewBuilder private var showOnHover: some View { - Toggle("Show on hover", isOn: manager.bindings.showOnHover) + Toggle("Show on hover", isOn: $settings.showOnHover) .annotation("Hover over an empty area of the menu bar to show hidden menu bar items.") } @ViewBuilder private var showOnScroll: some View { - Toggle("Show on scroll", isOn: manager.bindings.showOnScroll) + Toggle("Show on scroll", isOn: $settings.showOnScroll) .annotation("Scroll or swipe in the menu bar to toggle hidden menu bar items.") } @@ -272,19 +269,19 @@ struct GeneralSettingsPane: View { ) } .onAppear { - tempItemSpacingOffset = manager.itemSpacingOffset + tempItemSpacingOffset = settings.itemSpacingOffset } } @ViewBuilder private var rehideStrategyPicker: some View { - IcePicker("Strategy", selection: manager.bindings.rehideStrategy) { + IcePicker("Strategy", selection: $settings.rehideStrategy) { ForEach(RehideStrategy.allCases) { strategy in Text(strategy.localized).tag(strategy) } } .annotation { - switch manager.rehideStrategy { + switch settings.rehideStrategy { case .smart: Text("Menu bar items are rehidden using a smart algorithm.") case .timed: @@ -297,14 +294,14 @@ struct GeneralSettingsPane: View { @ViewBuilder private var autoRehideOptions: some View { - Toggle("Automatically rehide", isOn: manager.bindings.autoRehide) - if manager.autoRehide { - if case .timed = manager.rehideStrategy { + Toggle("Automatically rehide", isOn: $settings.autoRehide) + if settings.autoRehide { + if case .timed = settings.rehideStrategy { VStack { rehideStrategyPicker IceSlider( rehideIntervalKey, - value: manager.bindings.rehideInterval, + value: $settings.rehideInterval, in: 0...30, step: 1 ) @@ -318,7 +315,7 @@ struct GeneralSettingsPane: View { /// Apply menu bar spacing offset. private func applyOffset() { isApplyingOffset = true - manager.itemSpacingOffset = tempItemSpacingOffset + settings.itemSpacingOffset = tempItemSpacingOffset Task { do { try await appState.spacingManager.applyOffset() @@ -333,7 +330,7 @@ struct GeneralSettingsPane: View { /// Reset menu bar spacing offset to default. private func resetOffsetToDefault() { tempItemSpacingOffset = 0 - manager.itemSpacingOffset = tempItemSpacingOffset + settings.itemSpacingOffset = tempItemSpacingOffset applyOffset() } } diff --git a/Ice/Settings/SettingsPanes/HotkeysSettingsPane.swift b/Ice/Settings/SettingsPanes/HotkeysSettingsPane.swift index 0c33c966f..584a98789 100644 --- a/Ice/Settings/SettingsPanes/HotkeysSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/HotkeysSettingsPane.swift @@ -7,10 +7,7 @@ import SwiftUI struct HotkeysSettingsPane: View { @EnvironmentObject var appState: AppState - - private var manager: HotkeySettingsManager { - appState.settingsManager.hotkeySettingsManager - } + @ObservedObject var settings: HotkeysSettings var body: some View { IceForm { @@ -30,7 +27,7 @@ struct HotkeysSettingsPane: View { @ViewBuilder private func hotkeyRecorder(forAction action: HotkeyAction) -> some View { - if let hotkey = manager.hotkey(withAction: action) { + if let hotkey = settings.hotkey(withAction: action) { HotkeyRecorder(hotkey: hotkey) { switch action { case .toggleHiddenSection: diff --git a/Ice/Settings/SettingsView.swift b/Ice/Settings/SettingsView.swift index 56da2fce8..7d5258d8a 100644 --- a/Ice/Settings/SettingsView.swift +++ b/Ice/Settings/SettingsView.swift @@ -6,6 +6,7 @@ import SwiftUI struct SettingsView: View { + @EnvironmentObject var appState: AppState @EnvironmentObject var navigationState: AppNavigationState @Environment(\.appearsActive) var appearsActive @Environment(\.sidebarRowSize) var sidebarRowSize @@ -93,15 +94,15 @@ struct SettingsView: View { private var settingsPane: some View { switch navigationState.settingsNavigationIdentifier { case .general: - GeneralSettingsPane() + GeneralSettingsPane(settings: appState.settings.general) case .menuBarLayout: MenuBarLayoutSettingsPane() case .menuBarAppearance: MenuBarAppearanceSettingsPane() case .hotkeys: - HotkeysSettingsPane() + HotkeysSettingsPane(settings: appState.settings.hotkeys) case .advanced: - AdvancedSettingsPane() + AdvancedSettingsPane(settings: appState.settings.advanced) case .about: AboutSettingsPane() } diff --git a/Ice/Utilities/MigrationManager.swift b/Ice/Utilities/MigrationManager.swift index f0ad28b2e..a1b557e86 100644 --- a/Ice/Utilities/MigrationManager.swift +++ b/Ice/Utilities/MigrationManager.swift @@ -104,13 +104,13 @@ extension MigrationManager { key: KeyCode(rawValue: key), modifiers: Modifiers(rawValue: modifiers) ) - let hotkeySettingsManager = appState.settingsManager.hotkeySettingsManager + let hotkeysSettings = appState.settings.hotkeys if case .hidden = name { - if let hotkey = hotkeySettingsManager.hotkey(withAction: .toggleHiddenSection) { + if let hotkey = hotkeysSettings.hotkey(withAction: .toggleHiddenSection) { hotkey.keyCombination = keyCombination } } else if case .alwaysHidden = name { - if let hotkey = hotkeySettingsManager.hotkey(withAction: .toggleAlwaysHiddenSection) { + if let hotkey = hotkeysSettings.hotkey(withAction: .toggleAlwaysHiddenSection) { hotkey.keyCombination = keyCombination } } From 78b8c79dbe21a748f1d2cc436442779a171e7c47 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Mon, 7 Jul 2025 21:04:33 -0600 Subject: [PATCH 30/80] Rework `IceBarColorManager` --- Ice/MenuBar/IceBar/IceBarColorManager.swift | 70 ++++++++++++++------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/Ice/MenuBar/IceBar/IceBarColorManager.swift b/Ice/MenuBar/IceBar/IceBarColorManager.swift index 566589f03..0542e9f02 100644 --- a/Ice/MenuBar/IceBar/IceBarColorManager.swift +++ b/Ice/MenuBar/IceBar/IceBarColorManager.swift @@ -3,8 +3,8 @@ // Ice // -import Cocoa import Combine +import SwiftUI final class IceBarColorManager: ObservableObject { private struct WindowImageInfo { @@ -43,23 +43,39 @@ final class IceBarColorManager: ObservableObject { } .store(in: &c) - Publishers.CombineLatest( - iceBarPanel.publisher(for: \.frame), - iceBarPanel.publisher(for: \.isVisible) - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] frame, isVisible in - guard - let self, - let screen = iceBarPanel.screen, - isVisible, - screen == .main - else { - return + iceBarPanel.publisher(for: \.isVisible) + .receive(on: DispatchQueue.main) + .sink { [weak self, weak iceBarPanel] isVisible in + guard + let self, + let iceBarPanel, + let screen = iceBarPanel.screen, + isVisible, + screen == .main + else { + return + } + updateColorInfo(with: iceBarPanel.frame, screen: screen) } - updateColorInfo(with: frame, screen: screen) - } - .store(in: &c) + .store(in: &c) + + iceBarPanel.publisher(for: \.frame) + .receive(on: DispatchQueue.main) + .sink { [weak self, weak iceBarPanel] frame in + guard + let self, + let iceBarPanel, + let screen = iceBarPanel.screen, + iceBarPanel.isVisible, + screen == .main + else { + return + } + withAnimation(.interactiveSpring) { + self.updateColorInfo(with: frame, screen: screen) + } + } + .store(in: &c) Publishers.Merge4( NSWorkspace.shared.notificationCenter @@ -87,7 +103,9 @@ final class IceBarColorManager: ObservableObject { } updateWindowImageInfo(for: screen) if iceBarPanel.isVisible { - updateColorInfo(with: iceBarPanel.frame, screen: screen) + withAnimation(.interactiveSpring) { + self.updateColorInfo(with: iceBarPanel.frame, screen: screen) + } } } .store(in: &c) @@ -101,10 +119,16 @@ final class IceBarColorManager: ObservableObject { let displayID = screen.displayID if #available(macOS 26.0, *) { - if let window = WindowInfo.getWallpaperWindow(from: windows, for: displayID) { - let bounds = with(window.frame) { $0.size.height = 1 } - if let image = ScreenCapture.captureWindow(window.windowID, screenBounds: bounds, option: .nominalResolution) { - windowImageInfo = WindowImageInfo(image: image, source: .desktopWallpaper) + if + let menuBarWindow = WindowInfo.getMenuBarWindow(from: windows, for: displayID), + let wallpaperWindow = WindowInfo.getWallpaperWindow(from: windows, for: displayID) + { + let bounds = with(wallpaperWindow.frame) { $0.size.height = 1 } + let windowIDs = [menuBarWindow.windowID, wallpaperWindow.windowID] + if let image = ScreenCapture.captureWindows(windowIDs, screenBounds: bounds, option: .nominalResolution) { + // Just use `menuBarWindow` as the source for now, regardless + // of whether it contributes to the capture. + windowImageInfo = WindowImageInfo(image: image, source: .menuBarWindow) } else { windowImageInfo = nil } @@ -123,7 +147,6 @@ final class IceBarColorManager: ObservableObject { private func updateColorInfo(with frame: CGRect, screen: NSScreen) { guard let windowImageInfo else { - colorInfo = nil return } @@ -141,7 +164,6 @@ final class IceBarColorManager: ObservableObject { let croppedImage = image.cropping(to: cropRect), let averageColor = croppedImage.averageColor() else { - colorInfo = nil return } From bd496032aaf2a258b99a594968f81979f6d3b130 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Wed, 9 Jul 2025 06:30:12 -0600 Subject: [PATCH 31/80] Introduce `MenuBarItemSourceCache` This cache essentially allows us to determine the true `ownerPID` for menu bar items, since the process that owns all menu bar items in macOS Tahoe is the Control Center. We're using `sourcePID` to refer to this separately, since `ownerPID` could still be useful in some way. NOTE: A bunch of other changes got mixed into this commit, and I didn't realize until it was too deep in the commit list to fix. Here are some of the more notable things that were added, aside from `MenuBarItemSourceCache`: - Refactor `Bridging` APIs - Update `MenuBarItem` implementation - Replace `MenuBarItemInfo` and `MenuBarItemLegacyInfo` with `MenuBarItemTag` (essentially the original `MenuBarItemInfo`, but using `sourcePID` instead of `ownerPID`) - Improve `MenuBarItem` image caching - Change some documentation wording - Misc UI changes --- Ice/Bridging/Bridging.swift | 405 +++++++------- Ice/Bridging/Shims.swift | 17 +- Ice/Events/EventManager.swift | 22 +- Ice/Hotkeys/Hotkey.swift | 8 +- Ice/Hotkeys/HotkeyAction.swift | 8 +- Ice/Main/AppDelegate.swift | 8 +- Ice/Main/AppState.swift | 37 +- .../Appearance/MenuBarOverlayPanel.swift | 6 +- Ice/MenuBar/ControlItem/ControlItem.swift | 243 ++++++--- Ice/MenuBar/IceBar/IceBar.swift | 36 +- Ice/MenuBar/IceBar/IceBarColorManager.swift | 49 +- Ice/MenuBar/LayoutBar/LayoutBarItemView.swift | 34 +- .../LayoutBar/LayoutBarPaddingView.swift | 2 +- Ice/MenuBar/MenuBarItems/MenuBarItem.swift | 293 ++++++---- .../MenuBarItems/MenuBarItemImageCache.swift | 275 ++++++---- .../MenuBarItems/MenuBarItemManager.swift | 502 +++++++++--------- .../MenuBarItems/MenuBarItemSourceCache.swift | 175 ++++++ ...BarItemInfo.swift => MenuBarItemTag.swift} | 149 +++--- Ice/MenuBar/MenuBarManager.swift | 22 +- Ice/MenuBar/MenuBarSection.swift | 125 ++--- Ice/MenuBar/Search/MenuBarSearchPanel.swift | 84 +-- .../Spacing/MenuBarItemSpacingManager.swift | 2 +- ...ionsManager.swift => AppPermissions.swift} | 16 +- Ice/Permissions/Permission.swift | 5 + Ice/Permissions/PermissionsView.swift | 2 +- Ice/Permissions/PermissionsWindow.swift | 2 +- .../SettingsPanes/AdvancedSettingsPane.swift | 4 +- Ice/Utilities/Constants.swift | 17 +- Ice/Utilities/Defaults.swift | 1 + Ice/Utilities/Extensions.swift | 120 ++++- ...MigrationManager.swift => Migration.swift} | 83 ++- Ice/Utilities/Predicates.swift | 18 +- Ice/Utilities/ScreenCapture.swift | 10 +- Ice/Utilities/StatusItemDefaults.swift | 58 -- .../{TaskTimeout.swift => TaskHelpers.swift} | 46 +- Ice/Utilities/WindowInfo.swift | 232 ++------ 36 files changed, 1697 insertions(+), 1419 deletions(-) create mode 100644 Ice/MenuBar/MenuBarItems/MenuBarItemSourceCache.swift rename Ice/MenuBar/MenuBarItems/{MenuBarItemInfo.swift => MenuBarItemTag.swift} (54%) rename Ice/Permissions/{PermissionsManager.swift => AppPermissions.swift} (84%) rename Ice/Utilities/{MigrationManager.swift => Migration.swift} (85%) delete mode 100644 Ice/Utilities/StatusItemDefaults.swift rename Ice/Utilities/{TaskTimeout.swift => TaskHelpers.swift} (70%) diff --git a/Ice/Bridging/Bridging.swift b/Ice/Bridging/Bridging.swift index a30d9333f..6cbeb7b48 100644 --- a/Ice/Bridging/Bridging.swift +++ b/Ice/Bridging/Bridging.swift @@ -6,7 +6,9 @@ import Cocoa import OSLog -/// A namespace for bridged functionality. +// MARK: - Bridging + +/// A namespace for bridged APIs. enum Bridging { private static let mainConnectionID = CGSMainConnectionID() private static let logger = Logger(category: "Bridging") @@ -15,13 +17,11 @@ enum Bridging { // MARK: - CGSConnection extension Bridging { - /// Sets a value for the given key in the app's connection to - /// the window server. + /// Sets the value for a property in the app's window server connection. /// /// - Parameters: - /// - value: The value to set for `key`. - /// - key: A key associated with the app's connection to the - /// window server. + /// - value: A value to set for `key`. + /// - key: A key for a property in the app's window server connection. static func setConnectionProperty(_ value: Any?, forKey key: String) { let result = CGSSetConnectionProperty( mainConnectionID, @@ -34,11 +34,9 @@ extension Bridging { } } - /// Returns the value for the given key in the app's connection - /// to the window server. + /// Returns the value for a property in the app's window server connection. /// - /// - Parameter key: A key associated with the app's connection - /// to the window server. + /// - Parameter key: A key for a property in the app's window server connection. static func getConnectionProperty(forKey key: String) -> Any? { var value: Unmanaged? let result = CGSCopyConnectionProperty( @@ -54,31 +52,101 @@ extension Bridging { } } +// MARK: - CGSEvent + +extension Bridging { + /// Returns a Boolean value indicating whether the given process is + /// unresponsive. + /// + /// - Parameter pid: An identifier for a process. + static func isProcessUnresponsive(_ pid: pid_t) -> Bool { + var psn = ProcessSerialNumber() + let result = GetProcessForPID(pid, &psn) + guard result == noErr else { + logger.error("GetProcessForPID failed with error \(result, privacy: .public)") + return false + } + return CGSEventIsAppUnresponsive(mainConnectionID, &psn) + } +} + +// MARK: - CGSSpace + +extension Bridging { + /// Returns the identifier for the active space. + static func getActiveSpaceID() -> CGSSpaceID { + return CGSGetActiveSpace(mainConnectionID) + } + + /// Returns the identifier for the current space on the given display. + /// + /// - Parameter displayID: An identifier for a display. + static func getCurrentSpaceID(for displayID: CGDirectDisplayID) -> CGSSpaceID? { + guard + let uuid = CGDisplayCreateUUIDFromDisplayID(displayID), + let uuidString = CFUUIDCreateString(nil, uuid.takeRetainedValue()) + else { + logger.error("Failed to create UUID for display \(displayID, privacy: .public)") + return nil + } + return CGSManagedDisplayGetCurrentSpace(mainConnectionID, uuidString) + } + + /// Returns a list of identifiers for the spaces that contain the + /// given window. + /// + /// - Parameters: + /// - windowID: An identifier for a window. + /// - visibleSpacesOnly: A Boolean value that determines whether + /// the returned list should only include visible spaces. + /// The default value is `false`. + static func getSpaceList(for windowID: CGWindowID, visibleSpacesOnly: Bool = false) -> [CGSSpaceID] { + let mask: CGSSpaceMask = visibleSpacesOnly ? .allVisibleSpacesMask : .allSpacesMask + guard let spaces = CGSCopySpacesForWindows(mainConnectionID, mask, [windowID] as CFArray) else { + logger.error("CGSCopySpacesForWindows returned nil") + return [] + } + guard let list = spaces.takeRetainedValue() as? [CGSSpaceID] else { + logger.error("CGSCopySpacesForWindows returned array of unexpected type") + return [] + } + return list + } + + /// Returns a Boolean value that indicates whether the given space + /// is fullscreen. + /// + /// - Parameter spaceID: An identifier for a space. + static func isSpaceFullscreen(_ spaceID: CGSSpaceID) -> Bool { + let type = CGSSpaceGetType(mainConnectionID, spaceID) + return type == .fullscreen + } + + /// Returns a Boolean value that indicates whether the active space + /// is fullscreen. + static func isActiveSpaceFullscreen() -> Bool { + let activeSpaceID = getActiveSpaceID() + return isSpaceFullscreen(activeSpaceID) + } +} + // MARK: - CGSWindow extension Bridging { - /// Returns the bounds for the window with the specified identifier. + /// Returns the bounds for the given window. /// /// - Parameter windowID: An identifier for a window. static func getWindowBounds(for windowID: CGWindowID) -> CGRect? { var bounds = CGRect.zero - if #available(macOS 26.0, *) { - let result = CGSGetWindowBounds(mainConnectionID, windowID, &bounds) - guard result == .success else { - logger.error("CGSGetWindowBounds failed with error \(result.logString, privacy: .public)") - return nil - } - } else { - let result = CGSGetScreenRectForWindow(mainConnectionID, windowID, &bounds) - guard result == .success else { - logger.error("CGSGetScreenRectForWindow failed with error \(result.logString, privacy: .public)") - return nil - } + let result = CGSGetWindowBounds(mainConnectionID, windowID, &bounds) + guard result == .success else { + logger.error("CGSGetWindowBounds failed with error \(result.logString, privacy: .public)") + return nil } return bounds } - /// Returns the level for the window with the specified identifier. + /// Returns the level for the given window. /// /// - Parameter windowID: An identifier for a window. static func getWindowLevel(for windowID: CGWindowID) -> CGWindowLevel? { @@ -91,263 +159,212 @@ extension Bridging { return level } - /// Returns a Boolean value that indicates whether the window - /// with the given identifier is on the specified space. + /// Returns a Boolean value that indicates whether the given window + /// is on the given space. /// /// - Parameters: /// - windowID: An identifier for a window. /// - spaceID: An identifier for a space. static func isWindowOnSpace(_ windowID: CGWindowID, _ spaceID: CGSSpaceID) -> Bool { - let list = getSpaceList(for: windowID, option: .allSpaces) + let list = getSpaceList(for: windowID, visibleSpacesOnly: false) return list.contains(spaceID) } - /// Returns a Boolean value that indicates whether the window - /// with the given identifier is on the current active space. + /// Returns a Boolean value that indicates whether the given window + /// is on the active space. /// /// - Parameter windowID: An identifier for a window. static func isWindowOnActiveSpace(_ windowID: CGWindowID) -> Bool { - let spaceID = getActiveSpaceID() - return isWindowOnSpace(windowID, spaceID) + let activeSpaceID = getActiveSpaceID() + return isWindowOnSpace(windowID, activeSpaceID) } - /// Returns a Boolean value that indicates whether the window - /// with the given identifier is on the specified display. + /// Returns a Boolean value that indicates whether the given window + /// intersects the given display bounds. /// /// - Parameters: /// - windowID: An identifier for a window. - /// - displayID: An identifier for a display. - static func isWindowOnDisplay(_ windowID: CGWindowID, _ displayID: CGDirectDisplayID) -> Bool { + /// - displayBounds: The bounds of a display. + static func windowIntersectsDisplayBounds(_ windowID: CGWindowID, _ displayBounds: CGRect) -> Bool { if let windowBounds = getWindowBounds(for: windowID) { - let displayBounds = CGDisplayBounds(displayID) return displayBounds.intersects(windowBounds) } return false } -} -// MARK: Private Window List Helpers -extension Bridging { - private static func getWindowCount() -> Int { + /// Returns a Boolean value that indicates whether the given window + /// is on the specified display. + /// + /// - Parameters: + /// - windowID: An identifier for a window. + /// - displayID: An identifier for a display. + static func isWindowOnDisplay(_ windowID: CGWindowID, _ displayID: CGDirectDisplayID) -> Bool { + let displayBounds = CGDisplayBounds(displayID) + return windowIntersectsDisplayBounds(windowID, displayBounds) + } + + // MARK: Private Window List Helpers + + private static func getFullWindowCount() -> Int32 { var count: Int32 = 0 let result = CGSGetWindowCount(mainConnectionID, 0, &count) if result != .success { logger.error("CGSGetWindowCount failed with error \(result.logString, privacy: .public)") } - return Int(count) + return count } - private static func getOnScreenWindowCount() -> Int { + private static func getOnScreenWindowCount() -> Int32 { var count: Int32 = 0 let result = CGSGetOnScreenWindowCount(mainConnectionID, 0, &count) if result != .success { logger.error("CGSGetOnScreenWindowCount failed with error \(result.logString, privacy: .public)") } - return Int(count) + return count } - private static func getWindowList() -> [CGWindowID] { - let windowCount = getWindowCount() - var list = [CGWindowID](repeating: 0, count: windowCount) - var realCount: Int32 = 0 - let result = CGSGetWindowList( - mainConnectionID, - 0, - Int32(windowCount), - &list, - &realCount - ) + private static func getFullWindowList() -> [CGWindowID] { + let count = getFullWindowCount() + var list = [CGWindowID](repeating: 0, count: Int(count)) + var outCount: Int32 = 0 + let result = CGSGetWindowList(mainConnectionID, 0, count, &list, &outCount) guard result == .success else { logger.error("CGSGetWindowList failed with error \(result.logString, privacy: .public)") return [] } - return [CGWindowID](list[.. [CGWindowID] { - let windowCount = getOnScreenWindowCount() - var list = [CGWindowID](repeating: 0, count: windowCount) - var realCount: Int32 = 0 - let result = CGSGetOnScreenWindowList( - mainConnectionID, - 0, - Int32(windowCount), - &list, - &realCount - ) + let count = getOnScreenWindowCount() + var list = [CGWindowID](repeating: 0, count: Int(count)) + var outCount: Int32 = 0 + let result = CGSGetOnScreenWindowList(mainConnectionID, 0, count, &list, &outCount) guard result == .success else { logger.error("CGSGetOnScreenWindowList failed with error \(result.logString, privacy: .public)") return [] } - return [CGWindowID](list[.. [CGWindowID] { - let windowCount = getWindowCount() - var list = [CGWindowID](repeating: 0, count: windowCount) - var realCount: Int32 = 0 - let result = CGSGetProcessMenuBarWindowList( - mainConnectionID, - 0, - Int32(windowCount), - &list, - &realCount - ) + private static func getFullMenuBarWindowList() -> [CGWindowID] { + let count = getFullWindowCount() + var list = [CGWindowID](repeating: 0, count: Int(count)) + var outCount: Int32 = 0 + let result = CGSGetProcessMenuBarWindowList(mainConnectionID, 0, count, &list, &outCount) guard result == .success else { logger.error("CGSGetProcessMenuBarWindowList failed with error \(result.logString, privacy: .public)") return [] } - return list[.. [CGWindowID] { - let onScreenList = Set(getOnScreenWindowList()) - return getMenuBarItemWindowList().filter(onScreenList.contains) - } -} + // MARK: Public Window List API -// MARK: Public Window List API -extension Bridging { /// Options that specify the identifiers in a window list. struct WindowListOption: OptionSet { let rawValue: Int - /// Specifies windows that are currently on-screen. + /// Specifies windows that are currently on screen. static let onScreen = WindowListOption(rawValue: 1 << 0) - /// Specifies windows that represent items in the menu bar. - static let menuBarItems = WindowListOption(rawValue: 1 << 1) + /// Specifies windows on the currently active space. + static let activeSpace = WindowListOption(rawValue: 1 << 1) + } + + /// Options that specify the identifiers in a menu bar window list. + struct MenuBarWindowListOption: OptionSet { + let rawValue: Int + + /// Specifies windows that are currently on screen. + static let onScreen = MenuBarWindowListOption(rawValue: 1 << 0) /// Specifies windows on the currently active space. - static let activeSpace = WindowListOption(rawValue: 1 << 2) + static let activeSpace = MenuBarWindowListOption(rawValue: 1 << 1) + + /// Specifies only windows that represent menu bar items. + static let itemsOnly = MenuBarWindowListOption(rawValue: 1 << 2) } - /// Returns a list of window identifiers using the given options. + /// Returns a list of window identifiers. /// /// - Parameter option: Options that filter the returned list. /// Pass an empty option set to return all available windows. static func getWindowList(option: WindowListOption = []) -> [CGWindowID] { - let list = if option.contains(.menuBarItems) { - if option.contains(.onScreen) { - getOnScreenMenuBarItemWindowList() - } else { - getMenuBarItemWindowList() - } - } else if option.contains(.onScreen) { + let list = if option.contains(.onScreen) { getOnScreenWindowList() } else { - getWindowList() + getFullWindowList() } - return if option.contains(.activeSpace) { - list.filter(isWindowOnActiveSpace) - } else { - list + if option.contains(.activeSpace) { + let activeSpaceID = getActiveSpaceID() + return list.filter { windowID in + isWindowOnSpace(windowID, activeSpaceID) + } } - } -} - -// MARK: - CGSSpace - -extension Bridging { - /// Options that specify the identifiers in a space list. - enum SpaceListOption { - /// Specifies all available spaces. - case allSpaces - - /// Specifies visible spaces. - case visibleSpaces - } - - /// Returns the identifier for the current active space. - static func getActiveSpaceID() -> CGSSpaceID { - return CGSGetActiveSpace(mainConnectionID) + return list } - /// Returns the identifier for the current space on the given - /// display. + /// Returns a list of window identifiers for the elements of + /// the menu bar. /// - /// - Parameter displayID: An identifier for a display. - static func getCurrentSpaceID(for displayID: CGDirectDisplayID) -> CGSSpaceID? { - guard - let uuid = CGDisplayCreateUUIDFromDisplayID(displayID), - let uuidString = CFUUIDCreateString(nil, uuid.takeRetainedValue()) - else { - return nil - } - return CGSManagedDisplayGetCurrentSpace(mainConnectionID, uuidString) - } + /// - Parameter option: Options that filter the returned list. + /// Pass an empty option set to return all available windows. + static func getMenuBarWindowList(option: MenuBarWindowListOption = []) -> [CGWindowID] { + var predicates = [(CGWindowID) -> Bool]() - /// Returns a list of identifiers for the spaces that contain - /// the given window. - /// - /// - Parameters: - /// - windowID: An identifier for a window. - /// - option: An option that filters the spaces included in - /// the returned list. - static func getSpaceList(for windowID: CGWindowID, option: SpaceListOption) -> [CGSSpaceID] { - let mask: CGSSpaceMask = switch option { - case .allSpaces: .allSpaces - case .visibleSpaces: .allVisibleSpaces - } - guard let spaces = CGSCopySpacesForWindows(mainConnectionID, mask, [windowID] as CFArray) else { - logger.error("CGSCopySpacesForWindows returned nil value") - return [] + if option.contains(.onScreen) { + let onScreenList = Set(getOnScreenWindowList()) + predicates.append { windowID in + onScreenList.contains(windowID) + } } - guard let list = spaces.takeRetainedValue() as? [CGSSpaceID] else { - logger.error("CGSCopySpacesForWindows returned array of unexpected type") - return [] + + if option.contains(.activeSpace) { + let activeSpaceID = getActiveSpaceID() + predicates.append { windowID in + isWindowOnSpace(windowID, activeSpaceID) + } } - return list - } - /// Returns a Boolean value that indicates whether the space - /// with the given identifier is fullscreen. - /// - /// - Parameter spaceID: An identifier for a space. - static func isSpaceFullscreen(_ spaceID: CGSSpaceID) -> Bool { - let type = CGSSpaceGetType(mainConnectionID, spaceID) - return type == .fullscreen - } + if option.contains(.itemsOnly) { + predicates.append { windowID in + getWindowLevel(for: windowID) != kCGMainMenuWindowLevel + } + } - /// Returns a Boolean value that indicates whether the current - /// active space is fullscreen. - static func isActiveSpaceFullscreen() -> Bool { - let spaceID = getActiveSpaceID() - return isSpaceFullscreen(spaceID) + return getFullMenuBarWindowList().filter { windowID in + predicates.allSatisfy { predicate in + predicate(windowID) + } + } } -} - -// MARK: - Process Responsivity -extension Bridging { - /// Constants that indicate the responsivity of a process. - enum Responsivity { - /// The process is known to be responsive. - case responsive - - /// The process is known to be unresponsive. - case unresponsive + // MARK: - CGWindowList Specific - /// The responsivity of the process is unknown. - case unknown - } - - /// Returns the responsivity of the given process. + /// Creates a `CFArray` containing the bit patterns of the given + /// window list. /// - /// - Parameter pid: An identifier for a process. - static func responsivity(for pid: pid_t) -> Responsivity { - var psn = ProcessSerialNumber() - let result = GetProcessForPID(pid, &psn) - guard result == noErr else { - logger.error("GetProcessForPID failed with error \(result, privacy: .public)") - return .unknown + /// Pass the returned array into one of the `CGWindowList` APIs + /// from `CoreGraphics`. + /// + /// - Parameter windowIDs: A list of window identifiers. If the + /// list is empty, or if none of its elements can represent a + /// valid bit pattern, this function returns `nil`. + /// + /// - Returns: A `CFArray` where each element is a memory address + /// with a bit pattern that matches an element from `windowIDs`, + /// or `nil` if the array cannot be created. + static func createCGWindowArray(with windowIDs: [CGWindowID]) -> CFArray? { + var pointers: [UnsafeRawPointer?] = windowIDs.compactMap { windowID in + UnsafeRawPointer(bitPattern: UInt(windowID)) } - if CGSEventIsAppUnresponsive(mainConnectionID, &psn) { - return .unresponsive + guard + !pointers.isEmpty, + let array = CFArrayCreate(nil, &pointers, pointers.count, nil) + else { + return nil } - return .responsive + return array } } diff --git a/Ice/Bridging/Shims.swift b/Ice/Bridging/Shims.swift index eae38b8db..0a1b57082 100644 --- a/Ice/Bridging/Shims.swift +++ b/Ice/Bridging/Shims.swift @@ -24,12 +24,12 @@ struct CGSSpaceMask: OptionSet { static let includesOthers = CGSSpaceMask(rawValue: 1 << 1) static let includesUser = CGSSpaceMask(rawValue: 1 << 2) - static let includesVisible = CGSSpaceMask(rawValue: 1 << 16) + static let visible = CGSSpaceMask(rawValue: 1 << 16) - static let currentSpace: CGSSpaceMask = [.includesUser, .includesCurrent] - static let otherSpaces: CGSSpaceMask = [.includesOthers, .includesCurrent] - static let allSpaces: CGSSpaceMask = [.includesUser, .includesOthers, .includesCurrent] - static let allVisibleSpaces: CGSSpaceMask = [.includesVisible, .allSpaces] + static let currentSpaceMask: CGSSpaceMask = [.includesUser, .includesCurrent] + static let otherSpacesMask: CGSSpaceMask = [.includesOthers, .includesCurrent] + static let allSpacesMask: CGSSpaceMask = [.includesUser, .includesOthers, .includesCurrent] + static let allVisibleSpacesMask: CGSSpaceMask = [.visible, .allSpacesMask] } // MARK: - CGSConnection Functions @@ -128,13 +128,6 @@ func CGSGetOnScreenWindowCount( _ outCount: inout Int32 ) -> CGError -@_silgen_name("CGSGetScreenRectForWindow") -func CGSGetScreenRectForWindow( - _ cid: CGSConnectionID, - _ wid: CGWindowID, - _ outRect: inout CGRect -) -> CGError - @_silgen_name("CGSGetWindowBounds") func CGSGetWindowBounds( _ cid: CGSConnectionID, diff --git a/Ice/Events/EventManager.swift b/Ice/Events/EventManager.swift index 8dc894d2e..c04eb0380 100644 --- a/Ice/Events/EventManager.swift +++ b/Ice/Events/EventManager.swift @@ -186,7 +186,7 @@ extension EventManager { return } - await targetSection.toggle() + targetSection.toggle() } } @@ -239,9 +239,9 @@ extension EventManager { // Get the window that the user has clicked into. guard let mouseLocation = MouseCursor.locationCoreGraphics, - let windowUnderMouse = WindowInfo.getOnScreenWindows(excludeDesktopWindows: false) + let windowUnderMouse = WindowInfo.getWindows(option: .onScreen) .filter({ $0.layer < CGWindowLevelForKey(.cursorWindow) }) - .first(where: { $0.frame.contains(mouseLocation) && $0.title?.isEmpty == false }), + .first(where: { $0.bounds.contains(mouseLocation) && $0.title?.isEmpty == false }), let owningApplication = windowUnderMouse.owningApplication else { return @@ -340,7 +340,7 @@ extension EventManager { if appState.settings.advanced.showAllSectionsOnUserDrag { for section in appState.menuBarManager.sections { - section.controlItem.state = .showItems + section.controlItem.state = .showSection } } } @@ -373,7 +373,7 @@ extension EventManager { guard isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen) else { return } - await hiddenSection.show() + hiddenSection.show() } } else { guard @@ -416,12 +416,10 @@ extension EventManager { let averageDelta = (event.scrollingDeltaX + event.scrollingDeltaY) / 2 - Task { - if averageDelta > 5 { - await hiddenSection.show() - } else if averageDelta < -5 { - hiddenSection.hide() - } + if averageDelta > 5 { + hiddenSection.show() + } else if averageDelta < -5 { + hiddenSection.hide() } } } @@ -486,7 +484,7 @@ extension EventManager { option: [.onScreen, .activeSpace] ) return menuBarItems.contains { item in - item.frame.contains(mouseLocation) + item.bounds.contains(mouseLocation) } } diff --git a/Ice/Hotkeys/Hotkey.swift b/Ice/Hotkeys/Hotkey.swift index 0266d8183..3b2d8918b 100644 --- a/Ice/Hotkeys/Hotkey.swift +++ b/Ice/Hotkeys/Hotkey.swift @@ -75,13 +75,11 @@ extension Hotkey { return nil } let registry = appState.settings.hotkeys.registry - let id = registry.register(hotkey: hotkey, eventKind: eventKind) { [weak appState] in - guard let appState else { + let id = registry.register(hotkey: hotkey, eventKind: eventKind) { [weak hotkey, weak appState] in + guard let hotkey, let appState else { return } - Task { - await hotkey.action.perform(appState: appState) - } + hotkey.action.perform(appState: appState) } guard let id else { return nil diff --git a/Ice/Hotkeys/HotkeyAction.swift b/Ice/Hotkeys/HotkeyAction.swift index ab4c02202..8c354d0bb 100644 --- a/Ice/Hotkeys/HotkeyAction.swift +++ b/Ice/Hotkeys/HotkeyAction.swift @@ -16,13 +16,13 @@ enum HotkeyAction: String, Codable, CaseIterable { case toggleApplicationMenus = "ToggleApplicationMenus" @MainActor - func perform(appState: AppState) async { + func perform(appState: AppState) { switch self { case .toggleHiddenSection: guard let section = appState.menuBarManager.section(withName: .hidden) else { return } - await section.toggle() + section.toggle() // Prevent the section from automatically rehiding after mouse movement. if !section.isHidden { appState.menuBarManager.showOnHoverAllowed = false @@ -31,13 +31,13 @@ enum HotkeyAction: String, Codable, CaseIterable { guard let section = appState.menuBarManager.section(withName: .alwaysHidden) else { return } - await section.toggle() + section.toggle() // Prevent the section from automatically rehiding after mouse movement. if !section.isHidden { appState.menuBarManager.showOnHoverAllowed = false } case .searchMenuBarItems: - await appState.menuBarManager.searchPanel.toggle() + appState.menuBarManager.searchPanel.toggle() case .enableIceBar: appState.settings.general.useIceBar.toggle() case .toggleApplicationMenus: diff --git a/Ice/Main/AppDelegate.swift b/Ice/Main/AppDelegate.swift index db0b2d108..1067a210a 100644 --- a/Ice/Main/AppDelegate.swift +++ b/Ice/Main/AppDelegate.swift @@ -40,15 +40,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Depending on the permissions state, either perform setup // or prompt to grant permissions. - switch appState.permissionsManager.permissionsState { + switch appState.permissions.permissionsState { case .hasAll: - appState.permissionsManager.logger.info("Passed all permissions checks") + appState.permissions.logger.info("Passed all permissions checks") appState.performSetup(hasPermissions: true) case .hasRequired: - appState.permissionsManager.logger.info("Passed required permissions checks") + appState.permissions.logger.info("Passed required permissions checks") appState.performSetup(hasPermissions: true) case .missing: - appState.permissionsManager.logger.info("Failed required permissions checks") + appState.permissions.logger.info("Failed required permissions checks") appState.performSetup(hasPermissions: false) } } diff --git a/Ice/Main/AppState.swift b/Ice/Main/AppState.swift index e5cb24fb0..1ceeac5ad 100644 --- a/Ice/Main/AppState.swift +++ b/Ice/Main/AppState.swift @@ -19,6 +19,9 @@ final class AppState: ObservableObject { /// Model for the app's settings. let settings = AppSettings() + /// Model for the app's permissions. + let permissions = AppPermissions() + /// Model for app-wide navigation. let navigationState = AppNavigationState() @@ -40,9 +43,6 @@ final class AppState: ObservableObject { /// Manager for events received by the app. let eventManager = EventManager() - /// Manager for app permissions. - let permissionsManager = PermissionsManager() - /// Manager for app updates. let updatesManager = UpdatesManager() @@ -58,16 +58,24 @@ final class AppState: ObservableObject { /// Setup actions, run once on first access. private lazy var setupActions: () = { logger.info("Running setup actions") - configureCancellables() - permissionsManager.stopAllChecks() + + permissions.stopAllChecks() + + if #available(macOS 26.0, *) { + MenuBarItemSourceCache.start(with: permissions) + } + + settings.performSetup(with: self) + menuBarManager.performSetup(with: self) appearanceManager.performSetup(with: self) eventManager.performSetup(with: self) - settings.performSetup(with: self) itemManager.performSetup(with: self) imageCache.performSetup(with: self) updatesManager.performSetup(with: self) userNotificationManager.performSetup(with: self) + + configureCancellables() }() /// Performs app state setup. @@ -142,10 +150,8 @@ final class AppState: ObservableObject { guard let self, shouldUpdate else { return } - Task.detached { - if ScreenCapture.cachedCheckPermissions(reset: true) { - await self.imageCache.updateCacheWithoutChecks(sections: MenuBarSection.Name.allCases) - } + Task { + await self.imageCache.updateCacheWithoutChecks(sections: MenuBarSection.Name.allCases) } } .store(in: &c) @@ -155,7 +161,7 @@ final class AppState: ObservableObject { self?.objectWillChange.send() } .store(in: &c) - permissionsManager.objectWillChange + permissions.objectWillChange .sink { [weak self] in self?.objectWillChange.send() } @@ -174,6 +180,15 @@ final class AppState: ObservableObject { cancellables = c } + func hasPermission(_ key: AppPermissions.PermissionKey) -> Bool { + switch key { + case .accessibility: + permissions.accessibility.hasPermission + case .screenRecording: + permissions.screenRecording.hasPermission + } + } + /// Returns a publisher for the window with the given identifier. func publisherForWindow(_ id: IceWindowIdentifier) -> some Publisher { return NSApp.publisher(for: \.windows).mergeMap { window in diff --git a/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift b/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift index 6d3b4eb9a..ca7696586 100644 --- a/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift +++ b/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift @@ -228,7 +228,7 @@ final class MenuBarOverlayPanel: NSPanel { // Must be run async, or this will not remove the flags. self.updateFlags.removeAll() } - let windows = WindowInfo.getOnScreenWindows() + let windows = WindowInfo.getWindows(option: .onScreen) guard let owningDisplay = self.validate(for: .updates, with: windows) else { return } @@ -299,7 +299,7 @@ final class MenuBarOverlayPanel: NSPanel { else { return } - let wallpaper = ScreenCapture.captureWindow(wallpaperWindow.windowID, screenBounds: menuBarWindow.frame) + let wallpaper = ScreenCapture.captureWindow(wallpaperWindow.windowID, screenBounds: menuBarWindow.bounds) if desktopWallpaper?.dataProvider?.data != wallpaper?.dataProvider?.data { desktopWallpaper = wallpaper } @@ -562,7 +562,7 @@ private final class MenuBarOverlayPanelContentView: NSView { return .zero } let totalWidth = items.reduce(into: 0) { width, item in - width += item.frame.width + width += item.bounds.width } var position = rect.maxX - totalWidth if shouldInset { diff --git a/Ice/MenuBar/ControlItem/ControlItem.swift b/Ice/MenuBar/ControlItem/ControlItem.swift index 3b8fd083c..1a752f30d 100644 --- a/Ice/MenuBar/ControlItem/ControlItem.swift +++ b/Ice/MenuBar/ControlItem/ControlItem.swift @@ -6,32 +6,52 @@ import Cocoa import Combine +// MARK: - ControlItem + /// A status item that controls a section in the menu bar. @MainActor final class ControlItem { - /// Possible identifiers for control items. + /// An identifier for a control item. enum Identifier: String, CaseIterable { - case iceIcon = "SItem" - case hidden = "HItem" - case alwaysHidden = "AHItem" - - /// Legacy menu bar info for the control item with this identifier. - var legacyInfo: MenuBarItemLegacyInfo { + /// The identifier for the control item for the visible section. + case visible = "Ice.ControlItem.Visible" + /// The identifier for the control item for the hidden section. + case hidden = "Ice.ControlItem.Hidden" + /// The identifier for the control item for the always-hidden section. + case alwaysHidden = "Ice.ControlItem.AlwaysHidden" + + /// A tag for the control item with this identifier. + var tag: MenuBarItemTag { switch self { - case .iceIcon: .iceIcon + case .visible: .visibleControlItem case .hidden: .hiddenControlItem case .alwaysHidden: .alwaysHiddenControlItem } } + + /// Returns the length associated with this identifier and + /// the given hiding state. + func length(for state: HidingState) -> CGFloat { + switch self { + case .visible: + Lengths.standard + case .hidden, .alwaysHidden: + switch state { + case .showSection: Lengths.standard + case .hideSection: Lengths.expanded + } + } + } } - /// Possible hiding states for control items. + /// A hiding state for a control item. enum HidingState { - case hideItems, showItems + case showSection + case hideSection } - /// Possible lengths for control items. - enum Lengths { + /// A namespace for control item lengths. + private enum Lengths { static let standard: CGFloat = NSStatusItem.variableLength static let expanded: CGFloat = 10_000 } @@ -44,39 +64,20 @@ final class ControlItem { /// Creates a new storage instance. @MainActor init(controlItem: ControlItem) { - let autosaveName = controlItem.identifier.rawValue - - if StatusItemDefaults[.preferredPosition, autosaveName] == nil { - // Ice icon and hidden control item should be added before - // existing items in the status bar. - switch controlItem.identifier { - case .iceIcon: - StatusItemDefaults[.preferredPosition, autosaveName] = 0 - case .hidden: - StatusItemDefaults[.preferredPosition, autosaveName] = 1 - case .alwaysHidden: - break - } - } - - if StatusItemDefaults[.visible, autosaveName] == nil { - // The status item should be visible by default. We change - // this after finishing setup, if needed. - StatusItemDefaults[.visible, autosaveName] = true - } + ControlItemDefaults.preflightSetup(for: controlItem) self.statusItem = NSStatusBar.system.statusItem(withLength: 0) - self.statusItem.autosaveName = autosaveName + self.statusItem.autosaveName = controlItem.identifier.rawValue if let button = statusItem.button { - // This could break in a new macOS release, but we need this constraint in order to be - // able to hide the control item when the `ShowSectionDividers` setting is disabled. A - // previous implementation used the status item's `isVisible` property, which was more - // robust, but would completely remove the control item. With the current set of - // features, we need to be able to accurately retrieve the items for each section, so - // we need the control item to always be present to act as a delimiter. The new solution - // is to remove the constraint that prevents status items from having a length of zero, - // then resize the content view. FIXME: Find a replacement for this. + // This could break in a new macOS release, but we need this constraint in order to + // be able to hide the status item when the `ShowSectionDividers` setting is disabled. + // A previous implementation used `statusItem.isVisible`, which was more robust, but + // would completely remove the status item. With the current set of features, we use + // the control item positions to determine the items in each section, so we need the + // status item to be present if its section is enabled. The new solution is to remove + // a constraint from the item's content view prevents it from having a length of zero. + // Then, we set the length. FIXME: Find a replacement for this. if let constraints = button.window?.contentView?.constraintsAffectingLayout(for: .horizontal), let constraint = constraints.first(where: Predicates.controlItemConstraint(button: button)) @@ -103,14 +104,14 @@ final class ControlItem { // Removing the status item has the unwanted side effect of // deleting the preferred position. Cache and restore it. let autosaveName = statusItem.autosaveName as String - let cached = StatusItemDefaults[.preferredPosition, autosaveName] + let cached = ControlItemDefaults[.preferredPosition, autosaveName] NSStatusBar.system.removeStatusItem(statusItem) - StatusItemDefaults[.preferredPosition, autosaveName] = cached + ControlItemDefaults[.preferredPosition, autosaveName] = cached } } /// The control item's hiding state (`@Published`). - @Published var state = HidingState.hideItems + @Published var state = HidingState.hideSection /// The control item's window (`@Published`). @Published private(set) var window: NSWindow? @@ -127,7 +128,7 @@ final class ControlItem { /// The control item's identifier. let identifier: Identifier - /// Storage for the control item's underlying status item. + /// Lazy storage for the control item's underlying status item. private lazy var storage = StatusItemStorage(controlItem: self) /// The shared app state. @@ -149,7 +150,7 @@ final class ControlItem { /// A Boolean value that indicates whether the control item serves as /// a divider between sections. var isSectionDivider: Bool { - identifier != .iceIcon + identifier != .visible } /// A Boolean value that indicates whether the control item is currently @@ -161,7 +162,7 @@ final class ControlItem { /// The corresponding section name for the control item. var sectionName: MenuBarSection.Name { switch identifier { - case .iceIcon: .visible + case .visible: .visible case .hidden: .hidden case .alwaysHidden: .alwaysHidden } @@ -203,7 +204,7 @@ final class ControlItem { let hotkeysSettings = appState.settings.hotkeys let hotkey: Hotkey? = switch identifier { - case .iceIcon: nil + case .visible: nil case .hidden: hotkeysSettings.hotkey(withAction: .toggleHiddenSection) case .alwaysHidden: hotkeysSettings.hotkey(withAction: .toggleAlwaysHiddenSection) } @@ -293,7 +294,7 @@ final class ControlItem { } .store(in: &c) - if identifier == .iceIcon { + if identifier == .visible { appState.settings.general.$showIceIcon .combineLatest(statusItem.publisher(for: \.isVisible)) .removeDuplicates { $0 == $1 } @@ -379,7 +380,7 @@ final class ControlItem { button.image = nil switch identifier { - case .iceIcon: + case .visible: updateStatusItemVisibility(true, state: state) updateButtonEnabledState(true) // Make sure button is enabled. @@ -387,8 +388,8 @@ final class ControlItem { // We can usually just create the image directly from the icon. var image = switch state { - case .hideItems: icon.hidden.nsImage(for: appState) - case .showItems: icon.visible.nsImage(for: appState) + case .showSection: icon.visible.nsImage(for: appState) + case .hideSection: icon.hidden.nsImage(for: appState) } if @@ -406,10 +407,7 @@ final class ControlItem { button.image = image case .hidden, .alwaysHidden: switch state { - case .hideItems: - updateStatusItemVisibility(true, state: state) - updateButtonEnabledState(false) // Keep button from highlighting. - case .showItems: + case .showSection: switch appState.settings.advanced.sectionDividerStyle { case .noDivider: updateStatusItemVisibility(false, state: state) @@ -428,9 +426,12 @@ final class ControlItem { ControlItemImage.builtin(.chevronLarge).nsImage(for: appState) case .alwaysHidden: ControlItemImage.builtin(.chevronSmall).nsImage(for: appState) - case .iceIcon: nil + case .visible: nil } } + case .hideSection: + updateStatusItemVisibility(true, state: state) + updateButtonEnabledState(false) // Keep button from highlighting. } } } @@ -447,23 +448,21 @@ final class ControlItem { guard let appState else { return } + if isVisible { - statusItem.length = switch identifier { - case .iceIcon: Lengths.standard - case .hidden, .alwaysHidden: - switch state { - case .hideItems: Lengths.expanded - case .showItems: Lengths.standard - } - } constraint?.isActive = true + statusItem.length = identifier.length(for: state) } else { - let wider = appState.isDraggingMenuBarItem && appState.settings.advanced.showAllSectionsOnUserDrag - statusItem.length = wider ? 3 : 0 + let showOnDrag = appState.settings.advanced.showAllSectionsOnUserDrag + let isDragging = appState.isDraggingMenuBarItem + + let shouldShow = showOnDrag && isDragging + constraint?.isActive = false + statusItem.length = shouldShow ? 3 : 0 + if let window { - var size = window.frame.size - size.width = wider ? 3 : 1 + let size = with(window.frame.size) { $0.width = shouldShow ? 3 : 1 } window.setContentSize(size) } } @@ -485,9 +484,9 @@ final class ControlItem { // Setting `statusItem.isVisible` to `false` has the unwanted side // effect of deleting the preferred position. Cache and restore it. let autosaveName = statusItem.autosaveName as String - let cached = StatusItemDefaults[.preferredPosition, autosaveName] + let cached = ControlItemDefaults[.preferredPosition, autosaveName] statusItem.isVisible = false - StatusItemDefaults[.preferredPosition, autosaveName] = cached + ControlItemDefaults[.preferredPosition, autosaveName] = cached } /// Updates the enabled state of the status item's button. @@ -536,9 +535,7 @@ final class ControlItem { return } - Task { - await targetSection.toggle() - } + targetSection.toggle() case .rightMouseUp: showMenu() default: @@ -658,9 +655,7 @@ final class ControlItem { guard let section = menuItem.representedObject as? MenuBarSection else { return } - Task { - await section.toggle() - } + section.toggle() } /// Opens the menu bar search panel. @@ -671,9 +666,7 @@ final class ControlItem { else { return } - Task { - await appState.menuBarManager.searchPanel.show(on: screen) - } + appState.menuBarManager.searchPanel.show(on: screen) } /// Opens the settings window and checks for app updates. @@ -684,3 +677,93 @@ final class ControlItem { appState.updatesManager.checkForUpdates() } } + +// MARK: - ControlItemDefaults + +/// Proxy getters and setters for a control item's stored +/// UserDefaults values. +enum ControlItemDefaults { + /// Accesses the value associated with the specified key + /// and autosave name. + static subscript(key: Key, autosaveName: String) -> Value? { + get { + let stringKey = key.stringKey(for: autosaveName) + return UserDefaults.standard.object(forKey: stringKey) as? Value + } + set { + let stringKey = key.stringKey(for: autosaveName) + return UserDefaults.standard.set(newValue, forKey: stringKey) + } + } + + /// Migrates the given control item defaults key from an old + /// autosave name to a new autosave name. + static func migrate(key: Key, from oldAutosaveName: String, to newAutosaveName: String) { + guard newAutosaveName != oldAutosaveName else { + return + } + Self[key, newAutosaveName] = Self[key, oldAutosaveName] + Self[key, oldAutosaveName] = nil + } + + /// Performs some initial required setup work before the + /// creation of a control item. + fileprivate static func preflightSetup(for controlItem: ControlItem) { + let autosaveName = controlItem.identifier.rawValue + + // Visible and hidden control items should be added before + // existing items in the status bar. + if ControlItemDefaults[.preferredPosition, autosaveName] == nil { + switch controlItem.identifier { + case .visible: + ControlItemDefaults[.preferredPosition, autosaveName] = 0 + case .hidden: + ControlItemDefaults[.preferredPosition, autosaveName] = 1 + case .alwaysHidden: + break + } + } + + // The control item should be visible by default. We change + // this after finishing setup, if needed. + if ControlItemDefaults[.visible, autosaveName] == nil { + ControlItemDefaults[.visible, autosaveName] = true + } + if + #available(macOS 26.0, *), + ControlItemDefaults[.visibleCC, autosaveName] == nil + { + ControlItemDefaults[.visibleCC, autosaveName] = true + } + } +} + +// MARK: - ControlItemDefaults.Key + +extension ControlItemDefaults { + /// Keys used to look up UserDefaults values for control items. + struct Key { + /// The raw value of the key. + let rawValue: String + + /// Returns the full string key for the given autosave name. + func stringKey(for autosaveName: String) -> String { + "NSStatusItem \(rawValue) \(autosaveName)" + } + } +} + +// MARK: ControlItemDefaults.Key +extension ControlItemDefaults.Key { + /// String key: "NSStatusItem Preferred Position autosaveName" + static let preferredPosition = Self(rawValue: "Preferred Position") +} + +// MARK: ControlItemDefaults.Key +extension ControlItemDefaults.Key { + /// String key: "NSStatusItem Visible autosaveName" + static let visible = Self(rawValue: "Visible") + + /// String key: "NSStatusItem VisibleCC autosaveName" + static let visibleCC = Self(rawValue: "VisibleCC") +} diff --git a/Ice/MenuBar/IceBar/IceBar.swift b/Ice/MenuBar/IceBar/IceBar.swift index b5efb2470..b7801e9b5 100644 --- a/Ice/MenuBar/IceBar/IceBar.swift +++ b/Ice/MenuBar/IceBar/IceBar.swift @@ -140,10 +140,10 @@ final class IceBarPanel: NSPanel { guard lowerBound <= upperBound, - let iceIcon = appState.itemManager.itemCache.allItems.first(matching: .iceIcon), - // Bridging API is more reliable than ControlItem.frame - // in some cases (like if the control item is offscreen). - let itemBounds = Bridging.getWindowBounds(for: iceIcon.windowID) + let controlItem = appState.itemManager.itemCache.allItems.first(matching: .visibleControlItem), + // Bridging API is more reliable than controlItem.frame in some + // cases (like if the item is offscreen). + let itemBounds = Bridging.getWindowBounds(for: controlItem.windowID) else { return originForRightOfScreen } @@ -163,13 +163,18 @@ final class IceBarPanel: NSPanel { } // IMPORTANT: We must set the navigation state and current section - // before updating the cache. + // before updating the caches. appState.navigationState.isIceBarPresented = true currentSection = section - await appState.itemManager.cacheItemsIfNeeded() + var managedItems = appState.itemManager.itemCache.managedItems(for: section) - if ScreenCapture.cachedCheckPermissions() { + if managedItems.isEmpty { + await appState.itemManager.cacheItemsIfNeeded() + managedItems = appState.itemManager.itemCache.managedItems(for: section) + } + + if managedItems.contains(where: { appState.imageCache.images[$0.tag] == nil }) { await appState.imageCache.updateCache() } @@ -283,7 +288,7 @@ private struct IceBarContentView: View { } private var contentHeight: CGFloat? { - guard let menuBarHeight = imageCache.menuBarHeight ?? screen.getMenuBarHeight() else { + guard let menuBarHeight = screen.getMenuBarHeight() else { return nil } if configuration.shapeKind != .none && configuration.isInset && screen.hasNotch { @@ -325,7 +330,7 @@ private struct IceBarContentView: View { } } .padding(5) - .frame(maxWidth: imageCache.screen?.frame.width) + .frame(maxWidth: screen.frame.width) .fixedSize() .onFrameChange(update: $frame) } @@ -368,7 +373,7 @@ private struct IceBarContentView: View { } } } - .environment(\.isScrollEnabled, frame.width == imageCache.screen?.frame.width) + .environment(\.isScrollEnabled, frame.width == screen.frame.width) .defaultScrollAnchor(.trailing) .scrollIndicatorsFlash(trigger: scrollIndicatorsFlashTrigger) .task { @@ -415,17 +420,10 @@ private struct IceBarItemView: View { } private var image: NSImage? { - guard - let image = imageCache.images[item.info], - let screen = imageCache.screen - else { + guard let cachedImage = imageCache.images[item.tag] else { return nil } - let size = CGSize( - width: CGFloat(image.width) / screen.backingScaleFactor, - height: CGFloat(image.height) / screen.backingScaleFactor - ) - return NSImage(cgImage: image, size: size) + return cachedImage.nsImage } var body: some View { diff --git a/Ice/MenuBar/IceBar/IceBarColorManager.swift b/Ice/MenuBar/IceBar/IceBarColorManager.swift index 0542e9f02..f6c4a6844 100644 --- a/Ice/MenuBar/IceBar/IceBarColorManager.swift +++ b/Ice/MenuBar/IceBar/IceBarColorManager.swift @@ -60,7 +60,7 @@ final class IceBarColorManager: ObservableObject { .store(in: &c) iceBarPanel.publisher(for: \.frame) - .receive(on: DispatchQueue.main) + .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) .sink { [weak self, weak iceBarPanel] frame in guard let self, @@ -103,7 +103,7 @@ final class IceBarColorManager: ObservableObject { } updateWindowImageInfo(for: screen) if iceBarPanel.isVisible { - withAnimation(.interactiveSpring) { + withAnimation { self.updateColorInfo(with: iceBarPanel.frame, screen: screen) } } @@ -115,34 +115,27 @@ final class IceBarColorManager: ObservableObject { } private func updateWindowImageInfo(for screen: NSScreen) { - let windows = WindowInfo.getOnScreenWindows(excludeDesktopWindows: false) + let windows = WindowInfo.getWindows(option: .onScreen) let displayID = screen.displayID - if #available(macOS 26.0, *) { - if - let menuBarWindow = WindowInfo.getMenuBarWindow(from: windows, for: displayID), - let wallpaperWindow = WindowInfo.getWallpaperWindow(from: windows, for: displayID) - { - let bounds = with(wallpaperWindow.frame) { $0.size.height = 1 } - let windowIDs = [menuBarWindow.windowID, wallpaperWindow.windowID] - if let image = ScreenCapture.captureWindows(windowIDs, screenBounds: bounds, option: .nominalResolution) { - // Just use `menuBarWindow` as the source for now, regardless - // of whether it contributes to the capture. - windowImageInfo = WindowImageInfo(image: image, source: .menuBarWindow) - } else { - windowImageInfo = nil - } - } - } else { - if - let window = WindowInfo.getMenuBarWindow(from: windows, for: displayID), - let image = ScreenCapture.captureWindow(window.windowID, option: .nominalResolution) - { - windowImageInfo = WindowImageInfo(image: image, source: .menuBarWindow) - } else { - windowImageInfo = nil - } + guard + let menuBarWindow = WindowInfo.getMenuBarWindow(from: windows, for: displayID), + let wallpaperWindow = WindowInfo.getWallpaperWindow(from: windows, for: displayID) + else { + return + } + + let windowIDs = [menuBarWindow.windowID, wallpaperWindow.windowID] + let bounds = with(wallpaperWindow.bounds) { $0.size.height = 1 } + let option: CGWindowImageOption = .nominalResolution + + guard let image = ScreenCapture.captureWindows(windowIDs, screenBounds: bounds, option: option) else { + return } + + // Just use `menuBarWindow` as the source for now, regardless + // of whether it contributes to the capture. + windowImageInfo = WindowImageInfo(image: image, source: .menuBarWindow) } private func updateColorInfo(with frame: CGRect, screen: NSScreen) { @@ -157,7 +150,7 @@ final class IceBarColorManager: ObservableObject { let percentage = ((frame.midX - insetScreenFrame.minX) / insetScreenFrame.width).clamped(to: 0...1) let cropRect = CGRect(x: imageBounds.width * percentage, y: 0, width: 0, height: 1) - .insetBy(dx: -50, dy: 0) + .insetBy(dx: -150, dy: 0) .intersection(imageBounds) guard diff --git a/Ice/MenuBar/LayoutBar/LayoutBarItemView.swift b/Ice/MenuBar/LayoutBar/LayoutBarItemView.swift index 74cb6f819..17b602e21 100644 --- a/Ice/MenuBar/LayoutBar/LayoutBarItemView.swift +++ b/Ice/MenuBar/LayoutBar/LayoutBarItemView.swift @@ -32,17 +32,10 @@ final class LayoutBarItemView: NSView { var hasContainer = false /// The image displayed inside the view. - private var image: NSImage? { + private var cachedImage: MenuBarItemImageCache.CapturedImage? { didSet { - if - let image, - let screen = appState?.imageCache.screen - { - let size = CGSize( - width: image.size.width / screen.backingScaleFactor, - height: image.size.height / screen.backingScaleFactor - ) - setFrameSize(size) + if let image = cachedImage { + setFrameSize(image.scaledSize) } else { setFrameSize(.zero) } @@ -72,7 +65,7 @@ final class LayoutBarItemView: NSView { self.appState = appState // set the frame to the full item frame size; the image will be centered when displayed - super.init(frame: CGRect(origin: .zero, size: item.frame.size)) + super.init(frame: CGRect(origin: .zero, size: item.bounds.size)) unregisterDraggedTypes() self.toolTip = item.displayName @@ -92,13 +85,10 @@ final class LayoutBarItemView: NSView { if let appState { appState.imageCache.$images .sink { [weak self] images in - guard - let self, - let cgImage = images[item.info] - else { + guard let self, let cachedImage = images[item.tag] else { return } - image = NSImage(cgImage: cgImage, size: CGSize(width: cgImage.width, height: cgImage.height)) + self.cachedImage = cachedImage } .store(in: &c) } @@ -123,13 +113,13 @@ final class LayoutBarItemView: NSView { override func draw(_ dirtyRect: NSRect) { if !isDraggingPlaceholder { - image?.draw( + cachedImage?.nsImage.draw( in: bounds, from: .zero, operation: .sourceOver, fraction: isEnabled ? 1.0 : 0.67 ) - if Bridging.responsivity(for: item.ownerPID) == .unresponsive { + if Bridging.isProcessUnresponsive(item.ownerPID) { let warningImage = NSImage.warning let width: CGFloat = 15 let scale = width / warningImage.size.width @@ -158,20 +148,18 @@ final class LayoutBarItemView: NSView { return } - guard Bridging.responsivity(for: item.ownerPID) != .unresponsive else { + guard !Bridging.isProcessUnresponsive(item.ownerPID) else { let alert = provideAlertForUnresponsiveItem() alert.runModal() return } + // Data doesn't matter, but we do need to set the type. let pasteboardItem = NSPasteboardItem() - // contents of the pasteboard item don't matter here, as all needed information - // is available directly from the dragging session; what matters is that the type - // is set to `layoutBarItem`, as that is what the layout bar registers for pasteboardItem.setData(Data(), forType: .layoutBarItem) let draggingItem = NSDraggingItem(pasteboardWriter: pasteboardItem) - draggingItem.setDraggingFrame(bounds, contents: image) + draggingItem.setDraggingFrame(bounds, contents: cachedImage?.nsImage) beginDraggingSession(with: [draggingItem], event: event, source: self) } diff --git a/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift b/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift index 1206517ea..bb2182958 100644 --- a/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift +++ b/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift @@ -133,7 +133,7 @@ final class LayoutBarPaddingView: NSView { try await Task.sleep(for: .milliseconds(25)) do { try await appState.itemManager.slowMove(item: item, to: destination) - appState.itemManager.removeTempShownItemFromCache(with: item.info) + appState.itemManager.removeTempShownItemFromCache(with: item.tag) } catch { Logger.general.error("Error moving menu bar item: \(error, privacy: .public)") let alert = NSAlert(error: error) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift index cd37a8f23..320e4e5d6 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift @@ -3,67 +3,72 @@ // Ice // +import AXSwift import Cocoa +import Combine // MARK: - MenuBarItem /// A representation of an item in the menu bar. -struct MenuBarItem { - /// The item's window. - let window: WindowInfo +struct MenuBarItem: CustomStringConvertible { + /// The tag associated with this item. + let tag: MenuBarItemTag - /// The legacy menu bar item info associated with this item. - let legacyInfo: MenuBarItemLegacyInfo + /// The item's window identifier. + let windowID: CGWindowID - /// The menu bar item info associated with this item. - let info: MenuBarItemInfo + /// The identifier of the process that owns the item. + let ownerPID: pid_t - /// The identifier of the item's window. - var windowID: CGWindowID { - window.windowID - } + /// The identifier of the process that created the item. + let sourcePID: pid_t? - /// The frame of the item's window. - var frame: CGRect { - window.frame - } + /// The item's bounds, specified in screen coordinates. + let bounds: CGRect - /// The title of the item's window. - var title: String? { - window.title - } + /// The item's window title. + let title: String? + + /// The name of the process that owns the item. + /// + /// This may have a value when ``owningApplication`` does not have + /// a localized name. + let ownerName: String? /// A Boolean value that indicates whether the item is on screen. - var isOnScreen: Bool { - window.isOnScreen - } + let isOnScreen: Bool /// A Boolean value that indicates whether the item can be moved. var isMovable: Bool { - legacyInfo.isMovable + tag.isMovable } /// A Boolean value that indicates whether the item can be hidden. var canBeHidden: Bool { - legacyInfo.canBeHidden + tag.canBeHidden } - /// The process identifier of the application that owns the item. - var ownerPID: pid_t { - window.ownerPID + /// A Boolean value that indicates whether the item is one of Ice's + /// control items. + var isControlItem: Bool { + tag.isControlItem } - /// The name of the application that owns the item. + /// The application that owns the item. /// - /// This may have a value when ``owningApplication`` does not have - /// a localized name. - var ownerName: String? { - window.ownerName + /// - Note: In macOS 26 Tahoe and later, this property always returns + /// the Control Center. To get the actual application that created + /// the item, use ``sourceApplication``. + var owningApplication: NSRunningApplication? { + NSRunningApplication(processIdentifier: ownerPID) } - /// The application that owns the item. - var owningApplication: NSRunningApplication? { - window.owningApplication + /// The application that created the item. + var sourceApplication: NSRunningApplication? { + guard let sourcePID else { + return nil + } + return NSRunningApplication(processIdentifier: sourcePID) } /// A name associated with the item that is suited for display. @@ -73,28 +78,37 @@ struct MenuBarItem { String(s).replacing(/([a-z])([A-Z])/) { $0.output.1 + " " + $0.output.2 } } + var fallback: String { + "Unknown" + } + var mappedTitle: String? { + title.flatMap { $0.starts(with: /Item-\d+/) ? fallback : $0 } + } var bestName: String { - var fallback: String { "Unknown" } - return if #available(macOS 26.0, *) { - title ?? ownerName ?? fallback + if isControlItem { + Constants.displayName + } else if let sourceApplication { + sourceApplication.localizedName ?? + sourceApplication.bundleIdentifier ?? + mappedTitle ?? + fallback } else if let owningApplication { owningApplication.localizedName ?? - ownerName ?? owningApplication.bundleIdentifier ?? - title ?? + mappedTitle ?? fallback } else { - ownerName ?? title ?? fallback + ownerName ?? mappedTitle ?? fallback } } - guard #unavailable(macOS 26.0), let title else { + guard let title else { return bestName } // Most items will use their computed "best name", but we need to // handle a few special cases for system items. - return switch legacyInfo.namespace { + return switch tag.namespace { case .passwords, .weather: // "PasswordsMenuBarExtra" -> "Passwords" // "WeatherMenu" -> "Weather" @@ -104,7 +118,7 @@ struct MenuBarItem { case .controlCenter where title == "WiFi": title case .controlCenter where title.hasPrefix("Hearing"): - // Title of this item was changed to "Hearing_GlowE" in macOS 15.4. + // Changed to "Hearing_GlowE" in macOS 15.4. String(toTitleCase(title).prefix { $0.isLetter || $0.isNumber }) case .systemUIServer where title.contains("TimeMachine"): // Sonoma: "TimeMachine.TMMenuExtraHost" @@ -120,27 +134,46 @@ struct MenuBarItem { } } - /// A Boolean value that indicates whether the item is currently - /// in the menu bar. - var isCurrentlyInMenuBar: Bool { - let list = Set(Bridging.getWindowList(option: .menuBarItems)) - return list.contains(windowID) + /// A textual representation of the item. + var description: String { + String(describing: tag) } /// A string to use for logging purposes. var logString: String { - "<\(legacyInfo) (windowID: \(windowID))>" + "<\(tag) (windowID: \(windowID))>" } - /// Creates a menu bar item from the given window. + /// Creates a menu bar item without checks. /// - /// This initializer does not perform any checks on the window to ensure that - /// it is a valid menu bar item window. Only call this initializer if you are - /// certain that the window is valid. + /// This initializer does not perform validity checks on its parameters. + /// Only call it if you are certain the window is a valid menu bar item. private init(uncheckedItemWindow itemWindow: WindowInfo) { - self.window = itemWindow - self.legacyInfo = MenuBarItemLegacyInfo(uncheckedItemWindow: itemWindow) - self.info = MenuBarItemInfo(windowID: itemWindow.windowID) + self.tag = MenuBarItemTag(uncheckedItemWindow: itemWindow) + self.windowID = itemWindow.windowID + self.ownerPID = itemWindow.ownerPID + self.sourcePID = itemWindow.ownerPID + self.bounds = itemWindow.bounds + self.title = itemWindow.title + self.ownerName = itemWindow.ownerName + self.isOnScreen = itemWindow.isOnScreen + } + + /// Creates a menu bar item without checks. + /// + /// This initializer does not perform validity checks on its parameters. + /// Only call it if you are certain the window is a valid menu bar item + /// and the source pid belongs to the application that created it. + @available(macOS 26.0, *) + private init(uncheckedItemWindow itemWindow: WindowInfo, sourcePID: pid_t?) { + self.tag = MenuBarItemTag(uncheckedItemWindow: itemWindow, sourcePID: sourcePID) + self.windowID = itemWindow.windowID + self.ownerPID = itemWindow.ownerPID + self.sourcePID = sourcePID + self.bounds = itemWindow.bounds + self.title = itemWindow.title + self.ownerName = itemWindow.ownerName + self.isOnScreen = itemWindow.isOnScreen } } @@ -151,7 +184,7 @@ extension MenuBarItem { struct ListOption: OptionSet { let rawValue: Int - /// Specifies menu bar items that are currently on-screen. + /// Specifies menu bar items that are currently on screen. static let onScreen = ListOption(rawValue: 1 << 0) /// Specifies menu bar items on the currently active space. @@ -166,46 +199,50 @@ extension MenuBarItem { /// - option: Options that filter the returned list. Pass an empty option set /// to return all available menu bar item windows. static func getMenuBarItemWindows(on display: CGDirectDisplayID? = nil, option: ListOption) -> [WindowInfo] { - var bridgingOption: Bridging.WindowListOption = .menuBarItems + var bridgingOption: Bridging.MenuBarWindowListOption = .itemsOnly + var displayBoundsPredicate: (CGWindowID) -> Bool = { _ in true } - var onScreenPredicate: (CGWindowID) -> Bool = { _ in true } - var activeSpacePredicate: (CGWindowID) -> Bool = { _ in true } - - if option.contains(.onScreen) { + if let display { bridgingOption.insert(.onScreen) - if let display { - let displayBounds = CGDisplayBounds(display) - onScreenPredicate = { windowID in - if let bounds = Bridging.getWindowBounds(for: windowID) { - return displayBounds.intersects(bounds) - } - return false - } + let displayBounds = CGDisplayBounds(display) + displayBoundsPredicate = { windowID in + Bridging.windowIntersectsDisplayBounds(windowID, displayBounds) } + } else if option.contains(.onScreen) { + bridgingOption.insert(.onScreen) } if option.contains(.activeSpace) { bridgingOption.insert(.activeSpace) - if let spaceID = display.flatMap(Bridging.getCurrentSpaceID) { - activeSpacePredicate = { windowID in - Bridging.isWindowOnSpace(windowID, spaceID) - } - } } - return Bridging.getWindowList(option: bridgingOption) - .compactMap { windowID in + return Bridging.getMenuBarWindowList(option: bridgingOption) + .reversed().compactMap { windowID in guard - onScreenPredicate(windowID), - activeSpacePredicate(windowID), + displayBoundsPredicate(windowID), let window = WindowInfo(windowID: windowID) else { return nil } return window } - .sorted { lhs, rhs in - lhs.frame.maxX < rhs.frame.maxX - } + } + + /// Creates and returns a list of menu bar items using experimental + /// source pid retrieval for macOS 26. + @available(macOS 26.0, *) + private static func getMenuBarItemsExperimental(on display: CGDirectDisplayID?, option: ListOption) -> [MenuBarItem] { + getMenuBarItemWindows(on: display, option: option).map { window in + let sourcePID = MenuBarItemSourceCache.getCachedPID(for: window) + return MenuBarItem(uncheckedItemWindow: window, sourcePID: sourcePID) + } + } + + /// Creates and returns a list of menu bar items, defaulting to the + /// legacy source pid behavior, prior to macOS 26. + private static func getMenuBarItemsLegacyMethod(on display: CGDirectDisplayID?, option: ListOption) -> [MenuBarItem] { + getMenuBarItemWindows(on: display, option: option).map { window in + MenuBarItem(uncheckedItemWindow: window) + } } /// Creates and returns a list of menu bar items for the given display. @@ -216,8 +253,10 @@ extension MenuBarItem { /// - option: Options that filter the returned list. Pass an empty option set /// to return all available menu bar items. static func getMenuBarItems(on display: CGDirectDisplayID? = nil, option: ListOption) -> [MenuBarItem] { - getMenuBarItemWindows(on: display, option: option).map { window in - MenuBarItem(uncheckedItemWindow: window) + if #available(macOS 26.0, *) { + getMenuBarItemsExperimental(on: display, option: option) + } else { + getMenuBarItemsLegacyMethod(on: display, option: option) } } } @@ -225,39 +264,72 @@ extension MenuBarItem { // MARK: MenuBarItem: Equatable extension MenuBarItem: Equatable { static func == (lhs: MenuBarItem, rhs: MenuBarItem) -> Bool { - lhs.window == rhs.window + lhs.tag == rhs.tag && + lhs.windowID == rhs.windowID && + lhs.ownerPID == rhs.ownerPID && + lhs.sourcePID == rhs.sourcePID && + NSStringFromRect(lhs.bounds) == NSStringFromRect(rhs.bounds) && + lhs.title == rhs.title && + lhs.ownerName == rhs.ownerName && + lhs.isOnScreen == rhs.isOnScreen } } // MARK: MenuBarItem: Hashable extension MenuBarItem: Hashable { func hash(into hasher: inout Hasher) { - hasher.combine(window) + hasher.combine(tag) + hasher.combine(windowID) + hasher.combine(ownerPID) + hasher.combine(sourcePID) + hasher.combine(NSStringFromRect(bounds)) + hasher.combine(title) + hasher.combine(ownerName) + hasher.combine(isOnScreen) } } -// MARK: - MenuBarItemLegacyInfo Unchecked Item Window Initializer +// MARK: - MenuBarItemTag Helper -private extension MenuBarItemLegacyInfo { - /// Creates a simplified item from the given window. +private extension MenuBarItemTag { + /// Creates a tag without checks. /// - /// This initializer does not perform any checks on the window to ensure that - /// it is a valid menu bar item window. Only call this initializer if you are - /// certain that the window is valid. + /// This initializer does not perform validity checks on its parameters. + /// Only call it if you are certain the window is a valid menu bar item. init(uncheckedItemWindow itemWindow: WindowInfo) { - self.namespace = Namespace(uncheckedItemWindow: itemWindow) - self.title = itemWindow.title ?? "" + let title = itemWindow.title ?? "" + if title.hasPrefix("Ice.ControlItem") { + self.namespace = .ice + } else { + self.namespace = Namespace(uncheckedItemWindow: itemWindow) + } + self.title = title + } + + /// Creates a tag without checks. + /// + /// This initializer does not perform validity checks on its parameters. + /// Only call it if you are certain the window is a valid menu bar item + /// and the source pid belongs to the application that created it. + @available(macOS 26.0, *) + init(uncheckedItemWindow itemWindow: WindowInfo, sourcePID: pid_t?) { + let title = itemWindow.title ?? "" + if title.hasPrefix("Ice.ControlItem") { + self.namespace = .ice + } else { + self.namespace = Namespace(uncheckedItemWindow: itemWindow, sourcePID: sourcePID) + } + self.title = title } } -// MARK: - MenuBarItemLegacyInfo.Namespace Unchecked Item Window Initializer +// MARK: - MenuBarItemTag.Namespace Helper -private extension MenuBarItemLegacyInfo.Namespace { - /// Creates a namespace from the given window. +private extension MenuBarItemTag.Namespace { + /// Creates a namespace without checks. /// - /// This initializer does not perform any checks on the window to ensure that - /// it is a valid menu bar item window. Only call this initializer if you are - /// certain that the window is valid. + /// This initializer does not perform validity checks on its parameters. + /// Only call it if you are certain the window is a valid menu bar item. init(uncheckedItemWindow itemWindow: WindowInfo) { // Most apps have a bundle ID, but we should be able to handle apps // that don't. We should also be able to handle daemons and helpers, @@ -272,4 +344,23 @@ private extension MenuBarItemLegacyInfo.Namespace { self.init(itemWindow.ownerName) } } + + /// Creates a namespace without checks. + /// + /// This initializer does not perform validity checks on its parameters. + /// Only call it if you are certain the window is a valid menu bar item + /// and the source pid belongs to the application that created it. + @available(macOS 26.0, *) + init(uncheckedItemWindow itemWindow: WindowInfo, sourcePID: pid_t?) { + // Most apps have a bundle ID, but we should be able to handle apps + // that don't. We should also be able to handle daemons and helpers, + // which are more likely not to have a bundle ID. + if let sourcePID, let app = NSRunningApplication(processIdentifier: sourcePID) { + self.init(app.bundleIdentifier ?? app.localizedName) + } else if let app = itemWindow.owningApplication { + self.init(app.bundleIdentifier ?? itemWindow.ownerName ?? app.localizedName) + } else { + self.init(itemWindow.ownerName) + } + } } diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift index 7d02794fe..50049522f 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift @@ -9,8 +9,39 @@ import OSLog /// Cache for menu bar item images. final class MenuBarItemImageCache: ObservableObject { - /// The cached item images. - @Published private(set) var images = [MenuBarItemInfo: CGImage]() + /// A representation of a captured menu bar item image. + struct CapturedImage: Hashable { + /// The base image. + let cgImage: CGImage + + /// The scale factor of the image at the time of capture. + let scale: CGFloat + + /// The image's size, applying ``scale``. + var scaledSize: CGSize { + CGSize( + width: CGFloat(cgImage.width) / scale, + height: CGFloat(cgImage.height) / scale + ) + } + + /// The base image, converted to an `NSImage` and applying ``scale``. + var nsImage: NSImage { + NSImage(cgImage: cgImage, size: scaledSize) + } + } + + /// The result of an image capture operation. + private struct CaptureResult { + /// The successfully captured images. + var images = [MenuBarItemTag: CapturedImage]() + + /// The menu bar items excluded from the capture. + var excluded = [MenuBarItem]() + } + + /// The cached item images, keyed by their corresponding tags. + @Published private(set) var images = [MenuBarItemTag: CapturedImage]() /// Logger for the menu bar item image cache. private let logger = Logger(category: "MenuBarItemImageCache") @@ -18,11 +49,8 @@ final class MenuBarItemImageCache: ObservableObject { /// Queue to run cache operations. private let queue = DispatchQueue(label: "MenuBarItemImageCache", qos: .background) - /// The screen of the cached item images. - private(set) var screen: NSScreen? - - /// The height of the menu bar of the cached item images. - private(set) var menuBarHeight: CGFloat? + /// Image capture options. + private let captureOption: CGWindowImageOption = [.boundsIgnoreFraming, .bestResolution] /// The shared app state. private weak var appState: AppState? @@ -30,6 +58,8 @@ final class MenuBarItemImageCache: ObservableObject { /// Storage for internal observers. private var cancellables = Set() + // MARK: Setup + /// Sets up the cache. @MainActor func performSetup(with appState: AppState) { @@ -65,10 +95,8 @@ final class MenuBarItemImageCache: ObservableObject { guard let self else { return } - Task.detached { - if ScreenCapture.cachedCheckPermissions() { - await self.updateCache() - } + Task { + await self.updateCache() } } .store(in: &c) @@ -77,125 +105,142 @@ final class MenuBarItemImageCache: ObservableObject { cancellables = c } - /// Logs a reason for skipping the cache. - private func logSkippingCache(reason: @escaping @autoclosure () -> String) { - logger.debug("Skipping menu bar item image cache as \(reason(), privacy: .public)") - } + // MARK: Capturing Images - /// Returns a Boolean value that indicates whether caching menu bar items failed for - /// the given section. - @MainActor - func cacheFailed(for section: MenuBarSection.Name) -> Bool { - guard ScreenCapture.cachedCheckPermissions() else { - return true - } - let items = appState?.itemManager.itemCache[section] ?? [] - guard !items.isEmpty else { - return false - } - let keys = Set(images.keys) - for item in items where keys.contains(item.info) { - return false + /// Captures a composite image of the given items, then crops out an image + /// for each item and returns the result. + private nonisolated func compositeCapture(_ items: [MenuBarItem], scale: CGFloat) -> CaptureResult { + var result = CaptureResult() + + var windowIDs = [CGWindowID]() + var storage = [CGWindowID: (MenuBarItem, CGRect)]() + var boundsUnion = CGRect.null + + for item in items { + let windowID = item.windowID + + // Don't use item.bounds, it could be out of date. + guard let bounds = Bridging.getWindowBounds(for: windowID) else { + result.excluded.append(item) + continue + } + + windowIDs.append(windowID) + storage[windowID] = (item, bounds) + boundsUnion = boundsUnion.union(bounds) } - return true - } - /// Captures the images of the current menu bar items and returns a dictionary containing - /// the images, keyed by the current menu bar item infos. - func createImages(for section: MenuBarSection.Name, screen: NSScreen) async -> [MenuBarItemInfo: CGImage] { - guard let appState else { - return [:] + guard + let compositeImage = ScreenCapture.captureWindows(windowIDs, option: captureOption), + CGFloat(compositeImage.width) == boundsUnion.width * scale, // Safety check. + !compositeImage.isTransparent() + else { + result.excluded = items // Exclude all items. + return result } - let items = await appState.itemManager.itemCache[section] + // Crop out each item from the composite. + for windowID in windowIDs { + guard let (item, bounds) = storage[windowID] else { + continue + } + + let cropRect = CGRect( + x: (bounds.origin.x - boundsUnion.origin.x) * scale, + y: (bounds.origin.y - boundsUnion.origin.y) * scale, + width: bounds.width * scale, + height: bounds.height * scale + ) + + guard let image = compositeImage.cropping(to: cropRect) else { + result.excluded.append(item) + continue + } - var images = [MenuBarItemInfo: CGImage]() - let backingScaleFactor = screen.backingScaleFactor - let displayBounds = CGDisplayBounds(screen.displayID) - let option: CGWindowImageOption = [.boundsIgnoreFraming, .bestResolution] + result.images[item.tag] = CapturedImage(cgImage: image, scale: scale) + } - var itemInfosDict = [CGWindowID: MenuBarItemInfo]() - var itemBoundsDict = [CGWindowID: CGRect]() - var windowIDs = [CGWindowID]() - var combinedBounds = CGRect.null + return result + } + + /// Captures an image of each of the given items individually, then + /// returns the result. + private nonisolated func individualCapture(_ items: [MenuBarItem], scale: CGFloat) -> CaptureResult { + var result = CaptureResult() for item in items { - let windowID = item.windowID guard - let itemBounds = Bridging.getWindowBounds(for: windowID), // Get latest bounds. - itemBounds.minY == displayBounds.minY + let image = ScreenCapture.captureWindow(item.windowID, option: captureOption), + !image.isTransparent() else { + result.excluded.append(item) continue } - itemInfosDict[windowID] = item.info - itemBoundsDict[windowID] = itemBounds - windowIDs.append(windowID) - combinedBounds = combinedBounds.union(itemBounds) + result.images[item.tag] = CapturedImage(cgImage: image, scale: scale) } - if - let compositeImage = ScreenCapture.captureWindows(windowIDs, option: option), - CGFloat(compositeImage.width) == combinedBounds.width * backingScaleFactor - { - for windowID in windowIDs { - guard - let itemInfo = itemInfosDict[windowID], - let itemBounds = itemBoundsDict[windowID] - else { - continue - } + return result + } - let frame = CGRect( - x: (itemBounds.origin.x - combinedBounds.origin.x) * backingScaleFactor, - y: (itemBounds.origin.y - combinedBounds.origin.y) * backingScaleFactor, - width: itemBounds.width * backingScaleFactor, - height: itemBounds.height * backingScaleFactor - ) + /// Captures the images of the given menu bar items and returns a dictionary + /// containing the images, keyed by their menu bar item tags. + private nonisolated func captureImages(for items: [MenuBarItem], screen: NSScreen) -> [MenuBarItemTag: CapturedImage] { + let scale = screen.backingScaleFactor - guard let itemImage = compositeImage.cropping(to: frame) else { - continue - } + let compositeResult = compositeCapture(items, scale: scale) - images[itemInfo] = itemImage - } - } else { - logger.warning( - """ - Composite capture failed for \(section.logString, privacy: .public). \ - Attempting to capture items individually. - """ - ) + if compositeResult.excluded.isEmpty { + return compositeResult.images // All items were captured successfully. + } - for windowID in windowIDs { - guard - let itemInfo = itemInfosDict[windowID], - let itemImage = ScreenCapture.captureWindow(windowID, option: option) - else { - continue - } - images[itemInfo] = itemImage - } + logger.notice( + """ + Some items were excluded from composite capture. Attempting to capture \ + excluded items individually: \(compositeResult.excluded, privacy: .public) + """ + ) + + let individualResult = individualCapture(compositeResult.excluded, scale: scale) + + if !individualResult.excluded.isEmpty { + logger.error("Some items failed capture: \(individualResult.excluded, privacy: .public)") } - return images + return compositeResult.images.merging(individualResult.images) { (_, new) in new } + } + + /// Captures the images of the menu bar items in the given section and returns + /// a dictionary containing the images, keyed by their menu bar item tags. + private func captureImages(for section: MenuBarSection.Name, screen: NSScreen) async -> [MenuBarItemTag: CapturedImage] { + guard let appState else { + return [:] + } + let items = await appState.itemManager.itemCache.managedItems(for: section) + return captureImages(for: items, screen: screen) } - /// Updates the cache for the given sections, without checking whether caching is necessary. + // MARK: Update Cache + + /// Updates the cache for the given sections, without checking whether + /// caching is necessary. func updateCacheWithoutChecks(sections: [MenuBarSection.Name]) async { guard let appState, + await appState.hasPermission(.screenRecording), let screen = NSScreen.main else { return } - var newImages = [MenuBarItemInfo: CGImage]() + var newImages = [MenuBarItemTag: CapturedImage]() for section in sections { guard await !appState.itemManager.itemCache[section].isEmpty else { continue } - let sectionImages = await createImages(for: section, screen: screen) + + let sectionImages = await captureImages(for: section, screen: screen) + guard !sectionImages.isEmpty else { logger.warning( """ @@ -205,19 +250,21 @@ final class MenuBarItemImageCache: ObservableObject { ) continue } + newImages.merge(sectionImages) { (_, new) in new } } await MainActor.run { [newImages] in images.merge(newImages) { (_, new) in new } } - - self.screen = screen - self.menuBarHeight = screen.getMenuBarHeight() } /// Updates the cache for the given sections, if necessary. func updateCache(sections: [MenuBarSection.Name]) async { + func skippingCache(reason: @escaping @autoclosure () -> String) { + logger.debug("Skipping menu bar item image cache as \(reason(), privacy: .public)") + } + guard let appState else { return } @@ -227,26 +274,21 @@ final class MenuBarItemImageCache: ObservableObject { if !isIceBarPresented && !isSearchPresented { guard await appState.navigationState.isAppFrontmost else { - logSkippingCache(reason: "Ice Bar not visible, app not frontmost") + skippingCache(reason: "Ice Bar not visible, app not frontmost") return } guard await appState.navigationState.isSettingsPresented else { - logSkippingCache(reason: "Ice Bar not visible, Settings not visible") + skippingCache(reason: "Ice Bar not visible, Settings not visible") return } guard case .menuBarLayout = await appState.navigationState.settingsNavigationIdentifier else { - logSkippingCache(reason: "Ice Bar not visible, Settings visible but not on Menu Bar Layout") + skippingCache(reason: "Ice Bar not visible, Settings visible but not on Menu Bar Layout") return } } - guard await !appState.itemManager.isMovingItem else { - logSkippingCache(reason: "an item is currently being moved") - return - } - guard await !appState.itemManager.itemHasRecentlyMoved else { - logSkippingCache(reason: "an item was recently moved") + skippingCache(reason: "an item was recently moved") return } @@ -264,6 +306,7 @@ final class MenuBarItemImageCache: ObservableObject { let isSettingsPresented = await appState.navigationState.isSettingsPresented var sectionsNeedingDisplay = [MenuBarSection.Name]() + if isSettingsPresented || isSearchPresented { sectionsNeedingDisplay = MenuBarSection.Name.allCases } else if @@ -275,4 +318,24 @@ final class MenuBarItemImageCache: ObservableObject { await updateCache(sections: sectionsNeedingDisplay) } + + // MARK: Cache Failed + + /// Returns a Boolean value that indicates whether caching menu bar items + /// failed for the given section. + @MainActor + func cacheFailed(for section: MenuBarSection.Name) -> Bool { + guard ScreenCapture.cachedCheckPermissions() else { + return true + } + let items = appState?.itemManager.itemCache[section] ?? [] + guard !items.isEmpty else { + return false + } + let keys = Set(images.keys) + for item in items where keys.contains(item.tag) { + return false + } + return true + } } diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index 463399b80..44f5d9b54 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -38,15 +38,13 @@ final class MenuBarItemManager: ObservableObject { func managedItems(for section: MenuBarSection.Name) -> [MenuBarItem] { self[section].filter { item in // Filter out items that can't be hidden. - guard item.canBeHidden else { + if !item.canBeHidden { return false } - if item.owningApplication == .current { - // Ice icon is the only item owned by Ice that should be included. - guard item.title == ControlItem.Identifier.iceIcon.rawValue else { - return false - } + // Filter out the two separator control items. + if item.isControlItem && item.tag != .visibleControlItem { + return false } return true @@ -55,7 +53,7 @@ final class MenuBarItemManager: ObservableObject { /// Returns the name of the section for the given menu bar item. func section(for item: MenuBarItem) -> MenuBarSection.Name? { - for (section, items) in self.items where items.contains(where: { $0.info == item.info }) { + for (section, items) in self.items where items.contains(where: { $0.tag == item.tag }) { return section } return nil @@ -70,8 +68,8 @@ final class MenuBarItemManager: ObservableObject { /// Context for a temporarily shown menu bar item. private struct TempShownItemContext { - /// The information associated with the item. - let info: MenuBarItemInfo + /// The tag associated with the item. + let tag: MenuBarItemTag /// The destination to return the item to. let returnDestination: MoveDestination @@ -119,16 +117,6 @@ final class MenuBarItemManager: ObservableObject { /// The last time a menu bar item was moved. private var lastItemMoveStartDate: Date? - /// Counter to determine if a menu bar item, or group of menu bar - /// items is being moved. - private var itemMoveCount = 0 - - /// A Boolean value that indicates whether a menu bar item, or - /// group of menu bar items is being moved. - var isMovingItem: Bool { - itemMoveCount > 0 - } - /// A Boolean value that indicates whether a menu bar item has /// recently moved. var itemHasRecentlyMoved: Bool { @@ -141,34 +129,38 @@ final class MenuBarItemManager: ObservableObject { /// Sets up the manager. func performSetup(with appState: AppState) { self.appState = appState - configureCancellables() + configureCancellables(with: appState) } /// Configures the internal observers for the manager. - private func configureCancellables() { + private func configureCancellables(with appState: AppState) { var c = Set() - Timer.publish(every: 5, on: .main, in: .default) - .autoconnect() - .merge(with: Just(.now)) - .sink { [weak self] _ in - guard let self else { - return - } - Task { - await self.cacheItemsIfNeeded() - } + Publishers.CombineLatest( + Timer.publish(every: 5, on: .main, in: .default) + .autoconnect() + .merge(with: Just(.now)), + NSWorkspace.shared.publisher(for: \.runningApplications) + .delay(for: 0.25, scheduler: DispatchQueue.main) + ) + .throttle(for: 1, scheduler: DispatchQueue.main, latest: true) + .sink { [weak self] _ in + guard let self else { + return } - .store(in: &c) + Task { + await self.cacheItemsIfNeeded() + } + } + .store(in: &c) - NSWorkspace.shared.publisher(for: \.runningApplications) - .delay(for: 0.25, scheduler: DispatchQueue.main) - .sink { [weak self] _ in - guard let self else { + appState.navigationState.$settingsNavigationIdentifier + .sink { [weak self] identifier in + guard let self, identifier == .menuBarLayout else { return } Task { - await self.cacheItemsIfNeeded() + await self.cacheItemsRegardless() } } .store(in: &c) @@ -180,35 +172,34 @@ final class MenuBarItemManager: ObservableObject { // MARK: - Cache Items extension MenuBarItemManager { - /// Logs a warning that the given menu bar item was not added to the cache. - private func logNotCachedWarning(for item: MenuBarItem) { - logger.warning("\(item.logString, privacy: .public) was not cached") - } + private struct ControlItemSet { + let hidden: MenuBarItem + let alwaysHidden: MenuBarItem? - /// Logs a reason for skipping the cache. - private func logSkippingCache(reason: String) { - logger.debug("Skipping menu bar item cache as \(reason, privacy: .public)") + init?(items: inout [MenuBarItem]) { + guard let hidden = items.removeFirst(matching: .hiddenControlItem) else { + return nil + } + self.hidden = hidden + self.alwaysHidden = items.removeFirst(matching: .alwaysHiddenControlItem) + } } - /// Caches the given menu bar items, without checking whether the control - /// items are in the correct order. - private func uncheckedCacheItems( - hiddenControlItem: MenuBarItem, - alwaysHiddenControlItem: MenuBarItem?, - otherItems: [MenuBarItem] - ) { + /// Caches the given menu bar items, without ensuring that the + /// control items are in the correct order. + private func uncheckedCacheItems(controlItems: ControlItemSet, otherItems: [MenuBarItem]) { logger.debug("Caching menu bar items") let predicates = Predicates.sectionPredicates( - hiddenControlItem: hiddenControlItem, - alwaysHiddenControlItem: alwaysHiddenControlItem + hiddenControlItem: controlItems.hidden, + alwaysHiddenControlItem: controlItems.alwaysHidden ) var cache = ItemCache() var tempShownItems = [(MenuBarItem, MoveDestination)]() for item in otherItems { - if let context = tempShownItemContexts.first(where: { $0.info == item.info }) { + if let context = tempShownItemContexts.first(where: { $0.tag == item.tag }) { // Keep track of temporarily shown items and their return destinations separately. // We want to cache them as if they were in their original locations. Once all other // items are cached, use the return destinations to insert the items into the cache @@ -221,41 +212,44 @@ extension MenuBarItemManager { } else if predicates.isInAlwaysHiddenSection(item) { cache[.alwaysHidden].append(item) } else { - logNotCachedWarning(for: item) + logger.warning("\(item.logString, privacy: .public) was not cached") + cachedItemWindowIDs.removeAll() // Make sure we don't skip the next cache attempt. } } for (item, destination) in tempShownItems { switch destination { case .leftOfItem(let targetItem): - switch targetItem.legacyInfo { + switch targetItem.tag { case .hiddenControlItem: cache[.hidden].append(item) case .alwaysHiddenControlItem: cache[.alwaysHidden].append(item) default: - if + guard let section = cache.section(for: targetItem), - let index = cache[section].firstIndex(matching: targetItem.info) - { - let clampedIndex = index.clamped(to: cache[section].startIndex...cache[section].endIndex) - cache[section].insert(item, at: clampedIndex) + let index = cache[section].firstIndex(matching: targetItem.tag) + else { + continue } + let range = cache[section].startIndex...cache[section].endIndex + cache[section].insert(item, at: index.clamped(to: range)) } case .rightOfItem(let targetItem): - switch targetItem.legacyInfo { + switch targetItem.tag { case .hiddenControlItem: cache[.visible].insert(item, at: 0) case .alwaysHiddenControlItem: cache[.hidden].insert(item, at: 0) default: - if + guard let section = cache.section(for: targetItem), - let index = cache[section].firstIndex(matching: targetItem.info) - { - let clampedIndex = (index - 1).clamped(to: cache[section].startIndex...cache[section].endIndex) - cache[section].insert(item, at: clampedIndex) + let index = cache[section].firstIndex(matching: targetItem.tag) + else { + continue } + let range = cache[section].startIndex...cache[section].endIndex + cache[section].insert(item, at: (index - 1).clamped(to: range)) } } } @@ -263,58 +257,42 @@ extension MenuBarItemManager { itemCache = cache } - /// Caches the current menu bar items if needed, ensuring that the control - /// items are in the correct order. - func cacheItemsIfNeeded() async { - do { - try await waitForItemsToStopMoving(timeout: .seconds(1)) - } catch is TaskTimeoutError { - logSkippingCache(reason: "an item is currently being moved") + /// Caches the current menu bar items, regardless of the current item + /// state, ensuring that the control items are in the correct order. + func cacheItemsRegardless(_ currentItemWindowIDs: [CGWindowID]? = nil) async { + var items = MenuBarItem.getMenuBarItems(option: .activeSpace) + cachedItemWindowIDs = currentItemWindowIDs ?? items.reversed().map { $0.windowID } + + guard let controlItems = ControlItemSet(items: &items) else { + logger.warning("Missing control item for hidden section") + logger.debug("Clearing menu bar item cache") + itemCache.clear() return - } catch { - guard !itemHasRecentlyMoved else { - logSkippingCache(reason: "an item was recently moved") - return - } } - let itemWindowIDs = Bridging.getWindowList(option: [.menuBarItems, .activeSpace]) - if cachedItemWindowIDs == itemWindowIDs { - logSkippingCache(reason: "item windows have not changed") + await enforceControlItemOrder(controlItems: controlItems) + uncheckedCacheItems(controlItems: controlItems, otherItems: items) + } + + /// Caches the current menu bar items if needed, ensuring that the + /// control items are in the correct order. + func cacheItemsIfNeeded() async { + guard !itemHasRecentlyMoved else { + logger.debug("Skipping menu bar item cache as an item was recently moved") return - } else { - cachedItemWindowIDs = itemWindowIDs } - var items = MenuBarItem.getMenuBarItems(option: .activeSpace) - - let hiddenControlItem = items.firstIndex(matching: .hiddenControlItem).map { items.remove(at: $0) } - let alwaysHiddenControlItem = items.firstIndex(matching: .alwaysHiddenControlItem).map { items.remove(at: $0) } + let itemWindowIDs = Bridging.getMenuBarWindowList(option: [.itemsOnly, .activeSpace]) - guard let hiddenControlItem else { - logger.warning("Missing control item for hidden section") - logger.debug("Clearing menu bar item cache") - itemCache.clear() + if + cachedItemWindowIDs == itemWindowIDs, + itemCache.managedItems.allSatisfy({ $0.sourcePID != nil }) + { + logger.debug("Skipping menu bar item cache as item windows have not changed") return } - do { - if let alwaysHiddenControlItem { - try await enforceControlItemOrder( - hiddenControlItem: hiddenControlItem, - alwaysHiddenControlItem: alwaysHiddenControlItem - ) - } - uncheckedCacheItems( - hiddenControlItem: hiddenControlItem, - alwaysHiddenControlItem: alwaysHiddenControlItem, - otherItems: items - ) - } catch { - logger.error("Error enforcing control item order: \(error, privacy: .public)") - logger.debug("Clearing menu bar item cache") - itemCache.clear() - } + await cacheItemsRegardless(itemWindowIDs) } } @@ -327,31 +305,22 @@ extension MenuBarItemManager { enum ErrorCode: Int, CustomStringConvertible { /// An operation could not be completed. case couldNotComplete - /// The creation of a menu bar item event failed. case eventCreationFailure - /// The shared app state is invalid or could not be found. case invalidAppState - /// An event source could not be created or is otherwise invalid. case invalidEventSource - /// The location of the mouse cursor is invalid or could not be found. case invalidCursorLocation - /// A menu bar item is invalid. case invalidItem - /// A menu bar item cannot be moved. case notMovable - /// A menu bar item event operation timed out. case eventOperationTimeout - /// A menu bar item bounds check timed out. case boundsCheckTimeout - /// An operation timed out. case otherTimeout @@ -444,7 +413,11 @@ extension MenuBarItemManager { /// - Parameters: /// - timeout: Amount of time to wait before throwing an error. /// - operation: The operation to perform. - private func waitWithTask(timeout: Duration?, operation: @escaping @Sendable () async throws -> Void) async throws { + private func waitWithTask( + timeout: Duration?, + @_inheritActorContext @_implicitSelfCapture + operation: sending @escaping @isolated(any) () async throws -> Void + ) async throws { let task = if let timeout { Task(timeout: timeout, operation: operation) } else { @@ -453,21 +426,6 @@ extension MenuBarItemManager { try await task.value } - /// Waits asynchronously for all menu bar items to stop moving. - /// - /// - Parameter timeout: Amount of time to wait before throwing an error. - func waitForItemsToStopMoving(timeout: Duration? = nil) async throws { - try await waitWithTask(timeout: timeout) { [weak self] in - guard let self else { - return - } - while await isMovingItem { - try Task.checkCancellation() - try await Task.sleep(for: .milliseconds(10)) - } - } - } - /// Waits asynchronously for the mouse to stop moving. /// /// - Parameter timeout: Amount of time to wait before throwing an error. @@ -545,12 +503,11 @@ extension MenuBarItemManager { // MARK: - Move Items extension MenuBarItemManager { - /// A destination that a menu bar item can be moved to. + /// Destinations for menu bar item move operations. enum MoveDestination { - /// The menu bar item will be moved to the left of the given menu bar item. + /// Specifies a destination left of the given target item. case leftOfItem(MenuBarItem) - - /// The menu bar item will be moved to the right of the given menu bar item. + /// Specifies a destination right of the given target item. case rightOfItem(MenuBarItem) /// A string to use for logging purposes. @@ -659,16 +616,17 @@ extension MenuBarItemManager { /// - event: The event to post. /// - location: The event tap location to post the event to. private nonisolated func postEvent(_ event: CGEvent, to location: EventTap.Location) { - logger.debug("Posting \(event.type.logString, privacy: .public) to \(location.logString, privacy: .public)") + logger.debug( + """ + Posting \(event.type.logString, privacy: .public) \ + to \(location.logString, privacy: .public) + """ + ) switch location { - case .hidEventTap: - event.post(tap: .cghidEventTap) - case .sessionEventTap: - event.post(tap: .cgSessionEventTap) - case .annotatedSessionEventTap: - event.post(tap: .cgAnnotatedSessionEventTap) - case .pid(let pid): - event.postToPid(pid) + case .hidEventTap: event.post(tap: .cghidEventTap) + case .sessionEventTap: event.post(tap: .cgSessionEventTap) + case .annotatedSessionEventTap: event.post(tap: .cgAnnotatedSessionEventTap) + case .pid(let pid): event.postToPid(pid) } } @@ -709,11 +667,22 @@ extension MenuBarItemManager { // Ensure the tap is enabled, preventing multiple calls to resume(). guard proxy.isEnabled else { - logger.debug("Event tap \"\(proxy.label, privacy: .public)\" is disabled (item: \(item.logString, privacy: .public))") + logger.debug( + """ + Event tap \"\(proxy.label, privacy: .public)\" is disabled \ + (item: \(item.logString, privacy: .public)) + """ + ) return nil } - logger.debug("Received \(type.logString, privacy: .public) at \(location.logString, privacy: .public) (item: \(item.logString, privacy: .public))") + logger.debug( + """ + Received \(type.logString, privacy: .public) \ + at \(location.logString, privacy: .public) \ + (item: \(item.logString, privacy: .public)) + """ + ) // Disable the tap and resume the continuation. proxy.disable() @@ -722,8 +691,13 @@ extension MenuBarItemManager { return nil } - eventTap.enable(timeout: .milliseconds(50)) { [logger] in - logger.error("Event tap \"\(eventTap.label, privacy: .public)\" timed out (item: \(item.logString, privacy: .public))") + eventTap.enable(timeout: .milliseconds(100)) { [logger] in + logger.error( + """ + Event tap \"\(eventTap.label, privacy: .public)\" timed out \ + (item: \(item.logString, privacy: .public)) + """ + ) eventTap.disable() continuation.resume(throwing: EventError(code: .eventOperationTimeout, item: item)) } @@ -813,7 +787,12 @@ extension MenuBarItemManager { // Ensure the tap is enabled, preventing multiple calls to resume(). guard proxy.isEnabled else { - logger.debug("Event tap \"\(proxy.label, privacy: .public)\" is disabled (item: \(item.logString, privacy: .public))") + logger.debug( + """ + Event tap \"\(proxy.label, privacy: .public)\" is disabled \ + (item: \(item.logString, privacy: .public)) + """ + ) return nil } @@ -828,8 +807,13 @@ extension MenuBarItemManager { // Enable both taps, with a timeout on the second tap. eventTap1.enable() - eventTap2.enable(timeout: .milliseconds(50)) { [logger] in - logger.error("Event tap \"\(eventTap2.label, privacy: .public)\" timed out (item: \(item.logString, privacy: .public))") + eventTap2.enable(timeout: .milliseconds(100)) { [logger] in + logger.error( + """ + Event tap \"\(eventTap2.label, privacy: .public)\" timed out \ + (item: \(item.logString, privacy: .public)) + """ + ) eventTap1.disable() eventTap2.disable() continuation.resume(throwing: EventError(code: .eventOperationTimeout, item: item)) @@ -856,7 +840,12 @@ extension MenuBarItemManager { ) async throws { guard let currentBounds = getCurrentBounds(for: item) else { try await scrombleEvent(event, from: firstLocation, to: secondLocation, item: item) - logger.warning("Couldn't get menu bar item bounds for \(item.logString, privacy: .public), so using fixed delay") + logger.warning( + """ + Couldn't get bounds for \(item.logString, privacy: .public), \ + so using fixed delay + """ + ) // This will be slow, but subsequent events will have a better chance of succeeding. try await Task.sleep(for: .milliseconds(100)) return @@ -874,17 +863,19 @@ extension MenuBarItemManager { private func waitForBoundsChange(of item: MenuBarItem, initialBounds: CGRect, timeout: Duration) async throws { struct BoundsCheckCancellationError: Error { } - let boundsCheckTask = Task(timeout: timeout) { [weak self] in + let boundsCheckTask = Task(timeout: timeout) { while true { try Task.checkCancellation() - guard - let self, - let currentBounds = await getCurrentBounds(for: item) - else { + guard let currentBounds = getCurrentBounds(for: item) else { throw BoundsCheckCancellationError() } if currentBounds != initialBounds { - logger.debug("Menu bar item bounds for \(item.logString, privacy: .public) changed to \(NSStringFromRect(currentBounds), privacy: .public)") + logger.debug( + """ + Bounds for \(item.logString, privacy: .public) changed \ + to \(NSStringFromRect(currentBounds), privacy: .public) + """ + ) return } } @@ -892,7 +883,12 @@ extension MenuBarItemManager { do { try await boundsCheckTask.value } catch is BoundsCheckCancellationError { - logger.warning("Menu bar item bounds check for \(item.logString, privacy: .public) was cancelled, so using fixed delay") + logger.warning( + """ + Bounds check for \(item.logString, privacy: .public) \ + was cancelled, so using fixed delay + """ + ) // This will be slow, but subsequent events will have a better chance of succeeding. try await Task.sleep(for: .milliseconds(100)) } catch is TaskTimeoutError { @@ -975,11 +971,6 @@ extension MenuBarItemManager { /// - item: A menu bar item to move. /// - destination: A destination to move the menu bar item. private func moveItemWithoutRestoringMouseLocation(_ item: MenuBarItem, to destination: MoveDestination) async throws { - itemMoveCount += 1 - defer { - itemMoveCount -= 1 - } - guard item.isMovable else { throw EventError(code: .notMovable, item: item) } @@ -1046,20 +1037,34 @@ extension MenuBarItemManager { } catch { do { let eventTask = Task { - logger.debug("Posting fallback event for moving \(item.logString, privacy: .public)") + logger.debug( + """ + Posting fallback event for moving \ + \(item.logString, privacy: .public) + """ + ) try await postEventAndWaitToReceive( fallbackEvent, to: .sessionEventTap, item: item ) } + let result = await eventTask.result await eventSleep() - // Catch this, as we still want to throw the existing error if the fallback fails. + + // Catch this for logging purposes only -- we still want + // to throw the existing error if the fallback fails. try result.get() } catch { - logger.error("Failed to post fallback event for moving \(item.logString, privacy: .public)") + logger.error( + """ + Failed to post fallback event for moving \ + \(item.logString, privacy: .public) + """ + ) } + throw error } } @@ -1162,11 +1167,6 @@ extension MenuBarItemManager { /// - destination: A destination to move the menu bar item. /// - timeout: Amount of time to wait before throwing an error. func slowMove(item: MenuBarItem, to destination: MoveDestination, timeout: Duration = .seconds(1)) async throws { - itemMoveCount += 1 - defer { - itemMoveCount -= 1 - } - do { try await move(item: item, to: destination) } catch { @@ -1177,7 +1177,7 @@ extension MenuBarItemManager { let waitTask = Task(timeout: timeout) { while true { try Task.checkCancellation() - if try await self.itemHasCorrectPosition(item: item, for: destination) { + if try itemHasCorrectPosition(item: item, for: destination) { return } } @@ -1254,7 +1254,12 @@ extension MenuBarItemManager { } do { - logger.info("Clicking \(item.logString, privacy: .public) with \(mouseButton.logString, privacy: .public)") + logger.info( + """ + Clicking \(item.logString, privacy: .public) with \ + \(mouseButton.logString, privacy: .public) + """ + ) try await postEventAndWaitToReceive( mouseDownEvent, to: .sessionEventTap, @@ -1268,19 +1273,32 @@ extension MenuBarItemManager { } catch { do { let eventTask = Task { - logger.debug("Posting fallback event for clicking \(item.logString, privacy: .public)") + logger.debug( + """ + Posting fallback event for clicking \ + \(item.logString, privacy: .public) + """ + ) try await postEventAndWaitToReceive( fallbackEvent, to: .sessionEventTap, item: item ) } + let result = await eventTask.result await eventSleep() - // Catch this, as we still want to throw the existing error if the fallback fails. + + // Catch this for logging purposes only -- we still want + // to throw the existing error if the fallback fails. try result.get() } catch { - logger.error("Failed to post fallback event for clicking \(item.logString, privacy: .public)") + logger.error( + """ + Failed to post fallback event for clicking \ + \(item.logString, privacy: .public) + """ + ) } throw error } @@ -1292,8 +1310,7 @@ extension MenuBarItemManager { extension MenuBarItemManager { /// Gets the destination to return the given item to after it is temporarily shown. private func getReturnDestination(for item: MenuBarItem, in items: [MenuBarItem]) -> MoveDestination? { - let info = item.info - if let index = items.firstIndex(where: { $0.info == info }) { + if let index = items.firstIndex(matching: item.tag) { if items.indices.contains(index + 1) { return .leftOfItem(items[index + 1]) } else if items.indices.contains(index - 1) { @@ -1306,7 +1323,12 @@ extension MenuBarItemManager { /// Schedules a timer for the given interval, attempting to rehide the current /// temporarily shown items when the timer fires. private func runTempShownItemTimer(for interval: TimeInterval) { - logger.debug("Running rehide timer for temporarily shown items with interval: \(interval, privacy: .public)") + logger.debug( + """ + Running rehide timer for temporarily shown items \ + with interval: \(interval, privacy: .public) + """ + ) tempShownItemsTimer?.invalidate() tempShownItemsTimer = .scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] timer in guard let self else { @@ -1355,7 +1377,12 @@ extension MenuBarItemManager { let appState, let applicationMenuFrame = appState.menuBarManager.getApplicationMenuFrame(for: displayID) else { - logger.warning("No application menu frame, so not showing \(item.logString, privacy: .public)") + logger.warning( + """ + No application menu frame, so not showing \ + \(item.logString, privacy: .public) + """ + ) return } @@ -1369,7 +1396,7 @@ extension MenuBarItemManager { } // Remove all items up to the hidden control item. - items.trimPrefix { $0.legacyInfo != .hiddenControlItem } + items.trimPrefix { $0.tag != .hiddenControlItem } // Remove the hidden control item. items.removeFirst() @@ -1388,7 +1415,7 @@ extension MenuBarItemManager { } // Remove items until we have enough room to show this item. - items.trimPrefix { $0.frame.minX - item.frame.width <= maxX } + items.trimPrefix { $0.bounds.minX - item.bounds.width <= maxX } guard let targetItem = items.first else { let alert = NSAlert() @@ -1403,46 +1430,33 @@ extension MenuBarItemManager { let context: TempShownItemContext - if #available(macOS 26.0, *) { + if clickWhenFinished { + let beforeWindows = WindowInfo.getWindows(option: .onScreen) + await eventSleep() try await click(item: item, with: mouseButton) - await eventSleep() + await eventSleep(for: .seconds(0.25)) + + let afterWindows = WindowInfo.getWindows(option: .onScreen) + + let shownInterfaceWindow = afterWindows.first { afterWindow in + afterWindow.ownerPID == item.sourcePID && + !beforeWindows.contains { beforeWindow in + afterWindow.windowID == beforeWindow.windowID + } + } - // FIXME: Shown interface check is broken on macOS 26 (at least as of Developer Beta 1). Probably needs a significant rework. context = TempShownItemContext( - info: item.info, + tag: item.tag, returnDestination: destination, - shownInterfaceWindow: nil + shownInterfaceWindow: shownInterfaceWindow ) } else { - if clickWhenFinished { - let beforeWindows = WindowInfo.getOnScreenWindows() - - await eventSleep() - try await click(item: item, with: mouseButton) - await eventSleep(for: .milliseconds(100)) - - let afterWindows = WindowInfo.getOnScreenWindows() - - let shownInterfaceWindow = afterWindows.first { afterWindow in - afterWindow.ownerPID == item.ownerPID && - !beforeWindows.contains { beforeWindow in - afterWindow.windowID == beforeWindow.windowID - } - } - - context = TempShownItemContext( - info: item.info, - returnDestination: destination, - shownInterfaceWindow: shownInterfaceWindow - ) - } else { - context = TempShownItemContext( - info: item.info, - returnDestination: destination, - shownInterfaceWindow: nil - ) - } + context = TempShownItemContext( + tag: item.tag, + returnDestination: destination, + shownInterfaceWindow: nil + ) } return context @@ -1464,11 +1478,6 @@ extension MenuBarItemManager { /// If an item is currently showing its interface, this method waits for the /// interface to close before hiding the items. func rehideTempShownItems() async { - itemMoveCount += 1 - defer { - itemMoveCount -= 1 - } - guard !tempShownItemContexts.isEmpty else { return } @@ -1491,13 +1500,18 @@ extension MenuBarItemManager { let items = MenuBarItem.getMenuBarItems(option: .activeSpace) while let context = tempShownItemContexts.popLast() { - guard let item = items.first(where: { $0.info == context.info }) else { + guard let item = items.first(where: { $0.tag == context.tag }) else { continue } do { try await slowMove(item: item, to: context.returnDestination) } catch { - logger.error("Failed to rehide \(item.logString, privacy: .public) (error: \(error, privacy: .public))") + logger.error( + """ + Failed to rehide \(item.logString, privacy: .public) \ + (error: \(error, privacy: .public)) + """ + ) failedContexts.append(context) } await eventSleep() @@ -1516,34 +1530,32 @@ extension MenuBarItemManager { /// Removes a temporarily shown item from the cache. /// /// This ensures that the item will _not_ be returned to its previous location. - func removeTempShownItemFromCache(with info: MenuBarItemInfo) { - tempShownItemContexts.removeAll { $0.info == info } + func removeTempShownItemFromCache(with tag: MenuBarItemTag) { + tempShownItemContexts.removeAll { $0.tag == tag } } } -// MARK: - Arrange Items +// MARK: - Control Item Order extension MenuBarItemManager { /// Enforces the order of the given control items, ensuring that the always-hidden /// control item stays to the left of the hidden control item. - /// - /// - Parameters: - /// - hiddenControlItem: A menu bar item that represents the control item for the - /// hidden section. - /// - alwaysHiddenControlItem: A menu bar item that represents the control item - /// for the always-hidden section. - func enforceControlItemOrder(hiddenControlItem: MenuBarItem, alwaysHiddenControlItem: MenuBarItem) async throws { - guard !MouseEvents.isButtonPressed() else { - logger.debug("Mouse button is down, so will not enforce control item order") - return - } - guard !MouseEvents.lastMovementOccurred(within: .seconds(1)) else { - logger.debug("Mouse has recently moved, so will not enforce control item order") + private func enforceControlItemOrder(controlItems: ControlItemSet) async { + let hidden = controlItems.hidden + + guard + let alwaysHidden = controlItems.alwaysHidden, + hidden.bounds.maxX <= alwaysHidden.bounds.minX + else { return } - if hiddenControlItem.frame.maxX <= alwaysHiddenControlItem.frame.minX { - logger.info("Arranging menu bar items") - try await slowMove(item: alwaysHiddenControlItem, to: .leftOfItem(hiddenControlItem)) + + logger.info("Control items incorrectly ordered, enforcing correct order") + + do { + try await slowMove(item: alwaysHidden, to: .leftOfItem(hidden)) + } catch { + logger.error("Error enforcing control item order: \(error, privacy: .public)") } } } diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemSourceCache.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemSourceCache.swift new file mode 100644 index 000000000..fce99c957 --- /dev/null +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemSourceCache.swift @@ -0,0 +1,175 @@ +// +// MenuBarItemSourceCache.swift +// Ice +// + +import AXSwift +import Cocoa +import Combine +import OSLog + +// MARK: - MenuBarItemSourceCache + +@available(macOS 26.0, *) +enum MenuBarItemSourceCache { + private static let concurrentQueue = DispatchQueue.queue( + label: "MenuBarItemSourceCache.concurrentQueue", + qos: .userInteractive, + attributes: .concurrent + ) + + @MainActor + static func start(with permissions: AppPermissions) { + Storage.start(with: permissions) + } + + @discardableResult + private static func updateCachedPID(for window: WindowInfo) -> pid_t? { + let windowID = window.windowID + + for runningApp in Storage.getRunningApps() { + // Since we're running concurrently, we could have a pid + // at any point. + if let pid = Storage.getPID(for: windowID) { + return pid + } + + // IMPORTANT: These checks help prevent some major thread + // blocking caused by the AX APIs. + guard + runningApp.isFinishedLaunching, + !runningApp.isTerminated, + runningApp.activationPolicy != .prohibited + else { + continue + } + + guard + let app = Application(runningApp), + let bar: UIElement = try? app.attribute(.extrasMenuBar) + else { + continue + } + + for child in bar.children { + if let pid = Storage.getPID(for: windowID) { + return pid + } + + // Item window may have moved. Get the current bounds. + guard let windowBounds = Bridging.getWindowBounds(for: windowID) else { + Storage.setPID(nil, for: windowID) + return nil + } + + guard windowBounds == window.bounds else { + return nil + } + + guard + let childFrame = child.frame, + childFrame.center.distance(to: windowBounds.center) <= 10 + else { + continue + } + + let pid = runningApp.processIdentifier + Storage.setPID(pid, for: windowID) + return pid + } + } + + return nil + } + + static func getCachedPID(for window: WindowInfo) -> pid_t? { + if let pid = Storage.getPID(for: window.windowID) { + return pid + } + return concurrentQueue.sync { + updateCachedPID(for: window) + } + } +} + +// MARK: - MenuBarItemSourceCache.Storage + +@available(macOS 26.0, *) +extension MenuBarItemSourceCache { + private enum Storage { + private static let publisherQueue = DispatchQueue.queue( + label: "MenuBarItemSourceCache.Storage.publisherQueue", + qos: .userInteractive + ) + private static let pidsQueue = DispatchQueue.queue( + label: "MenuBarItemSourceCache.Storage.pidsQueue", + qos: .userInteractive + ) + private static let runningAppsQueue = DispatchQueue.queue( + label: "MenuBarItemSourceCache.Storage.runningAppsQueue", + qos: .userInteractive + ) + + private static var pids = [CGWindowID: pid_t]() + private static var runningApps = [NSRunningApplication]() + private static var cancellable: AnyCancellable? + + static func getPID(for windowID: CGWindowID) -> pid_t? { + pidsQueue.sync { pids[windowID] } + } + + static func setPID(_ pid: pid_t?, for windowID: CGWindowID) { + pidsQueue.sync { pids[windowID] = pid } + } + + static func getRunningApps() -> [NSRunningApplication] { + runningAppsQueue.sync { runningApps } + } + + @MainActor + static func start(with permissions: AppPermissions) { + cancellable = NSWorkspace.shared.publisher(for: \.runningApplications) + .receive(on: publisherQueue) + .sink { [weak permissions] runningApps in + guard + let permissions, + permissions.accessibility.hasPermission + else { + return + } + + pidsQueue.sync { + let newPIDs = Set(runningApps.map { $0.processIdentifier }) + for (key, value) in pids where !newPIDs.contains(value) { + pids.removeValue(forKey: key) + } + } + + runningAppsQueue.sync { + self.runningApps = runningApps + } + + for window in MenuBarItem.getMenuBarItemWindows(option: .activeSpace) { + concurrentQueue.async { + updateCachedPID(for: window) + } + } + } + } + } +} + +// MARK: - DispatchQueue Helper + +private extension DispatchQueue { + /// Creates and returns a new dispatch queue that targets the global + /// system queue with the specified quality-of-service class. + static func queue( + label: String, + qos: DispatchQoS.QoSClass, + attributes: Attributes = [] + ) -> DispatchQueue { + let target: DispatchQueue = .global(qos: qos) + return DispatchQueue(label: label, attributes: attributes, target: target) + } +} diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemInfo.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift similarity index 54% rename from Ice/MenuBar/MenuBarItems/MenuBarItemInfo.swift rename to Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift index f8c2470e1..6f97fb03a 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemInfo.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift @@ -1,58 +1,39 @@ // -// MenuBarItemInfo.swift +// MenuBarItemTag.swift // Ice // import CoreGraphics -// MARK: - MenuBarItemInfo - -/// A simplified version of a menu bar item. -/// -/// This type acts as a partial replacement for the original `MenuBarItemInfo` -/// type (now called ``MenuBarItemLegacyInfo``). Its purpose is to help regain -/// some of the functionality that was broken in macOS 26 Developer Beta 1. -/// -/// A value of this type functions as a unique identifier for a single menu -/// bar item, and is mainly used for caching and comparison. Currently, the -/// only information this type contains is a CGWindowID corresponding to a -/// menu bar item's window, meaning that there are still instances where the -/// legacy type is needed. However, the hope is to build up this new type, -/// so that it can eventually replace the original. -struct MenuBarItemInfo: Hashable, CustomStringConvertible { - /// The item's window identifier. - let windowID: CGWindowID - - /// A textual representation of the item. - var description: String { - String(describing: windowID) - } -} +// MARK: - MenuBarItemTag -// MARK: - MenuBarItemLegacyInfo - -/// A simplified version of a menu bar item that was used as the primary way -/// to identify menu bar items until macOS 26 (Developer Beta 1). -/// -/// See ``MenuBarItemInfo`` documentation for more details. -struct MenuBarItemLegacyInfo: Hashable, CustomStringConvertible { - /// The namespace of the info's item. +/// An identifier for a menu bar item. +struct MenuBarItemTag: Hashable, CustomStringConvertible { + /// The namespace of the item identified by this tag. let namespace: Namespace - /// The title of the info's item. + /// The title of the item identified by this tag. let title: String - /// A Boolean value that indicates whether the info's item can be moved. + /// A Boolean value that indicates whether the item identified + /// by this tag can be moved. var isMovable: Bool { - !MenuBarItemLegacyInfo.immovableItems.contains(self) + !MenuBarItemTag.immovableItems.contains(self) } - /// A Boolean value that indicates whether the info's item can be hidden. + /// A Boolean value that indicates whether the item identified + /// by this tag can be hidden. var canBeHidden: Bool { - !MenuBarItemLegacyInfo.nonHideableItems.contains(self) + !MenuBarItemTag.nonHideableItems.contains(self) + } + + /// A Boolean value that indicates whether the item identified + /// by this tag is a control item owned by Ice. + var isControlItem: Bool { + MenuBarItemTag.controlItems.contains(self) } - /// A string representation of the info. + /// A string representation of the tag. var stringValue: String { var result = namespace.rawValue if !title.isEmpty { @@ -61,35 +42,31 @@ struct MenuBarItemLegacyInfo: Hashable, CustomStringConvertible { return result } - /// A textual representation of the info. + /// A textual representation of the tag. var description: String { stringValue } - /// Creates info with the given namespace and title. + /// Creates a tag with the given namespace and title. init(namespace: Namespace, title: String) { self.namespace = namespace self.title = title } - /// Creates info for the control item with the given identifier. + /// Creates a tag for the control item with the given identifier. private init(controlItem identifier: ControlItem.Identifier) { - if #available(macOS 26.0, *) { - self.init(namespace: .controlCenter, title: identifier.rawValue) - } else { - self.init(namespace: .ice, title: identifier.rawValue) - } + self.init(namespace: .ice, title: identifier.rawValue) } } -// MARK: MenuBarItemLegacyInfo Constants +// MARK: MenuBarItemTag Constants -extension MenuBarItemLegacyInfo { +extension MenuBarItemTag { // MARK: Special Item Lists - /// An array of infos for items whose movement is prevented by macOS. - static let immovableItems: [MenuBarItemLegacyInfo] = { + /// An array of tags for items whose movement is prevented by macOS. + static let immovableItems: [MenuBarItemTag] = { var items = [clock, controlCenter] if #unavailable(macOS 26.0) { items.append(siri) @@ -105,8 +82,8 @@ extension MenuBarItemLegacyInfo { // // We're using macOS 15.3.2 for now, but it could be earlier. // - /// An array of infos for items that can be moved, but cannot be hidden. - static let nonHideableItems: [MenuBarItemLegacyInfo] = { + /// An array of tags for items that can be moved, but cannot be hidden. + static let nonHideableItems: [MenuBarItemTag] = { var items = [audioVideoModule, faceTime, screenCaptureUI] if #unavailable(macOS 15.3.2) { items.append(musicRecognition) @@ -114,61 +91,61 @@ extension MenuBarItemLegacyInfo { return items }() - /// An array of infos for items representing Ice's control items. - static let controlItems = ControlItem.Identifier.allCases.map { $0.legacyInfo } + /// An array of tags for items representing Ice's control items. + static let controlItems = ControlItem.Identifier.allCases.map { $0.tag } // MARK: Control Items - /// Info for the control item for the visible section. - static let iceIcon = MenuBarItemLegacyInfo(controlItem: .iceIcon) + /// A tag for the control item for the visible section. + static let visibleControlItem = MenuBarItemTag(controlItem: .visible) - /// Info for the control item for the hidden section. - static let hiddenControlItem = MenuBarItemLegacyInfo(controlItem: .hidden) + /// A tag for the control item for the hidden section. + static let hiddenControlItem = MenuBarItemTag(controlItem: .hidden) - /// Info for the control item for the always-hidden section. - static let alwaysHiddenControlItem = MenuBarItemLegacyInfo(controlItem: .alwaysHidden) + /// A tag for the control item for the always-hidden section. + static let alwaysHiddenControlItem = MenuBarItemTag(controlItem: .alwaysHidden) // MARK: Other System Items - /// Info for the "Clock" item. - static let clock = MenuBarItemLegacyInfo(namespace: .controlCenter, title: "Clock") + /// A tag for the "Clock" item. + static let clock = MenuBarItemTag(namespace: .controlCenter, title: "Clock") - /// Info for the "Siri" item. - static let siri: MenuBarItemLegacyInfo = { + /// A tag for the "Siri" item. + static let siri: MenuBarItemTag = { if #available(macOS 26.0, *) { - MenuBarItemLegacyInfo(namespace: .controlCenter, title: "Siri") + MenuBarItemTag(namespace: .controlCenter, title: "Siri") } else { - MenuBarItemLegacyInfo(namespace: .systemUIServer, title: "Siri") + MenuBarItemTag(namespace: .systemUIServer, title: "Siri") } }() - /// Info for the "Control Center" item. - static let controlCenter: MenuBarItemLegacyInfo = { + /// A tag for the "Control Center" item. + static let controlCenter: MenuBarItemTag = { if #available(macOS 26.0, *) { - MenuBarItemLegacyInfo(namespace: .controlCenter, title: "BentoBox-0") + MenuBarItemTag(namespace: .controlCenter, title: "BentoBox-0") } else { - MenuBarItemLegacyInfo(namespace: .controlCenter, title: "BentoBox") + MenuBarItemTag(namespace: .controlCenter, title: "BentoBox") } }() - /// Info for the item that appears in the menu bar while the screen or system - /// audio is being recorded. - static let audioVideoModule = MenuBarItemLegacyInfo(namespace: .controlCenter, title: "AudioVideoModule") + /// A tag for the item that appears in the menu bar while the screen + /// or system audio is being recorded. + static let audioVideoModule = MenuBarItemTag(namespace: .controlCenter, title: "AudioVideoModule") - /// Info for the "FaceTime" item. - static let faceTime = MenuBarItemLegacyInfo(namespace: .controlCenter, title: "FaceTime") + /// A tag for the "FaceTime" item. + static let faceTime = MenuBarItemTag(namespace: .controlCenter, title: "FaceTime") - /// Info for the "MusicRecognition" (a.k.a. "Shazam") item. - static let musicRecognition = MenuBarItemLegacyInfo(namespace: .controlCenter, title: "MusicRecognition") + /// A tag for the "MusicRecognition" (a.k.a. "Shazam") item. + static let musicRecognition = MenuBarItemTag(namespace: .controlCenter, title: "MusicRecognition") // TODO: How do we reference this item in macOS 26? - /// Info for the "stop recording" item that appears in the menu bar during screen - /// recordings started by the macOS "Screenshot" tool. - static let screenCaptureUI = MenuBarItemLegacyInfo(namespace: .screenCaptureUI, title: "Item-0") + /// A tag for the "stop recording" item that appears in the menu bar + /// during screen recordings started by the macOS "Screenshot" tool. + static let screenCaptureUI = MenuBarItemTag(namespace: .screenCaptureUI, title: "Item-0") } -// MARK: MenuBarItemLegacyInfo: Codable -extension MenuBarItemLegacyInfo: Codable { +// MARK: MenuBarItemTag: Codable +extension MenuBarItemTag: Codable { init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() let string = try container.decode(String.self) @@ -199,9 +176,9 @@ extension MenuBarItemLegacyInfo: Codable { } } -// MARK: - MenuBarItemLegacyInfo.Namespace +// MARK: - MenuBarItemTag.Namespace -extension MenuBarItemLegacyInfo { +extension MenuBarItemTag { /// A type that represents a menu bar item namespace. struct Namespace: Codable, Hashable, RawRepresentable, CustomStringConvertible { /// Private representation of a namespace. @@ -266,8 +243,8 @@ extension MenuBarItemLegacyInfo { } } -// MARK: MenuBarItemLegacyInfo.Namespace Constants -extension MenuBarItemLegacyInfo.Namespace { +// MARK: MenuBarItemTag.Namespace Constants +extension MenuBarItemTag.Namespace { /// The namespace for menu bar items owned by Ice. static let ice = Self(Constants.bundleIdentifier) diff --git a/Ice/MenuBar/MenuBarManager.swift b/Ice/MenuBar/MenuBarManager.swift index 1af94966e..7f89357d4 100644 --- a/Ice/MenuBar/MenuBarManager.swift +++ b/Ice/MenuBar/MenuBarManager.swift @@ -178,7 +178,7 @@ final class MenuBarManager: ObservableObject { return } - if sections.contains(where: { $0.controlItem.state == .showItems }) { + if sections.contains(where: { $0.controlItem.state == .showSection }) { guard let screen = NSScreen.main else { return } @@ -198,25 +198,25 @@ final class MenuBarManager: ObservableObject { let alwaysHiddenSection = section(withName: .alwaysHidden), alwaysHiddenSection.isEnabled { - if alwaysHiddenSection.controlItem.state == .hideItems { + if alwaysHiddenSection.controlItem.state == .hideSection { if let alwaysHiddenControlItem = items.firstIndex(matching: .alwaysHiddenControlItem).map({ items.remove(at: $0) }) { - items.trimPrefix { $0.frame.maxX <= alwaysHiddenControlItem.frame.minX } + items.trimPrefix { $0.bounds.maxX <= alwaysHiddenControlItem.bounds.minX } } } } else { if let hiddenControlItem = items.firstIndex(matching: .hiddenControlItem).map({ items.remove(at: $0) }) { - items.trimPrefix { $0.frame.maxX <= hiddenControlItem.frame.minX } + items.trimPrefix { $0.bounds.maxX <= hiddenControlItem.bounds.minX } } } // Get the leftmost item on the screen. - guard let leftmostItem = items.min(by: { $0.frame.minX < $1.frame.minX }) else { + guard let leftmostItem = items.min(by: { $0.bounds.minX < $1.bounds.minX }) else { return } // If the minX of the item is less than or equal to the maxX of the // application menu frame, activate the app to hide the menu. - if leftmostItem.frame.minX <= applicationMenuFrame.maxX { + if leftmostItem.bounds.minX <= applicationMenuFrame.maxX { hideApplicationMenus() } } else if isHidingApplicationMenus { @@ -241,12 +241,12 @@ final class MenuBarManager: ObservableObject { let image: CGImage? let source: MenuBarAverageColorInfo.Source - let windows = WindowInfo.getOnScreenWindows(excludeDesktopWindows: false) + let windows = WindowInfo.getWindows(option: .onScreen) let displayID = screen.displayID if #available(macOS 26.0, *) { if let window = WindowInfo.getWallpaperWindow(from: windows, for: displayID) { - var bounds = window.frame + var bounds = window.bounds bounds.size.height = 1 bounds.origin.x = bounds.midX bounds.size.width /= 2 @@ -258,7 +258,7 @@ final class MenuBarManager: ObservableObject { } } else { if let window = WindowInfo.getMenuBarWindow(from: windows, for: displayID) { - var bounds = window.frame + var bounds = window.bounds bounds.size.height = 1 bounds.origin.x = bounds.maxX - (bounds.width / 4) bounds.size.width /= 4 @@ -266,7 +266,7 @@ final class MenuBarManager: ObservableObject { image = ScreenCapture.captureWindow(window.windowID, screenBounds: bounds, option: .nominalResolution) source = .menuBarWindow } else if let window = WindowInfo.getWallpaperWindow(from: windows, for: displayID) { - var bounds = window.frame + var bounds = window.bounds bounds.size.height = 1 bounds.origin.x = bounds.midX bounds.size.width /= 2 @@ -298,7 +298,7 @@ final class MenuBarManager: ObservableObject { guard let menuBarWindow = WindowInfo.getMenuBarWindow(from: windows, for: display) else { return false } - let position = menuBarWindow.frame.origin + let position = menuBarWindow.bounds.origin do { let uiElement = try systemWideElement.elementAtPosition(Float(position.x), Float(position.y)) return try uiElement?.role() == .menuBar diff --git a/Ice/MenuBar/MenuBarSection.swift b/Ice/MenuBar/MenuBarSection.swift index 85aa4d3ba..6cf98bcd2 100644 --- a/Ice/MenuBar/MenuBarSection.swift +++ b/Ice/MenuBar/MenuBarSection.swift @@ -74,7 +74,7 @@ final class MenuBarSection { /// A Boolean value that indicates whether the section is hidden. var isHidden: Bool { if useIceBar { - if controlItem.state == .showItems { + if controlItem.state == .showSection { return false } switch name { @@ -89,12 +89,12 @@ final class MenuBarSection { if menuBarManager?.iceBarPanel.currentSection == .hidden { return false } - return controlItem.state == .hideItems + return controlItem.state == .hideSection case .alwaysHidden: if menuBarManager?.iceBarPanel.currentSection == .alwaysHidden { return false } - return controlItem.state == .hideItems + return controlItem.state == .hideSection } } @@ -117,7 +117,7 @@ final class MenuBarSection { convenience init(name: Name) { let controlItem = switch name { case .visible: - ControlItem(identifier: .iceIcon) + ControlItem(identifier: .visible) case .hidden: ControlItem(identifier: .hidden) case .alwaysHidden: @@ -133,7 +133,7 @@ final class MenuBarSection { } /// Shows the section. - func show() async { + func show() { guard let menuBarManager, isHidden else { return } @@ -144,54 +144,51 @@ final class MenuBarSection { return } - defer { - startRehideChecks() - } - if useIceBar { + // Make sure hidden and always-hidden control items are collapsed. + // Still update the visible control item (Ice icon) state to show + // its alternate icon. for section in menuBarManager.sections { - section.controlItem.state = switch section.name { - case .visible: .showItems - default: .hideItems + switch section.name { + case .visible: + section.controlItem.state = .showSection + case .hidden, .alwaysHidden: + section.controlItem.state = .hideSection } } + if let screen = screenForIceBar { - switch name { - case .visible, .hidden: - await menuBarManager.iceBarPanel.show(section: .hidden, on: screen) - case .alwaysHidden: - await menuBarManager.iceBarPanel.show(section: .alwaysHidden, on: screen) + Task(timeout: .seconds(3)) { + switch name { + case .visible, .hidden: + await menuBarManager.iceBarPanel.show(section: .hidden, on: screen) + case .alwaysHidden: + await menuBarManager.iceBarPanel.show(section: .alwaysHidden, on: screen) + } + try Task.checkCancellation() + startRehideChecks() } } - } else { - // Make sure the Ice bar is closed. - menuBarManager.iceBarPanel.close() - var controlItems = [ControlItem]() - switch name { - case .visible: - if let hiddenControlItem = menuBarManager.controlItem(withName: .hidden) { - controlItems.append(controlItem) - controlItems.append(hiddenControlItem) - } - case .hidden: - if let visibleControlItem = menuBarManager.controlItem(withName: .visible) { - controlItems.append(controlItem) - controlItems.append(visibleControlItem) - } - case .alwaysHidden: - if - let hiddenControlItem = menuBarManager.controlItem(withName: .hidden), - let visibleControlItem = menuBarManager.controlItem(withName: .visible) - { - controlItems.append(controlItem) - controlItems.append(hiddenControlItem) - controlItems.append(visibleControlItem) - } + + return // We're done. + } + + // If we made it here, we're not using the Ice Bar. + // Make sure it's closed. + menuBarManager.iceBarPanel.close() + + switch name { + case .visible, .hidden: + for section in menuBarManager.sections where section.name != .alwaysHidden { + section.controlItem.state = .showSection } - for controlItem in controlItems { - controlItem.state = .showItems + case .alwaysHidden: + for section in menuBarManager.sections { + section.controlItem.state = .showSection } } + + startRehideChecks() } /// Hides the section. @@ -199,47 +196,25 @@ final class MenuBarSection { guard let menuBarManager, !isHidden else { return } - // Make sure the Ice bar is always closed. - menuBarManager.iceBarPanel.close() + + menuBarManager.iceBarPanel.close() // Make sure Ice Bar is always closed. + menuBarManager.showOnHoverAllowed = true + switch name { - case _ where useIceBar: + case _ where useIceBar, .visible, .hidden: for section in menuBarManager.sections { - section.controlItem.state = .hideItems - } - case .visible: - guard - let hiddenSection = menuBarManager.section(withName: .hidden), - let alwaysHiddenSection = menuBarManager.section(withName: .alwaysHidden) - else { - return - } - controlItem.state = .hideItems - hiddenSection.controlItem.state = .hideItems - alwaysHiddenSection.controlItem.state = .hideItems - case .hidden: - guard - let visibleSection = menuBarManager.section(withName: .visible), - let alwaysHiddenSection = menuBarManager.section(withName: .alwaysHidden) - else { - return + section.controlItem.state = .hideSection } - controlItem.state = .hideItems - visibleSection.controlItem.state = .hideItems - alwaysHiddenSection.controlItem.state = .hideItems case .alwaysHidden: - controlItem.state = .hideItems + controlItem.state = .hideSection } - menuBarManager.showOnHoverAllowed = true + stopRehideChecks() } /// Toggles the visibility of the section. - func toggle() async { - if isHidden { - await show() - } else { - hide() - } + func toggle() { + if isHidden { show() } else { hide() } } /// Starts running checks to determine when to rehide the section. diff --git a/Ice/MenuBar/Search/MenuBarSearchPanel.swift b/Ice/MenuBar/Search/MenuBarSearchPanel.swift index cd513310d..cb62e2c99 100644 --- a/Ice/MenuBar/Search/MenuBarSearchPanel.swift +++ b/Ice/MenuBar/Search/MenuBarSearchPanel.swift @@ -23,17 +23,15 @@ final class MenuBarSearchPanel: NSPanel { /// Monitor for mouse down events. private lazy var mouseDownMonitor = UniversalEventMonitor( mask: [.leftMouseDown, .rightMouseDown, .otherMouseDown] - ) { [weak self, weak appState] event in + ) { [weak self] event in guard let self, - let appState, - event.window !== self + event.window !== self, + Bridging.getWindowLevel(for: CGWindowID(event.windowNumber)) != kCGStatusWindowLevel else { return event } - if !appState.itemManager.isMovingItem { - close() - } + close() return event } @@ -97,7 +95,7 @@ final class MenuBarSearchPanel: NSPanel { } /// Shows the search panel on the given screen. - func show(on screen: NSScreen) async { + func show(on screen: NSScreen) { guard let appState else { return } @@ -105,35 +103,35 @@ final class MenuBarSearchPanel: NSPanel { // Important that we set the navigation state before updating the cache. appState.navigationState.isSearchPresented = true - if ScreenCapture.cachedCheckPermissions() { + Task { await appState.imageCache.updateCache() - } - let hostingView = MenuBarSearchHostingView(appState: appState, displayID: screen.displayID, panel: self) - hostingView.setFrameSize(hostingView.intrinsicContentSize) - setFrame(hostingView.frame, display: true) + let hostingView = MenuBarSearchHostingView(appState: appState, displayID: screen.displayID, panel: self) + hostingView.setFrameSize(hostingView.intrinsicContentSize) + setFrame(hostingView.frame, display: true) - contentView = hostingView + contentView = hostingView - // Calculate the top left position. - let topLeft = CGPoint( - x: screen.frame.midX - frame.width / 2, - y: screen.frame.midY + (frame.height / 2) + (screen.frame.height / 8) - ) + // Calculate the top left position. + let topLeft = CGPoint( + x: screen.frame.midX - frame.width / 2, + y: screen.frame.midY + (frame.height / 2) + (screen.frame.height / 8) + ) - cascadeTopLeft(from: topLeft) - makeKeyAndOrderFront(nil) + cascadeTopLeft(from: topLeft) + makeKeyAndOrderFront(nil) - mouseDownMonitor.start() - keyDownMonitor.start() + mouseDownMonitor.start() + keyDownMonitor.start() + } } /// Toggles the panel's visibility. - func toggle() async { + func toggle() { if isVisible { close() } else if let screen = MenuBarSearchPanel.defaultScreen { - await show(on: screen) + show(on: screen) } } @@ -184,7 +182,7 @@ private struct MenuBarSearchContentView: View { private enum ItemID: Hashable { case header(MenuBarSection.Name) - case item(MenuBarItemInfo) + case item(MenuBarItemTag) } @EnvironmentObject var itemManager: MenuBarItemManager @@ -296,7 +294,7 @@ private struct MenuBarSearchContentView: View { items.append((headerItem, section.displayString)) for item in itemManager.itemCache.managedItems(for: section).reversed() { - let listItem = ListItem.item(id: .item(item.info)) { + let listItem = ListItem.item(id: .item(item.tag)) { performAction(for: item) } content: { MenuBarSearchItemView(item: item) @@ -321,10 +319,8 @@ private struct MenuBarSearchContentView: View { private func menuBarItem(for selection: ItemID) -> MenuBarItem? { switch selection { - case .item(let info): - itemManager.itemCache.managedItems.first { $0.info == info } - case .header: - nil + case .item(let tag): itemManager.itemCache.managedItems.first(matching: tag) + case .header: nil } } @@ -458,24 +454,32 @@ private struct MenuBarSearchItemView: View { private var image: NSImage { guard - let image = imageCache.images[item.info]?.trimmingTransparentPixels(around: [.minXEdge, .maxXEdge]), - let screen = imageCache.screen + let cachedImage = imageCache.images[item.tag], + let trimmedImage = cachedImage.cgImage.trimmingTransparentPixels(around: [.minXEdge, .maxXEdge]) else { return NSImage() } let size = CGSize( - width: CGFloat(image.width) / screen.backingScaleFactor, - height: CGFloat(image.height) / screen.backingScaleFactor + width: CGFloat(trimmedImage.width) / cachedImage.scale, + height: CGFloat(trimmedImage.height) / cachedImage.scale ) - return NSImage(cgImage: image, size: size) + return NSImage(cgImage: trimmedImage, size: size) } private var appIcon: NSImage { - if item.legacyInfo.namespace == .systemUIServer { - controlCenterIcon ?? NSImage() - } else { - item.owningApplication?.icon ?? NSImage() + if + item.tag.namespace == .systemUIServer, + let icon = controlCenterIcon + { + return icon + } + if let icon = item.sourceApplication?.icon { + return icon + } + if let icon = item.owningApplication?.icon { + return icon } + return NSImage() } private var backgroundShape: some InsettableShape { @@ -538,6 +542,6 @@ private struct MenuBarSearchItemView: View { @ViewBuilder private var imageView: some View { Image(nsImage: image) - .frame(width: item.frame.width, height: size) + .frame(width: item.bounds.width, height: size) } } diff --git a/Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift b/Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift index 518c9c84e..321ae2919 100644 --- a/Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift +++ b/Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift @@ -159,7 +159,7 @@ final class MenuBarItemSpacingManager { try? await Task.sleep(for: .milliseconds(100)) let items = MenuBarItem.getMenuBarItems(option: .activeSpace) - let pids = Set(items.map { $0.ownerPID }) + let pids = Set(items.map { $0.sourcePID ?? $0.ownerPID }) var failedApps = [String]() diff --git a/Ice/Permissions/PermissionsManager.swift b/Ice/Permissions/AppPermissions.swift similarity index 84% rename from Ice/Permissions/PermissionsManager.swift rename to Ice/Permissions/AppPermissions.swift index 7f64ddccf..1faf04d77 100644 --- a/Ice/Permissions/PermissionsManager.swift +++ b/Ice/Permissions/AppPermissions.swift @@ -1,5 +1,5 @@ // -// PermissionsManager.swift +// AppPermissions.swift // Ice // @@ -9,7 +9,13 @@ import OSLog /// A type that manages the permissions of the app. @MainActor -final class PermissionsManager: ObservableObject { +final class AppPermissions: ObservableObject { + /// Keys to access individual permissions. + enum PermissionKey { + case accessibility + case screenRecording + } + /// The state of the app's granted permissions. enum PermissionsState { case missing @@ -21,10 +27,10 @@ final class PermissionsManager: ObservableObject { let logger = Logger(category: "Permissions") /// The permission for Accessibility features. - let accessibilityPermission = AccessibilityPermission() + let accessibility = AccessibilityPermission() /// The permission for Screen Recording features. - let screenRecordingPermission = ScreenRecordingPermission() + let screenRecording = ScreenRecordingPermission() /// The state of the app's granted permissions. @Published private(set) var permissionsState: PermissionsState = .missing @@ -34,7 +40,7 @@ final class PermissionsManager: ObservableObject { /// The permissions required for full app functionality. var allPermissions: [Permission] { - [accessibilityPermission, screenRecordingPermission] + [accessibility, screenRecording] } /// The permissions required for basic app functionality. diff --git a/Ice/Permissions/Permission.swift b/Ice/Permissions/Permission.swift index 62d2b7813..3b8b0c22e 100644 --- a/Ice/Permissions/Permission.swift +++ b/Ice/Permissions/Permission.swift @@ -19,20 +19,25 @@ class Permission: ObservableObject, Identifiable { /// The title of the permission. let title: String + /// Descriptive details for the permission. let details: [String] + /// A Boolean value that indicates if the app can work without this permission. let isRequired: Bool /// The URL of the settings pane to open. private let settingsURL: URL? + /// The function that checks permissions. private let check: () -> Bool + /// The function that requests permissions. private let request: () -> Void /// Observer that runs on a timer to check permissions. private var timerCancellable: AnyCancellable? + /// Observer that observes the ``hasPermission`` property. private var hasPermissionCancellable: AnyCancellable? diff --git a/Ice/Permissions/PermissionsView.swift b/Ice/Permissions/PermissionsView.swift index 225d4a788..669a7ce25 100644 --- a/Ice/Permissions/PermissionsView.swift +++ b/Ice/Permissions/PermissionsView.swift @@ -7,7 +7,7 @@ import SwiftUI struct PermissionsView: View { @EnvironmentObject var appState: AppState - @EnvironmentObject var manager: PermissionsManager + @EnvironmentObject var manager: AppPermissions private var continueButtonText: LocalizedStringKey { if case .hasRequired = manager.permissionsState { diff --git a/Ice/Permissions/PermissionsWindow.swift b/Ice/Permissions/PermissionsWindow.swift index 5cec890c3..5112a6b31 100644 --- a/Ice/Permissions/PermissionsWindow.swift +++ b/Ice/Permissions/PermissionsWindow.swift @@ -32,6 +32,6 @@ struct PermissionsWindow: Scene { .windowResizability(.contentSize) .windowStyle(.hiddenTitleBar) .environmentObject(appState) - .environmentObject(appState.permissionsManager) + .environmentObject(appState.permissions) } } diff --git a/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift b/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift index 8d4603f4d..55f5de1e7 100644 --- a/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift @@ -95,7 +95,7 @@ struct AdvancedSettingsPane: View { Text( """ Right-click in an empty area of the menu bar to display a minimal \ - version of Ice's menu. Disable this if you experience conflicts with \ + version of Ice's menu. Disable this if you encounter conflicts with \ other apps. """ ) @@ -143,7 +143,7 @@ struct AdvancedSettingsPane: View { @ViewBuilder private var allPermissions: some View { - ForEach(appState.permissionsManager.allPermissions) { permission in + ForEach(appState.permissions.allPermissions) { permission in IceLabeledContent { if permission.hasPermission { Label { diff --git a/Ice/Utilities/Constants.swift b/Ice/Utilities/Constants.swift index 0c70af903..8842cac04 100644 --- a/Ice/Utilities/Constants.swift +++ b/Ice/Utilities/Constants.swift @@ -7,6 +7,7 @@ import Foundation enum Constants { // swiftlint:disable force_unwrapping + /// The version string in the app's bundle. static let versionString = Bundle.main.versionString! @@ -16,19 +17,11 @@ enum Constants { /// The user-readable copyright string in the app's bundle. static let copyrightString = Bundle.main.copyrightString! - /// The bundle identifier of the app. + /// The app's bundle identifier. static let bundleIdentifier = Bundle.main.bundleIdentifier! - // swiftlint:enable force_unwrapping - - /// The identifier for the settings window. - static let settingsWindowID = "SettingsWindow" - /// The identifier for the permissions window. - static let permissionsWindowID = "PermissionsWindow" + /// The app's display name. + static let displayName = Bundle.main.displayName! - /// The title for the settings window. - static let settingsWindowTitle = "Ice" - - /// The title for the permissions window. - static let permissionsWindowTitle = "Permissions" + // swiftlint:enable force_unwrapping } diff --git a/Ice/Utilities/Defaults.swift b/Ice/Utilities/Defaults.swift index c82c13507..f74926d14 100644 --- a/Ice/Utilities/Defaults.swift +++ b/Ice/Utilities/Defaults.swift @@ -182,6 +182,7 @@ extension Defaults { case hasMigrated0_10_1 = "hasMigrated0_10_1" case hasMigrated0_11_10 = "hasMigrated0_11_10" case hasMigrated0_11_13 = "hasMigrated0_11_13" + case hasMigrated0_11_13_1 = "hasMigrated0_11_13_1" // MARK: Deprecated (Menu Bar Appearance) case menuBarHasBorder = "MenuBarHasBorder" diff --git a/Ice/Utilities/Extensions.swift b/Ice/Utilities/Extensions.swift index 984052510..ba61137cd 100644 --- a/Ice/Utilities/Extensions.swift +++ b/Ice/Utilities/Extensions.swift @@ -3,6 +3,7 @@ // Ice // +import AXSwift import Combine import SwiftUI @@ -11,27 +12,38 @@ import SwiftUI extension Bundle { /// The bundle's copyright string. /// - /// This accessor looks for an associated value for the "NSHumanReadableCopyright" - /// key in the bundle's Info.plist. If a string value cannot be found for this key, - /// this accessor returns `nil`. + /// This accessor checks the bundle's `Info.plist` for a string value associated + /// with the "NSHumanReadableCopyright" key. If a valid value cannot be found for + /// the key, this accessor returns `nil`. var copyrightString: String? { object(forInfoDictionaryKey: "NSHumanReadableCopyright") as? String } + /// The bundle's display name. + /// + /// This accessor checks the bundle's `Info.plist` for a string value associated + /// with the "CFBundleDisplayName" key. If a valid value cannot be found for the + /// key, the same check is performed for the "CFBundleName" key. If a valid value + /// cannot be found for either key, this accessor returns `nil`. + var displayName: String? { + object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? + object(forInfoDictionaryKey: "CFBundleName") as? String + } + /// The bundle's version string. /// - /// This accessor looks for an associated value for the "CFBundleShortVersionString" - /// key in the bundle's Info.plist. If a string value cannot be found for this key, - /// this accessor returns `nil`. + /// This accessor checks the bundle's `Info.plist` for a string value associated + /// with the "CFBundleShortVersionString" key. If a valid value cannot be found + /// for the key, this accessor returns `nil`. var versionString: String? { object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String } /// The bundle's build string. /// - /// This accessor looks for an associated value for the "CFBundleVersion" key in - /// the bundle's Info.plist. If a string value cannot be found for this key, this - /// accessor returns `nil`. + /// This accessor checks the bundle's `Info.plist` for a string value associated + /// with the "CFBundleVersion" key. If a valid value cannot be found for the key, + /// this accessor returns `nil`. var buildString: String? { object(forInfoDictionaryKey: "CFBundleVersion") as? String } @@ -311,19 +323,31 @@ extension CGImage { } } -// MARK: - Collection where Element == MenuBarItem +// MARK: - CGPoint -extension Collection where Element == MenuBarItem { - /// Returns the first index where the menu bar item matching the specified - /// info appears in the collection. - func firstIndex(matching info: MenuBarItemInfo) -> Index? { - firstIndex { $0.info == info } +extension CGPoint { + /// Returns the distance between this point and another point. + func distance(to other: CGPoint) -> CGFloat { + hypot(x - other.x, y - other.y) } +} +// MARK: - CGRect + +extension CGRect { + /// The center point of the rectangle. + var center: CGPoint { + CGPoint(x: midX, y: midY) + } +} + +// MARK: - Collection where Element == MenuBarItem + +extension Collection where Element == MenuBarItem { /// Returns the first index where the menu bar item matching the specified - /// legacy info appears in the collection. - func firstIndex(matching info: MenuBarItemLegacyInfo) -> Index? { - firstIndex { $0.legacyInfo == info } + /// tag appears in the collection. + func firstIndex(matching tag: MenuBarItemTag) -> Index? { + firstIndex { $0.tag == tag } } } @@ -462,7 +486,7 @@ extension NSScreen { /// Returns the height of the menu bar on this screen. func getMenuBarHeight() -> CGFloat? { let menuBarWindow = WindowInfo.getMenuBarWindow(for: displayID) - return menuBarWindow?.frame.height + return menuBarWindow?.bounds.height } } @@ -528,16 +552,62 @@ extension Publisher where Output: Sequence, Failure == Never { } } +// MARK: - RangeReplaceableCollection where Element: Hashable + +extension RangeReplaceableCollection where Element: Hashable { + /// Returns a copy of the collection with duplicate values removed. + func removingDuplicates() -> Self { + var seen = Set() + return filter { seen.insert($0).inserted } + } + + /// Removes duplicate values from the collection. + mutating func removeDuplicates() { + self = self.removingDuplicates() + } +} + +// MARK: - RangeReplaceableCollection where Element == MenuBarItem + +extension RangeReplaceableCollection where Element == MenuBarItem { + /// Removes and returns the first menu bar item that matches the + /// specified tag. + mutating func removeFirst(matching tag: MenuBarItemTag) -> MenuBarItem? { + guard let index = firstIndex(matching: tag) else { + return nil + } + return remove(at: index) + } +} + // MARK: - Sequence where Element == MenuBarItem extension Sequence where Element == MenuBarItem { - /// Returns the first menu bar item that matches the specified info. - func first(matching info: MenuBarItemInfo) -> MenuBarItem? { - first { $0.info == info } + /// Returns the first menu bar item that matches the specified tag. + func first(matching tag: MenuBarItemTag) -> MenuBarItem? { + first { $0.tag == tag } + } +} + +// MARK: - SystemWideElement + +extension SystemWideElement { + /// Returns the element at the specified top-down coordinates. + func elementAtPosition(_ point: CGPoint) throws -> UIElement? { + try elementAtPosition(Float(point.x), Float(point.y)) + } +} + +// MARK: - UIElement + +extension UIElement { + /// The element's frame. + var frame: CGRect? { + try? attribute(.frame) } - /// Returns the first menu bar item that matches the specified legacy info. - func first(matching info: MenuBarItemLegacyInfo) -> MenuBarItem? { - first { $0.legacyInfo == info } + /// The element's child elements. + var children: [UIElement] { + (try? arrayAttribute(.children)) ?? [] } } diff --git a/Ice/Utilities/MigrationManager.swift b/Ice/Utilities/Migration.swift similarity index 85% rename from Ice/Utilities/MigrationManager.swift rename to Ice/Utilities/Migration.swift index a1b557e86..4b1012f8d 100644 --- a/Ice/Utilities/MigrationManager.swift +++ b/Ice/Utilities/Migration.swift @@ -1,5 +1,5 @@ // -// MigrationManager.swift +// Migration.swift // Ice // @@ -39,6 +39,7 @@ extension MigrationManager { migrate0_10_1(), migrate0_11_10(), migrate0_11_13(), + migrate0_11_13_1(), ] for result in results { @@ -93,7 +94,7 @@ extension MigrationManager { // to the corresponding hotkeys for name: MenuBarSection.Name in [.hidden, .alwaysHidden] { guard - let sectionDict = sectionsArray.first(where: { $0["name"] as? String == name.deprecatedRawValue }), + let sectionDict = sectionsArray.first(where: { $0["name"] as? String == name.rawValue0_8_0 }), let hotkeyDict = sectionDict["hotkey"] as? [String: Int], let key = hotkeyDict["key"], let modifiers = hotkeyDict["modifiers"] @@ -136,7 +137,7 @@ extension MigrationManager { for name in MenuBarSection.Name.allCases { guard - var sectionDict = sectionsArray.first(where: { $0["name"] as? String == name.deprecatedRawValue }), + var sectionDict = sectionsArray.first(where: { $0["name"] as? String == name.rawValue0_8_0 }), var controlItemDict = sectionDict["controlItem"] as? [String: Any], // remove the "autosaveName" key from the dictionary let autosaveName = controlItemDict.removeValue(forKey: "autosaveName") as? String @@ -146,19 +147,19 @@ extension MigrationManager { let identifier = switch name { case .visible: - ControlItem.Identifier.iceIcon.deprecatedRawValue + ControlItem.Identifier.visible.rawValue0_8_0 case .hidden: - ControlItem.Identifier.hidden.deprecatedRawValue + ControlItem.Identifier.hidden.rawValue0_8_0 case .alwaysHidden: - ControlItem.Identifier.alwaysHidden.deprecatedRawValue + ControlItem.Identifier.alwaysHidden.rawValue0_8_0 } // add the "identifier" key to the dictionary controlItemDict["identifier"] = identifier // migrate the old autosave name to the new autosave name in UserDefaults - StatusItemDefaults.migrate(key: .preferredPosition, from: autosaveName, to: identifier) - StatusItemDefaults.migrate(key: .visible, from: autosaveName, to: identifier) + ControlItemDefaults.migrate(key: .preferredPosition, from: autosaveName, to: identifier) + ControlItemDefaults.migrate(key: .visible, from: autosaveName, to: identifier) // replace the old "controlItem" dictionary with the new one sectionDict["controlItem"] = controlItemDict @@ -198,10 +199,10 @@ extension MigrationManager { private func migrateControlItems0_10_0() { for identifier in ControlItem.Identifier.allCases { - StatusItemDefaults.migrate( + ControlItemDefaults.migrate( key: .preferredPosition, - from: identifier.deprecatedRawValue, - to: identifier.rawValue + from: identifier.rawValue0_8_0, + to: identifier.rawValue0_10_0 ) } } @@ -231,17 +232,17 @@ extension MigrationManager { for identifier in ControlItem.Identifier.allCases { if - StatusItemDefaults[.visible, identifier.rawValue] == false, - StatusItemDefaults[.preferredPosition, identifier.rawValue] == nil + ControlItemDefaults[.visible, identifier.rawValue0_10_0] == false, + ControlItemDefaults[.preferredPosition, identifier.rawValue0_10_0] == nil { needsResetPreferredPositions = true } - StatusItemDefaults[.visible, identifier.rawValue] = nil + ControlItemDefaults[.visible, identifier.rawValue0_10_0] = nil } if needsResetPreferredPositions { for identifier in ControlItem.Identifier.allCases { - StatusItemDefaults[.preferredPosition, identifier.rawValue] = nil + ControlItemDefaults[.preferredPosition, identifier.rawValue0_10_0] = nil } let alert = NSAlert() @@ -347,6 +348,44 @@ extension MigrationManager { } } +// MARK: - Migrate 0.11.13.1 + +extension MigrationManager { + /// Performs all migrations for the `0.11.13.1` release. + private func migrate0_11_13_1() -> MigrationResult { + guard !Defaults.bool(forKey: .hasMigrated0_11_13_1) else { + return .success + } + + migrateControlItems0_11_13_1() + + Defaults.set(true, forKey: .hasMigrated0_11_13_1) + logger.info("Successfully migrated to 0.11.13.1 settings") + + return .success + } + + private func migrateControlItems0_11_13_1() { + for identifier in ControlItem.Identifier.allCases { + ControlItemDefaults.migrate( + key: .preferredPosition, + from: identifier.rawValue0_10_0, + to: identifier.rawValue + ) + ControlItemDefaults.migrate( + key: .visible, + from: identifier.rawValue0_10_0, + to: identifier.rawValue + ) + ControlItemDefaults.migrate( + key: .visibleCC, + from: identifier.rawValue0_10_0, + to: identifier.rawValue + ) + } + } +} + // MARK: - Helpers extension MigrationManager { @@ -421,9 +460,17 @@ extension MigrationManager { // MARK: - ControlItem.Identifier Extension private extension ControlItem.Identifier { - var deprecatedRawValue: String { + var rawValue0_8_0: String { + switch self { + case .visible: "IceIcon" + case .hidden: "HItem" + case .alwaysHidden: "AHItem" + } + } + + var rawValue0_10_0: String { switch self { - case .iceIcon: "IceIcon" + case .visible: "SItem" case .hidden: "HItem" case .alwaysHidden: "AHItem" } @@ -433,7 +480,7 @@ private extension ControlItem.Identifier { // MARK: - MenuBarSection.Name Extension private extension MenuBarSection.Name { - var deprecatedRawValue: String { + var rawValue0_8_0: String { switch self { case .visible: "Visible" case .hidden: "Hidden" diff --git a/Ice/Utilities/Predicates.swift b/Ice/Utilities/Predicates.swift index 64c4c9f9a..b6fb04517 100644 --- a/Ice/Utilities/Predicates.swift +++ b/Ice/Utilities/Predicates.swift @@ -44,7 +44,7 @@ extension Predicates where Input == WindowInfo { // wallpaper window belongs to the Dock process window.owningApplication?.bundleIdentifier == "com.apple.dock" && window.title?.hasPrefix("Wallpaper") == true && - CGDisplayBounds(display).contains(window.frame) + CGDisplayBounds(display).contains(window.bounds) } } @@ -57,7 +57,7 @@ extension Predicates where Input == WindowInfo { window.isOnScreen && window.layer == kCGMainMenuWindowLevel && window.title == "Menubar" && - CGDisplayBounds(display).contains(window.frame) + CGDisplayBounds(display).contains(window.bounds) } } } @@ -72,11 +72,15 @@ extension Predicates where Input == MenuBarItem { isInAlwaysHiddenSection: NonThrowingPredicate ) + private static func bounds(for item: MenuBarItem) -> CGRect { + Bridging.getWindowBounds(for: item.windowID) ?? item.bounds + } + /// Creates a predicate that returns whether a menu bar item is in the visible section /// using the control item for the hidden section as a delimiter. static func isInVisibleSection(hiddenControlItem: MenuBarItem) -> NonThrowingPredicate { predicate { item in - item.frame.minX >= hiddenControlItem.frame.maxX + bounds(for: item).minX >= bounds(for: hiddenControlItem).maxX } } @@ -85,12 +89,12 @@ extension Predicates where Input == MenuBarItem { static func isInHiddenSection(hiddenControlItem: MenuBarItem, alwaysHiddenControlItem: MenuBarItem?) -> NonThrowingPredicate { if let alwaysHiddenControlItem { predicate { item in - item.frame.maxX <= hiddenControlItem.frame.minX && - item.frame.minX >= alwaysHiddenControlItem.frame.maxX + bounds(for: item).maxX <= bounds(for: hiddenControlItem).minX && + bounds(for: item).minX >= bounds(for: alwaysHiddenControlItem).maxX } } else { predicate { item in - item.frame.maxX <= hiddenControlItem.frame.minX + bounds(for: item).maxX <= bounds(for: hiddenControlItem).minX } } } @@ -100,7 +104,7 @@ extension Predicates where Input == MenuBarItem { static func isInAlwaysHiddenSection(alwaysHiddenControlItem: MenuBarItem?) -> NonThrowingPredicate { if let alwaysHiddenControlItem { predicate { item in - item.frame.maxX <= alwaysHiddenControlItem.frame.minX + bounds(for: item).maxX <= bounds(for: alwaysHiddenControlItem).minX } } else { predicate { false } diff --git a/Ice/Utilities/ScreenCapture.swift b/Ice/Utilities/ScreenCapture.swift index d46ecc009..86764a64d 100644 --- a/Ice/Utilities/ScreenCapture.swift +++ b/Ice/Utilities/ScreenCapture.swift @@ -15,7 +15,7 @@ enum ScreenCapture { /// Returns a Boolean value that indicates whether the app has screen capture permissions. static func checkPermissions() -> Bool { - for windowID in Bridging.getWindowList(option: [.menuBarItems, .activeSpace]) { + for windowID in Bridging.getMenuBarWindowList(option: [.itemsOnly, .activeSpace]) { guard let window = WindowInfo(windowID: windowID), window.owningApplication != .current // Skip windows we own. @@ -72,13 +72,7 @@ enum ScreenCapture { /// capture the minimum rectangle that encloses the windows. /// - option: Options that specify which parts of the windows are captured. static func captureWindows(_ windowIDs: [CGWindowID], screenBounds: CGRect? = nil, option: CGWindowImageOption = []) -> CGImage? { - var pointers: [UnsafeRawPointer?] = windowIDs.reduce(into: []) { result, windowID in - guard let pointer = UnsafeRawPointer(bitPattern: UInt(windowID)) else { - return - } - result.append(pointer) - } - guard let windowArray = CFArrayCreate(nil, &pointers, pointers.count, nil) else { + guard let windowArray = Bridging.createCGWindowArray(with: windowIDs) else { return nil } let screenBounds = screenBounds ?? .null diff --git a/Ice/Utilities/StatusItemDefaults.swift b/Ice/Utilities/StatusItemDefaults.swift deleted file mode 100644 index 1e2b1e6b8..000000000 --- a/Ice/Utilities/StatusItemDefaults.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// StatusItemDefaults.swift -// Ice -// - -import Cocoa - -// MARK: - StatusItemDefaults - -/// Proxy getters and setters for a status item's user defaults values. -enum StatusItemDefaults { - /// Accesses the value associated with the specified key and autosave name. - static subscript(key: Key, autosaveName: String) -> Value? { - get { - let stringKey = key.stringKey(for: autosaveName) - return UserDefaults.standard.object(forKey: stringKey) as? Value - } - set { - let stringKey = key.stringKey(for: autosaveName) - return UserDefaults.standard.set(newValue, forKey: stringKey) - } - } - - /// Migrates the given status item defaults key from an old autosave name - /// to a new autosave name. - static func migrate(key: Key, from oldAutosaveName: String, to newAutosaveName: String) { - guard newAutosaveName != oldAutosaveName else { - return - } - Self[key, newAutosaveName] = Self[key, oldAutosaveName] - Self[key, oldAutosaveName] = nil - } -} - -// MARK: - StatusItemDefaults.Key - -extension StatusItemDefaults { - /// Keys used to look up user defaults values for status items. - struct Key { - /// The raw value of the key. - let rawValue: String - - /// Returns the full string key for the given autosave name. - func stringKey(for autosaveName: String) -> String { - return "NSStatusItem \(rawValue) \(autosaveName)" - } - } -} - -extension StatusItemDefaults.Key { - /// String key: "NSStatusItem Preferred Position autosaveName" - static let preferredPosition = Self(rawValue: "Preferred Position") -} - -extension StatusItemDefaults.Key { - /// String key: "NSStatusItem Visible autosaveName" - static let visible = Self(rawValue: "Visible") -} diff --git a/Ice/Utilities/TaskTimeout.swift b/Ice/Utilities/TaskHelpers.swift similarity index 70% rename from Ice/Utilities/TaskTimeout.swift rename to Ice/Utilities/TaskHelpers.swift index 93d0b77a1..9ded002b4 100644 --- a/Ice/Utilities/TaskTimeout.swift +++ b/Ice/Utilities/TaskHelpers.swift @@ -1,58 +1,64 @@ // -// TaskTimeout.swift +// TaskHelpers.swift // Ice // import Foundation +// MARK: - Task Timeout + extension Task where Failure == any Error { - /// Runs the given throwing operation asynchronously as part of a new top-level task - /// on behalf of the current actor. + /// Runs the given throwing operation asynchronously as part of a new + /// top-level task on behalf of the current actor. /// /// - Parameters: - /// - priority: The priority of the task. - /// - timeout: The amount of time to wait before throwing a ``TaskTimeoutError``. + /// - timeout: The amount of time to wait before cancelling the task + /// by throwing a ``TaskTimeoutError``. /// - tolerance: The tolerance of the clock. - /// - clock: The clock to use in the timeout operation. + /// - clock: The clock that manages the timeout operation. + /// - priority: The priority of the task. /// - operation: The operation to perform. @discardableResult init( - priority: TaskPriority? = nil, timeout: C.Instant.Duration, tolerance: C.Instant.Duration? = nil, clock: C = ContinuousClock(), - operation: @escaping @Sendable () async throws -> Success + priority: TaskPriority? = nil, + @_inheritActorContext @_implicitSelfCapture + operation: sending @escaping @isolated(any) () async throws -> Success ) { self.init(priority: priority) { try await Task.run(operation: operation, withTimeout: timeout, tolerance: tolerance, clock: clock) } } - /// Runs the given throwing operation asynchronously as part of a new top-level task. + /// Runs the given throwing operation asynchronously as part of a new + /// top-level task. /// /// - Parameters: - /// - priority: The priority of the task. - /// - timeout: The amount of time to wait before throwing a ``TaskTimeoutError``. + /// - timeout: The amount of time to wait before cancelling the task + /// by throwing a ``TaskTimeoutError``. /// - tolerance: The tolerance of the clock. - /// - clock: The clock to use in the timeout operation. + /// - clock: The clock that manages the timeout operation. + /// - priority: The priority of the task. /// - operation: The operation to perform. /// /// - Returns: A reference to the task. @discardableResult static func detached( - priority: TaskPriority? = nil, timeout: C.Instant.Duration, tolerance: C.Instant.Duration? = nil, clock: C = ContinuousClock(), - operation: @escaping @Sendable () async throws -> Success - ) -> Task { + priority: TaskPriority? = nil, + operation: sending @escaping @isolated(any) () async throws -> Success + ) -> Task { detached(priority: priority) { try await run(operation: operation, withTimeout: timeout, tolerance: tolerance, clock: clock) } } private static func run( - operation: @escaping @Sendable () async throws -> Success, + operation: sending @escaping @isolated(any) () async throws -> Success, withTimeout timeout: C.Instant.Duration, tolerance: C.Instant.Duration?, clock: C @@ -72,14 +78,10 @@ extension Task where Failure == any Error { } } -// MARK: - TaskTimeoutError +// MARK: TaskTimeoutError /// An error that indicates that a task timed out. -struct TaskTimeoutError: Error, CustomStringConvertible { +struct TaskTimeoutError: LocalizedError, CustomStringConvertible { let description = "Task timed out before completion" -} - -// MARK: TaskTimeoutError: LocalizedError -extension TaskTimeoutError: LocalizedError { var errorDescription: String? { description } } diff --git a/Ice/Utilities/WindowInfo.swift b/Ice/Utilities/WindowInfo.swift index 31276cba5..99939dfbb 100644 --- a/Ice/Utilities/WindowInfo.swift +++ b/Ice/Utilities/WindowInfo.swift @@ -7,50 +7,30 @@ import Cocoa /// Information for a window. struct WindowInfo { - /// The window identifier associated with the window. + /// The window's identifier. let windowID: CGWindowID - /// The frame of the window. - /// - /// The frame is specified in screen coordinates, where the origin - /// is at the upper left corner of the main display. - let frame: CGRect + /// The identifier of the process that owns the window. + let ownerPID: pid_t - /// The title of the window. - let title: String? + /// The window's bounds, specified in screen coordinates. + let bounds: CGRect - /// The layer number of the window. + /// The window's layer number. let layer: Int - /// The alpha value of the window, ranging from `0.0` to `1.0`, - /// where `0.0` is fully transparent, and `1.0` is fully opaque. - let alpha: Double - - /// The process identifier of the application that owns the window. - let ownerPID: pid_t + /// The window's title. + let title: String? - /// The name of the application that owns the window. + /// The name of the process that owns the window. /// - /// This may have a value when ``owningApplication`` does not have a - /// localized name. + /// This may have a value when ``owningApplication`` does not have + /// a localized name. let ownerName: String? - /// The sharing mode used by the window. - let sharingState: CGWindowSharingType - - /// The backing type of the window. - let backingStoreType: CGWindowBackingType - - /// An estimate of the amount of memory in bytes used by the window. - let memoryUsage: Measurement - /// A Boolean value that indicates whether the window is on screen. let isOnScreen: Bool - /// A Boolean value that indicates whether the window's backing store - /// is located in video memory. - let isBackedByVideoMemory: Bool - /// The application that owns the window. var owningApplication: NSRunningApplication? { NSRunningApplication(processIdentifier: ownerPID) @@ -65,40 +45,28 @@ struct WindowInfo { /// Creates a window with the given dictionary. private init?(dictionary: CFDictionary) { guard - let info = dictionary as? [CFString: CFTypeRef], + let info = dictionary as? [CFString: Any], let windowID = info[kCGWindowNumber] as? CGWindowID, - let boundsDict = info[kCGWindowBounds] as? NSDictionary, - let frame = CGRect(dictionaryRepresentation: boundsDict), - let layer = info[kCGWindowLayer] as? Int, - let alpha = info[kCGWindowAlpha] as? Double, let ownerPID = info[kCGWindowOwnerPID] as? pid_t, - let rawSharingState = info[kCGWindowSharingState] as? UInt32, - let rawBackingStoreType = info[kCGWindowStoreType] as? UInt32, - let sharingState = CGWindowSharingType(rawValue: rawSharingState), - let backingStoreType = CGWindowBackingType(rawValue: rawBackingStoreType), - let memoryUsage = info[kCGWindowMemoryUsage] as? Double + let boundsDict = info[kCGWindowBounds] as? NSDictionary, + let bounds = CGRect(dictionaryRepresentation: boundsDict), + let layer = info[kCGWindowLayer] as? Int else { return nil } self.windowID = windowID - self.frame = frame - self.title = info[kCGWindowName] as? String - self.layer = layer - self.alpha = alpha self.ownerPID = ownerPID + self.bounds = bounds + self.layer = layer + self.title = info[kCGWindowName] as? String self.ownerName = info[kCGWindowOwnerName] as? String - self.sharingState = sharingState - self.backingStoreType = backingStoreType - self.memoryUsage = Measurement(value: memoryUsage, unit: .bytes) self.isOnScreen = info[kCGWindowIsOnscreen] as? Bool ?? false - self.isBackedByVideoMemory = info[kCGWindowBackingLocationVideoMemory] as? Bool ?? false } /// Creates a window with the given window identifier. init?(windowID: CGWindowID) { - var pointer = UnsafeRawPointer(bitPattern: Int(windowID)) guard - let array = CFArrayCreate(kCFAllocatorDefault, &pointer, 1, nil), + let array = Bridging.createCGWindowArray(with: [windowID]), let list = CGWindowListCreateDescriptionFromArray(array) as? [CFDictionary], let dictionary = list.first else { @@ -110,138 +78,14 @@ struct WindowInfo { // MARK: - WindowList Operations -// MARK: Private -extension WindowInfo { - /// Options to use to retrieve on screen windows. - private enum OnScreenWindowListOption { - case above(_ window: WindowInfo, includeWindow: Bool) - case below(_ window: WindowInfo, includeWindow: Bool) - case onScreenOnly - } - - /// A context that contains the information needed to retrieve a window list. - private struct WindowListContext { - let windowListOption: CGWindowListOption - let referenceWindow: WindowInfo? - - init(windowListOption: CGWindowListOption, referenceWindow: WindowInfo?) { - self.windowListOption = windowListOption - self.referenceWindow = referenceWindow - } - - init(onScreenOption: OnScreenWindowListOption, excludeDesktopWindows: Bool) { - var windowListOption: CGWindowListOption = [] - var referenceWindow: WindowInfo? - switch onScreenOption { - case .above(let window, let includeWindow): - windowListOption.insert(.optionOnScreenAboveWindow) - if includeWindow { - windowListOption.insert(.optionIncludingWindow) - } - referenceWindow = window - case .below(let window, let includeWindow): - windowListOption.insert(.optionOnScreenBelowWindow) - if includeWindow { - windowListOption.insert(.optionIncludingWindow) - } - referenceWindow = window - case .onScreenOnly: - windowListOption.insert(.optionOnScreenOnly) - } - if excludeDesktopWindows { - windowListOption.insert(.excludeDesktopElements) - } - self.init(windowListOption: windowListOption, referenceWindow: referenceWindow) - } - } - - /// Retrieves a copy of the current window list as an array of dictionaries. - private static func copyWindowListArray(context: WindowListContext) -> [CFDictionary] { - let option = context.windowListOption - let windowID = context.referenceWindow?.windowID ?? kCGNullWindowID - guard let list = CGWindowListCopyWindowInfo(option, windowID) as? [CFDictionary] else { - return [] - } - return list - } - - /// Returns the current window list using the given context. - private static func getWindowList(context: WindowListContext) -> [WindowInfo] { - let list = copyWindowListArray(context: context) - return list.compactMap { WindowInfo(dictionary: $0) } - } -} - // MARK: All Windows extension WindowInfo { - /// Returns the current windows. + /// Returns a list of windows using the given options. /// - /// - Parameter excludeDesktopWindows: A Boolean value that indicates whether - /// to exclude desktop owned windows, such as the wallpaper and desktop icons. - static func getAllWindows(excludeDesktopWindows: Bool = false) -> [WindowInfo] { - var option = CGWindowListOption.optionAll - if excludeDesktopWindows { - option.insert(.excludeDesktopElements) - } - let context = WindowListContext(windowListOption: option, referenceWindow: nil) - return getWindowList(context: context) - } -} - -// MARK: On Screen Windows -extension WindowInfo { - /// Returns the on screen windows. - /// - /// - Parameter excludeDesktopWindows: A Boolean value that indicates whether - /// to exclude desktop owned windows, such as the wallpaper and desktop icons. - static func getOnScreenWindows(excludeDesktopWindows: Bool = false) -> [WindowInfo] { - let context = WindowListContext( - onScreenOption: .onScreenOnly, - excludeDesktopWindows: excludeDesktopWindows - ) - return getWindowList(context: context) - } - - /// Returns the on screen windows above the given window. - /// - /// - Parameters: - /// - window: The window to use as a reference point when determining which - /// windows to return. - /// - includeWindow: A Boolean value that indicates whether to include the - /// window in the result. - /// - excludeDesktopWindows: A Boolean value that indicates whether to exclude - /// desktop owned windows, such as the wallpaper and desktop icons. - static func getOnScreenWindows( - above window: WindowInfo, - includeWindow: Bool = false, - excludeDesktopWindows: Bool = false - ) -> [WindowInfo] { - let context = WindowListContext( - onScreenOption: .above(window, includeWindow: includeWindow), - excludeDesktopWindows: excludeDesktopWindows - ) - return getWindowList(context: context) - } - - /// Returns the on screen windows below the given window. - /// - /// - Parameters: - /// - window: The window to use as a reference point when determining which - /// windows to return. - /// - includeWindow: A Boolean value that indicates whether to include the - /// window in the result. - /// - excludeDesktopWindows: A Boolean value that indicates whether to exclude - /// desktop owned windows, such as the wallpaper and desktop icons. - static func getOnScreenWindows( - below window: WindowInfo, - includeWindow: Bool = false, - excludeDesktopWindows: Bool = false - ) -> [WindowInfo] { - let context = WindowListContext( - onScreenOption: .below(window, includeWindow: includeWindow), - excludeDesktopWindows: excludeDesktopWindows - ) - return getWindowList(context: context) + /// - Parameter option: Options that filter the returned list. + /// Pass an empty option set to return all available windows. + static func getWindows(option: Bridging.WindowListOption = []) -> [WindowInfo] { + Bridging.getWindowList(option: option).compactMap { WindowInfo(windowID: $0) } } } @@ -254,7 +98,7 @@ extension WindowInfo { /// Returns the wallpaper window for the given display. static func getWallpaperWindow(for display: CGDirectDisplayID) -> WindowInfo? { - getWallpaperWindow(from: getOnScreenWindows(), for: display) + getWallpaperWindow(from: getWindows(option: .onScreen), for: display) } } @@ -267,7 +111,7 @@ extension WindowInfo { /// Returns the menu bar window for the given display. static func getMenuBarWindow(for display: CGDirectDisplayID) -> WindowInfo? { - getMenuBarWindow(from: getOnScreenWindows(excludeDesktopWindows: true), for: display) + getMenuBarWindow(from: getWindows(option: .onScreen), for: display) } } @@ -275,17 +119,12 @@ extension WindowInfo { extension WindowInfo: Equatable { static func == (lhs: WindowInfo, rhs: WindowInfo) -> Bool { lhs.windowID == rhs.windowID && - NSStringFromRect(lhs.frame) == NSStringFromRect(rhs.frame) && - lhs.title == rhs.title && - lhs.layer == rhs.layer && - lhs.alpha == rhs.alpha && lhs.ownerPID == rhs.ownerPID && + NSStringFromRect(lhs.bounds) == NSStringFromRect(rhs.bounds) && + lhs.layer == rhs.layer && + lhs.title == rhs.title && lhs.ownerName == rhs.ownerName && - lhs.sharingState == rhs.sharingState && - lhs.backingStoreType == rhs.backingStoreType && - lhs.memoryUsage == rhs.memoryUsage && - lhs.isOnScreen == rhs.isOnScreen && - lhs.isBackedByVideoMemory == rhs.isBackedByVideoMemory + lhs.isOnScreen == rhs.isOnScreen } } @@ -293,16 +132,11 @@ extension WindowInfo: Equatable { extension WindowInfo: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(windowID) - hasher.combine(NSStringFromRect(frame)) - hasher.combine(title) - hasher.combine(layer) - hasher.combine(alpha) hasher.combine(ownerPID) + hasher.combine(NSStringFromRect(bounds)) + hasher.combine(layer) + hasher.combine(title) hasher.combine(ownerName) - hasher.combine(sharingState) - hasher.combine(backingStoreType) - hasher.combine(memoryUsage) hasher.combine(isOnScreen) - hasher.combine(isBackedByVideoMemory) } } From a2b8e53c54e6d0cd46a8e0ec70211716a670d5b5 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Sun, 13 Jul 2025 21:27:11 -0600 Subject: [PATCH 32/80] Fix menu bar item click/temp show --- .../MenuBarItems/MenuBarItemManager.swift | 140 +++++++++++------- 1 file changed, 86 insertions(+), 54 deletions(-) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index 44f5d9b54..62a908533 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -1206,31 +1206,30 @@ extension MenuBarItemManager { throw EventError(code: .invalidItem, item: item) } - let clickPoint = CGPoint(x: currentBounds.midX, y: currentBounds.midY) - let mouseTypes: (down: CGEventType, up: CGEventType) = switch mouseButton { - case .left: (.leftMouseDown, .leftMouseUp) - case .right: (.rightMouseDown, .rightMouseUp) - default: (.otherMouseDown, .otherMouseUp) - } + let buttonStates = mouseButton.buttonStates + let clickPoint = currentBounds.center guard - let mouseDownEvent = CGEvent( - mouseEventSource: source, - mouseType: mouseTypes.down, - mouseCursorPosition: clickPoint, - mouseButton: mouseButton + let mouseDownEvent = CGEvent.menuBarItemEvent( + type: .click(buttonStates.down), + location: clickPoint, + item: item, + pid: item.ownerPID, + source: source ), - let mouseUpEvent = CGEvent( - mouseEventSource: source, - mouseType: mouseTypes.up, - mouseCursorPosition: clickPoint, - mouseButton: mouseButton + let mouseUpEvent = CGEvent.menuBarItemEvent( + type: .click(buttonStates.up), + location: clickPoint, + item: item, + pid: item.ownerPID, + source: source ), - let fallbackEvent = CGEvent( - mouseEventSource: source, - mouseType: mouseTypes.up, - mouseCursorPosition: clickPoint, - mouseButton: mouseButton + let fallbackEvent = CGEvent.menuBarItemEvent( + type: .click(buttonStates.up), + location: clickPoint, + item: item, + pid: item.ownerPID, + source: source ) else { throw EventError(code: .eventCreationFailure, item: item) @@ -1260,16 +1259,11 @@ extension MenuBarItemManager { \(mouseButton.logString, privacy: .public) """ ) - try await postEventAndWaitToReceive( - mouseDownEvent, - to: .sessionEventTap, - item: item - ) - try await postEventAndWaitToReceive( - mouseUpEvent, - to: .sessionEventTap, - item: item - ) + await eventSleep() + try await scrombleEvent(mouseDownEvent, from: .pid(item.ownerPID), to: .sessionEventTap, item: item) + await eventSleep() + try await scrombleEvent(mouseUpEvent, from: .pid(item.ownerPID), to: .sessionEventTap, item: item) + await eventSleep() } catch { do { let eventTask = Task { @@ -1623,13 +1617,20 @@ private extension CGEventField { /// Key to access a field that contains the event's window identifier. static let windowID = CGEventField(rawValue: 0x33)! // swiftlint:disable:this force_unwrapping - /// An array of integer event fields that can be used to compare menu bar item events. - static let menuBarItemEventFields: [CGEventField] = [ - .eventSourceUserData, + /// An array of integer fields that are required for a menu bar item event. + static let menuBarItemRequiredWindowFields: [CGEventField] = [ .mouseEventWindowUnderMousePointer, .mouseEventWindowUnderMousePointerThatCanHandleThisEvent, - .windowID, ] + + /// An array of integer fields that may be set for a menu bar item event. + static let menuBarItemOptionalWindowFields: [CGEventField] = [.windowID] + + /// An array of integer event fields that can be used to compare menu bar item events. + static let menuBarItemEventFields: [CGEventField] = { + let baseFields: [CGEventField] = [.eventSourceUserData] + return baseFields + menuBarItemRequiredWindowFields + menuBarItemOptionalWindowFields + }() } // MARK: - CGEventFilterMask Helpers @@ -1699,38 +1700,69 @@ private extension CGMouseButton { // MARK: - CGEvent Constructor private extension CGEvent { - /// Returns an event that can be sent to the given menu bar item. + /// Returns an event that can be sent to a menu bar item. /// /// - Parameters: /// - type: The type of the event. - /// - location: The location of the event. Does not need to be within the bounds of the item. + /// - location: The location of the event. Does not need to be + /// within the bounds of the item. /// - item: The target item of the event. - /// - pid: The target process identifier of the event. Does not need to be the item's `ownerPID`. + /// - pid: The target process identifier of the event. Does not + /// need to be the item's `ownerPID`. /// - source: The source of the event. - class func menuBarItemEvent(type: MenuBarItemEventType, location: CGPoint, item: MenuBarItem, pid: pid_t, source: CGEventSource) -> CGEvent? { - let mouseType = type.cgEventType - let mouseButton = type.mouseButton - - guard let event = CGEvent(mouseEventSource: source, mouseType: mouseType, mouseCursorPosition: location, mouseButton: mouseButton) else { + class func menuBarItemEvent( + type: MenuBarItemEventType, + location: CGPoint, + item: MenuBarItem, + pid: pid_t, + source: CGEventSource + ) -> CGEvent? { + guard let event = CGEvent( + mouseEventSource: source, + mouseType: type.cgEventType, + mouseCursorPosition: location, + mouseButton: type.mouseButton + ) else { return nil } + event.setFlags(for: type) + event.setTargetPID(pid) + event.setUserData(ObjectIdentifier(event)) + event.setWindowID(item.windowID, for: type) + event.setClickState(for: type) + return event + } - event.flags = type.cgEventFlags + private func setFlags(for type: MenuBarItemEventType) { + flags = type.cgEventFlags + } + private func setTargetPID(_ pid: pid_t) { let targetPID = Int64(pid) - let userData = Int64(truncatingIfNeeded: Int(bitPattern: ObjectIdentifier(event))) - let windowID = Int64(item.windowID) + setIntegerValueField(.eventTargetUnixProcessID, value: targetPID) + } - event.setIntegerValueField(.eventTargetUnixProcessID, value: targetPID) - event.setIntegerValueField(.eventSourceUserData, value: userData) - event.setIntegerValueField(.mouseEventWindowUnderMousePointer, value: windowID) - event.setIntegerValueField(.mouseEventWindowUnderMousePointerThatCanHandleThisEvent, value: windowID) - event.setIntegerValueField(.windowID, value: windowID) + private func setUserData(_ bitPattern: ObjectIdentifier) { + let userData = Int64(Int(bitPattern: bitPattern)) + setIntegerValueField(.eventSourceUserData, value: userData) + } + + private func setWindowID(_ windowID: CGWindowID, for type: MenuBarItemEventType) { + let windowID = Int64(windowID) - if case .click = type { - event.setIntegerValueField(.mouseEventClickState, value: 1) + for field in CGEventField.menuBarItemRequiredWindowFields { + setIntegerValueField(field, value: windowID) } - return event + if case .move = type { + setIntegerValueField(.windowID, value: windowID) + } + } + + private func setClickState(for type: MenuBarItemEventType) { + guard case .click = type else { + return + } + setIntegerValueField(.mouseEventClickState, value: 1) } } From 4594280f329b4ad11db24ce3c40237516c939038 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Mon, 14 Jul 2025 16:47:23 -0600 Subject: [PATCH 33/80] Update `MenuBarItemSourceCache` --- .../MenuBarItems/MenuBarItemSourceCache.swift | 247 ++++++++++-------- 1 file changed, 140 insertions(+), 107 deletions(-) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemSourceCache.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemSourceCache.swift index fce99c957..e5c4a1445 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemSourceCache.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemSourceCache.swift @@ -6,155 +6,188 @@ import AXSwift import Cocoa import Combine -import OSLog +import os.lock // MARK: - MenuBarItemSourceCache @available(macOS 26.0, *) enum MenuBarItemSourceCache { - private static let concurrentQueue = DispatchQueue.queue( - label: "MenuBarItemSourceCache.concurrentQueue", + private static let axQueue = DispatchQueue.queue( + label: "MenuBarItemSourceCache.axQueue", + qos: .utility, + attributes: .concurrent + ) + private static let serialWorkQueue = DispatchQueue( + label: "MenuBarItemSourceCache.serialWorkQueue", + qos: .userInteractive + ) + private static let concurrentWorkQueue = DispatchQueue( + label: "MenuBarItemSourceCache.concurrentWorkQueue", qos: .userInteractive, attributes: .concurrent ) - @MainActor - static func start(with permissions: AppPermissions) { - Storage.start(with: permissions) - } - - @discardableResult - private static func updateCachedPID(for window: WindowInfo) -> pid_t? { - let windowID = window.windowID - - for runningApp in Storage.getRunningApps() { - // Since we're running concurrently, we could have a pid - // at any point. - if let pid = Storage.getPID(for: windowID) { - return pid - } + private final class CachedApplication: Sendable { + private struct ExtrasMenuBarLazyStorage: @unchecked Sendable { + var extrasMenuBar: UIElement? + var hasInitialized = false + } - // IMPORTANT: These checks help prevent some major thread - // blocking caused by the AX APIs. - guard - runningApp.isFinishedLaunching, - !runningApp.isTerminated, - runningApp.activationPolicy != .prohibited - else { - continue - } + private let runningApp: NSRunningApplication + private let extrasMenuBarState = OSAllocatedUnfairLock(initialState: ExtrasMenuBarLazyStorage()) - guard - let app = Application(runningApp), - let bar: UIElement = try? app.attribute(.extrasMenuBar) - else { - continue - } + var processIdentifier: pid_t { + runningApp.processIdentifier + } - for child in bar.children { - if let pid = Storage.getPID(for: windowID) { - return pid + var extrasMenuBar: UIElement? { + extrasMenuBarState.withLock { storage in + if storage.hasInitialized { + return storage.extrasMenuBar } - // Item window may have moved. Get the current bounds. - guard let windowBounds = Bridging.getWindowBounds(for: windowID) else { - Storage.setPID(nil, for: windowID) - return nil + defer { + storage.hasInitialized = true } - guard windowBounds == window.bounds else { + // These checks help limit blocks that can occur when + // calling the AX APIs (app could be unresponsive, or + // in some other invalid state). + guard + !Bridging.isProcessUnresponsive(processIdentifier), + runningApp.isFinishedLaunching, + !runningApp.isTerminated, + runningApp.activationPolicy != .prohibited + else { return nil } - guard - let childFrame = child.frame, - childFrame.center.distance(to: windowBounds.center) <= 10 - else { - continue + storage.extrasMenuBar = axQueue.sync { + guard let app = Application(runningApp) else { + return nil + } + return try? app.attribute(.extrasMenuBar) } - let pid = runningApp.processIdentifier - Storage.setPID(pid, for: windowID) - return pid + return storage.extrasMenuBar } } - return nil - } - - static func getCachedPID(for window: WindowInfo) -> pid_t? { - if let pid = Storage.getPID(for: window.windowID) { - return pid - } - return concurrentQueue.sync { - updateCachedPID(for: window) + init(_ runningApp: NSRunningApplication) { + self.runningApp = runningApp } } -} -// MARK: - MenuBarItemSourceCache.Storage + private struct State: Sendable { + var apps = [CachedApplication]() + var pids = [CGWindowID: pid_t]() -@available(macOS 26.0, *) -extension MenuBarItemSourceCache { - private enum Storage { - private static let publisherQueue = DispatchQueue.queue( - label: "MenuBarItemSourceCache.Storage.publisherQueue", - qos: .userInteractive - ) - private static let pidsQueue = DispatchQueue.queue( - label: "MenuBarItemSourceCache.Storage.pidsQueue", - qos: .userInteractive - ) - private static let runningAppsQueue = DispatchQueue.queue( - label: "MenuBarItemSourceCache.Storage.runningAppsQueue", - qos: .userInteractive - ) - - private static var pids = [CGWindowID: pid_t]() - private static var runningApps = [NSRunningApplication]() - private static var cancellable: AnyCancellable? - - static func getPID(for windowID: CGWindowID) -> pid_t? { - pidsQueue.sync { pids[windowID] } - } + mutating func updateCachedPID(for window: WindowInfo) { + let windowID = window.windowID - static func setPID(_ pid: pid_t?, for windowID: CGWindowID) { - pidsQueue.sync { pids[windowID] = pid } - } + for app in apps { + // Since we're running concurrently, we could have a pid + // at any point. + if pids[windowID] != nil { + return + } - static func getRunningApps() -> [NSRunningApplication] { - runningAppsQueue.sync { runningApps } - } + guard let bar = app.extrasMenuBar else { + continue + } + + for child in axQueue.sync(execute: { bar.children }) { + if pids[windowID] != nil { + return + } + + // Item window may have moved. Get the current bounds. + guard let windowBounds = Bridging.getWindowBounds(for: windowID) else { + pids.removeValue(forKey: windowID) + return + } + + guard windowBounds == window.bounds else { + return + } - @MainActor - static func start(with permissions: AppPermissions) { - cancellable = NSWorkspace.shared.publisher(for: \.runningApplications) - .receive(on: publisherQueue) - .sink { [weak permissions] runningApps in guard - let permissions, - permissions.accessibility.hasPermission + let childFrame = axQueue.sync(execute: { child.frame }), + childFrame.center.distance(to: windowBounds.center) <= 10 else { - return + continue } - pidsQueue.sync { - let newPIDs = Set(runningApps.map { $0.processIdentifier }) - for (key, value) in pids where !newPIDs.contains(value) { - pids.removeValue(forKey: key) - } + pids[windowID] = app.processIdentifier + return + } + } + } + } + + private static let state = OSAllocatedUnfairLock(initialState: State()) + private static var cancellable: AnyCancellable? + + @MainActor + static func start(with permissions: AppPermissions) { + cancellable = NSWorkspace.shared.publisher(for: \.runningApplications) + .receive(on: serialWorkQueue) + .sink { [weak permissions] runningApps in + guard + let permissions, + permissions.accessibility.hasPermission + else { + return + } + + state.withLock { state in + // Convert the cached state to dictionaries keyed by pid to + // allow for efficient repeated access. + let appMappings = state.apps.reduce(into: [:]) { result, app in + result[app.processIdentifier] = app } + let pidMappings = state.pids.reduce(into: [:]) { result, pair in + result[pair.value, default: []].append(pair) + } + + // Create a new state that matches the current running apps. + state = runningApps.reduce(into: State()) { result, app in + let pid = app.processIdentifier + + if let app = appMappings[pid] { + // Prefer the cached app, as it may have already done + // the work to initialize its extras menu bar. + result.apps.append(app) + } else { + // App wasn't in the cache, so it must be new. + result.apps.append(CachedApplication(app)) + } - runningAppsQueue.sync { - self.runningApps = runningApps + if let pids = pidMappings[pid] { + result.pids.merge(pids) { (_, new) in new } + } } + } - for window in MenuBarItem.getMenuBarItemWindows(option: .activeSpace) { - concurrentQueue.async { - updateCachedPID(for: window) + for window in MenuBarItem.getMenuBarItemWindows(option: []) { + concurrentWorkQueue.async { + state.withLock { state in + state.updateCachedPID(for: window) } } } + } + } + + static func getCachedPID(for window: WindowInfo) -> pid_t? { + concurrentWorkQueue.sync { + state.withLock { state in + if let pid = state.pids[window.windowID] { + return pid + } + state.updateCachedPID(for: window) + return state.pids[window.windowID] + } } } } From 1e2e0ddf26c6d0f31d121e8bfb1b5a9b09acdedc Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Mon, 14 Jul 2025 16:47:25 -0600 Subject: [PATCH 34/80] Update screen capture implementation --- Ice/Utilities/ScreenCapture.swift | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Ice/Utilities/ScreenCapture.swift b/Ice/Utilities/ScreenCapture.swift index 86764a64d..adb929999 100644 --- a/Ice/Utilities/ScreenCapture.swift +++ b/Ice/Utilities/ScreenCapture.swift @@ -61,6 +61,9 @@ enum ScreenCapture { // MARK: Capture Window(s) + /// Queue for screen capture operations. + private static let captureQueue = DispatchQueue(label: "ScreenCapture.captureQueue", qos: .userInteractive) + /// Captures a composite image of an array of windows. /// /// The windows are composited from front to back, according to the order of the `windowIDs` @@ -72,11 +75,13 @@ enum ScreenCapture { /// capture the minimum rectangle that encloses the windows. /// - option: Options that specify which parts of the windows are captured. static func captureWindows(_ windowIDs: [CGWindowID], screenBounds: CGRect? = nil, option: CGWindowImageOption = []) -> CGImage? { - guard let windowArray = Bridging.createCGWindowArray(with: windowIDs) else { - return nil + captureQueue.sync { + guard let windowArray = Bridging.createCGWindowArray(with: windowIDs) else { + return nil + } + let screenBounds = screenBounds ?? .null + return CGImage.windowListImage(from: screenBounds, windowArray: windowArray, imageOption: option) } - let screenBounds = screenBounds ?? .null - return CGImage.windowListImage(from: screenBounds, windowArray: windowArray, imageOption: option) } /// Captures an image of a window. @@ -87,7 +92,7 @@ enum ScreenCapture { /// capture the minimum rectangle that encloses the window. /// - option: Options that specify which parts of the window are captured. static func captureWindow(_ windowID: CGWindowID, screenBounds: CGRect? = nil, option: CGWindowImageOption = []) -> CGImage? { - return captureWindows([windowID], screenBounds: screenBounds, option: option) + captureWindows([windowID], screenBounds: screenBounds, option: option) } } @@ -104,7 +109,7 @@ private protocol WindowListImage { private extension WindowListImage { @inline(__always) // Ensure a direct call to the initializer. static func windowListImage(from screenBounds: CGRect, windowArray: CFArray, imageOption: CGWindowImageOption) -> Self? { - return Self(windowListFromArrayScreenBounds: screenBounds, windowArray: windowArray, imageOption: imageOption) + Self(windowListFromArrayScreenBounds: screenBounds, windowArray: windowArray, imageOption: imageOption) } } From 90a06508f97c18ebcde3b732cd12690df68fd28b Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Mon, 14 Jul 2025 16:47:29 -0600 Subject: [PATCH 35/80] Update cache operations when showing Ice Bar --- Ice/MenuBar/IceBar/IceBar.swift | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Ice/MenuBar/IceBar/IceBar.swift b/Ice/MenuBar/IceBar/IceBar.swift index b7801e9b5..cfdde3f55 100644 --- a/Ice/MenuBar/IceBar/IceBar.swift +++ b/Ice/MenuBar/IceBar/IceBar.swift @@ -4,6 +4,7 @@ // import Combine +import OSLog import SwiftUI // MARK: - IceBarPanel @@ -167,15 +168,17 @@ final class IceBarPanel: NSPanel { appState.navigationState.isIceBarPresented = true currentSection = section - var managedItems = appState.itemManager.itemCache.managedItems(for: section) - - if managedItems.isEmpty { + let cacheTask = Task(timeout: .milliseconds(100)) { await appState.itemManager.cacheItemsIfNeeded() - managedItems = appState.itemManager.itemCache.managedItems(for: section) + await appState.imageCache.updateCache() } - if managedItems.contains(where: { appState.imageCache.images[$0.tag] == nil }) { - await appState.imageCache.updateCache() + do { + try await cacheTask.value + } catch is TaskTimeoutError { + Logger.general.error("Cache task timed out during IceBarPanel.show") + } catch { + Logger.general.error("Cache task failed during IceBarPanel.show - \(error)") } contentView = IceBarContentHostingView( From 9c6740c8e3c17fc1b3328e9ed531c755612d7b26 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Tue, 15 Jul 2025 11:21:45 -0600 Subject: [PATCH 36/80] Change how menu bar items are displayed --- Ice/MenuBar/MenuBarItems/MenuBarItem.swift | 22 ++--- Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift | 98 +++++++++++++------ Ice/MenuBar/Search/MenuBarSearchPanel.swift | 85 +++++++++------- Ice/UI/Views/SectionedList.swift | 13 +++ 4 files changed, 139 insertions(+), 79 deletions(-) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift index 320e4e5d6..beedc69a1 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift @@ -78,27 +78,17 @@ struct MenuBarItem: CustomStringConvertible { String(s).replacing(/([a-z])([A-Z])/) { $0.output.1 + " " + $0.output.2 } } - var fallback: String { - "Unknown" - } - var mappedTitle: String? { - title.flatMap { $0.starts(with: /Item-\d+/) ? fallback : $0 } + guard let sourceApplication else { + return "Menu Bar Item" } + var bestName: String { if isControlItem { Constants.displayName - } else if let sourceApplication { + } else { sourceApplication.localizedName ?? sourceApplication.bundleIdentifier ?? - mappedTitle ?? - fallback - } else if let owningApplication { - owningApplication.localizedName ?? - owningApplication.bundleIdentifier ?? - mappedTitle ?? - fallback - } else { - ownerName ?? mappedTitle ?? fallback + title ?? "Unknown" } } @@ -113,6 +103,8 @@ struct MenuBarItem: CustomStringConvertible { // "PasswordsMenuBarExtra" -> "Passwords" // "WeatherMenu" -> "Weather" String(toTitleCase(bestName).prefix { !$0.isWhitespace }) + case .textInput: + "Text Input" case .controlCenter where title.hasPrefix("BentoBox"): bestName case .controlCenter where title == "WiFi": diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift index 6f97fb03a..e175d2ff6 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift @@ -66,6 +66,12 @@ extension MenuBarItemTag { // MARK: Special Item Lists /// An array of tags for items whose movement is prevented by macOS. + /// + /// These items have fixed positions at the trailing end of the menu bar, + /// and cannot be hidden. + /// + /// In macOS 26, this list contains the "Clock" and "Control Center" items. + /// In earlier releases, it also contained the "Siri" item. static let immovableItems: [MenuBarItemTag] = { var items = [clock, controlCenter] if #unavailable(macOS 26.0) { @@ -96,50 +102,76 @@ extension MenuBarItemTag { // MARK: Control Items - /// A tag for the control item for the visible section. + /// The tag for Ice's control item for the "Visible" section. static let visibleControlItem = MenuBarItemTag(controlItem: .visible) - /// A tag for the control item for the hidden section. + /// The tag for Ice's control item for the "Hidden" section. static let hiddenControlItem = MenuBarItemTag(controlItem: .hidden) - /// A tag for the control item for the always-hidden section. + /// The tag for Ice's control item for the "Always-Hidden" section. static let alwaysHiddenControlItem = MenuBarItemTag(controlItem: .alwaysHidden) // MARK: Other System Items - /// A tag for the "Clock" item. + /// The tag for the system "Clock" item. static let clock = MenuBarItemTag(namespace: .controlCenter, title: "Clock") - /// A tag for the "Siri" item. - static let siri: MenuBarItemTag = { - if #available(macOS 26.0, *) { - MenuBarItemTag(namespace: .controlCenter, title: "Siri") - } else { - MenuBarItemTag(namespace: .systemUIServer, title: "Siri") - } - }() + /// The tag for the system "Control Center" item. + static let controlCenter = if #available(macOS 26.0, *) { + MenuBarItemTag(namespace: .controlCenter, title: "BentoBox-0") + } else { + MenuBarItemTag(namespace: .controlCenter, title: "BentoBox") + } - /// A tag for the "Control Center" item. - static let controlCenter: MenuBarItemTag = { - if #available(macOS 26.0, *) { - MenuBarItemTag(namespace: .controlCenter, title: "BentoBox-0") - } else { - MenuBarItemTag(namespace: .controlCenter, title: "BentoBox") - } - }() + /// The tag for the system "Siri" item. + static let siri = MenuBarItemTag(namespace: .systemUIServer, title: "Siri") + + /// The tag for the system "Spotlight" item. + static let spotlight = MenuBarItemTag(namespace: .spotlight, title: "Item-0") + + /// The tag for the system "WiFi" item. + static let wifi = MenuBarItemTag(namespace: .controlCenter, title: "WiFi") + + /// The tag for the system "Bluetooth" item. + static let bluetooth = MenuBarItemTag(namespace: .controlCenter, title: "Bluetooth") + + /// The tag for the system "Battery" item. + static let battery = MenuBarItemTag(namespace: .controlCenter, title: "Battery") + + /// The tag for the system "Focus Modes" item. + static let focusModes = MenuBarItemTag(namespace: .controlCenter, title: "FocusModes") + + /// The tag for the system "Screen Mirroring" item. + static let screenMirroring = MenuBarItemTag(namespace: .controlCenter, title: "ScreenMirroring") - /// A tag for the item that appears in the menu bar while the screen + /// The tag for the system "Display" item. + static let display = MenuBarItemTag(namespace: .controlCenter, title: "Display") + + /// The tag for the system "Sound" item. + static let sound = MenuBarItemTag(namespace: .controlCenter, title: "Sound") + + /// The tag for the system "Now Playing" item. + static let nowPlaying = MenuBarItemTag(namespace: .controlCenter, title: "NowPlaying") + + /// The tag for the system "TimeMachine" item. + static let timeMachine = if #available(macOS 15.0, *) { + MenuBarItemTag(namespace: .systemUIServer, title: "TimeMachineMenuExtra.TMMenuExtraHost") + } else { + MenuBarItemTag(namespace: .systemUIServer, title: "TimeMachine.TMMenuExtraHost") + } + + /// The tag for the item that appears in the menu bar while the screen /// or system audio is being recorded. static let audioVideoModule = MenuBarItemTag(namespace: .controlCenter, title: "AudioVideoModule") - /// A tag for the "FaceTime" item. + /// The tag for the system "FaceTime" item. static let faceTime = MenuBarItemTag(namespace: .controlCenter, title: "FaceTime") - /// A tag for the "MusicRecognition" (a.k.a. "Shazam") item. + /// The tag for the system "MusicRecognition" item. static let musicRecognition = MenuBarItemTag(namespace: .controlCenter, title: "MusicRecognition") // TODO: How do we reference this item in macOS 26? - /// A tag for the "stop recording" item that appears in the menu bar + /// The tag for the "stop recording" item that appears in the menu bar /// during screen recordings started by the macOS "Screenshot" tool. static let screenCaptureUI = MenuBarItemTag(namespace: .screenCaptureUI, title: "Item-0") } @@ -245,21 +277,27 @@ extension MenuBarItemTag { // MARK: MenuBarItemTag.Namespace Constants extension MenuBarItemTag.Namespace { - /// The namespace for menu bar items owned by Ice. + /// The namespace for menu bar items created by Ice. static let ice = Self(Constants.bundleIdentifier) - /// The namespace for menu bar items owned by "Control Center". + /// The namespace for menu bar items created by Control Center. static let controlCenter = Self("com.apple.controlcenter") - /// The namespace for menu bar items owned by "System UI Server". - static let systemUIServer = Self("com.apple.systemuiserver") + /// The namespace for the "Passwords" menu bar item. + static let passwords = Self("com.apple.Passwords.MenuBarExtra") /// The namespace for the "stop recording" menu bar item that appears /// during screen recordings started by the macOS "Screenshot" tool. static let screenCaptureUI = Self("com.apple.screencaptureui") - /// The namespace for the "Passwords" menu bar item. - static let passwords = Self("com.apple.Passwords.MenuBarExtra") + /// The namespace for the "Spotlight" menu bar item. + static let spotlight = Self("com.apple.Spotlight") + + /// The namespace for menu bar items created by SystemUIServer. + static let systemUIServer = Self("com.apple.systemuiserver") + + /// The namespace for the "Text Input" menu bar item. + static let textInput = Self("com.apple.TextInputMenuAgent") /// The namespace for the "Weather" menu bar item. static let weather = Self("com.apple.weather.menu") diff --git a/Ice/MenuBar/Search/MenuBarSearchPanel.swift b/Ice/MenuBar/Search/MenuBarSearchPanel.swift index cb62e2c99..d61bdf19e 100644 --- a/Ice/MenuBar/Search/MenuBarSearchPanel.swift +++ b/Ice/MenuBar/Search/MenuBarSearchPanel.swift @@ -23,15 +23,17 @@ final class MenuBarSearchPanel: NSPanel { /// Monitor for mouse down events. private lazy var mouseDownMonitor = UniversalEventMonitor( mask: [.leftMouseDown, .rightMouseDown, .otherMouseDown] - ) { [weak self] event in + ) { [weak self, weak appState] event in guard let self, - event.window !== self, - Bridging.getWindowLevel(for: CGWindowID(event.windowNumber)) != kCGStatusWindowLevel + let appState, + event.window !== self else { return event } - close() + if !appState.itemManager.itemHasRecentlyMoved { + close() + } return event } @@ -160,6 +162,7 @@ private final class MenuBarSearchHostingView: NSHostingView { displayID: displayID, closePanel: { [weak panel] in panel?.close() } ) + .environmentObject(appState) .environmentObject(appState.itemManager) .environmentObject(appState.imageCache) .erasedToAnyView() @@ -448,6 +451,7 @@ private let controlCenterIcon: NSImage? = { }() private struct MenuBarSearchItemView: View { + @EnvironmentObject var appState: AppState @EnvironmentObject var imageCache: MenuBarItemImageCache let item: MenuBarItem @@ -466,20 +470,16 @@ private struct MenuBarSearchItemView: View { return NSImage(cgImage: trimmedImage, size: size) } - private var appIcon: NSImage { - if - item.tag.namespace == .systemUIServer, - let icon = controlCenterIcon - { - return icon - } - if let icon = item.sourceApplication?.icon { - return icon + private var appIcon: NSImage? { + guard let sourceApplication = item.sourceApplication else { + return nil } - if let icon = item.owningApplication?.icon { - return icon + switch item.tag.namespace { + case .controlCenter, .systemUIServer, .textInput: + return controlCenterIcon + default: + return sourceApplication.icon } - return NSImage() } private var backgroundShape: some InsettableShape { @@ -508,10 +508,7 @@ private struct MenuBarSearchItemView: View { var body: some View { HStack { - Image(nsImage: appIcon) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: size, height: size) + iconViewWithFrame Text(item.displayName) Spacer() imageViewWithBackground @@ -520,23 +517,43 @@ private struct MenuBarSearchItemView: View { } @ViewBuilder - private var imageViewWithBackground: some View { - if #available(macOS 26.0, *) { - imageView.glassEffect( - Glass.regular.tint(.secondary.opacity(0.33)), - in: backgroundShape - ) + private var iconViewWithFrame: some View { + iconView + .frame(width: size, height: size) + } + + @ViewBuilder + private var iconView: some View { + if let appIcon { + Image(nsImage: appIcon) + .resizable() + .aspectRatio(contentMode: .fit) } else { - imageView.background { + RoundedRectangle(cornerRadius: 5) + .fill(Color.accentColor.gradient) + .strokeBorder(Color.primary.gradient.quaternary) + .overlay { + Image(systemName: "rectangle.topthird.inset.filled") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(.white) + .padding(3) + .shadow(radius: 2) + } + .padding(2.5) + .shadow(color: .black.opacity(0.1), radius: 2) + } + } + + @ViewBuilder + private var imageViewWithBackground: some View { + imageView + .layoutBarStyle(appState: appState, averageColorInfo: appState.menuBarManager.averageColorInfo) + .clipShape(backgroundShape) + .overlay { backgroundShape - .fill(.regularMaterial.opacity(0.75)) - .brightness(0.25) - .overlay { - backgroundShape - .strokeBorder(.white.opacity(0.15)) - } + .strokeBorder(.quaternary) } - } } @ViewBuilder diff --git a/Ice/UI/Views/SectionedList.swift b/Ice/UI/Views/SectionedList.swift index 3fff6e06d..6737908f0 100644 --- a/Ice/UI/Views/SectionedList.swift +++ b/Ice/UI/Views/SectionedList.swift @@ -180,12 +180,24 @@ struct SectionedListItem { // MARK: - SectionedListItemView private struct SectionedListItemView: View { + @Environment(\.self) private var environment @Binding var selection: ItemID? @Binding var itemFrames: [ItemID: CGRect] @State private var isHovering = false let item: SectionedListItem + private var foregroundStyle: some ShapeStyle { + if + environment.colorScheme == .light, + selection == item.id + { + Color.primary.resolve(in: with(environment) { $0.colorScheme = .dark }) + } else { + Color.primary.resolve(in: environment) + } + } + private var backgroundShape: some InsettableShape { if #available(macOS 26.0, *) { RoundedRectangle(cornerRadius: 10, style: .continuous) @@ -204,6 +216,7 @@ private struct SectionedListItemView: View { } } item.content + .foregroundStyle(foregroundStyle) } .frame(minWidth: 22, minHeight: 22) .contentShape(Rectangle()) From 8adf5a4bdd828323c0cc5b3d138f578d1761a724 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Wed, 16 Jul 2025 09:17:33 -0600 Subject: [PATCH 37/80] Create MenuBarItemService XPC - Move the `MenuBarItemSourceCache` logic into a separate XPC service - Keeps the app from hanging due to the Accessibility API - Reduces strain on the app itself - The app sends requests to the XPC and receives responses asynchronously - Add "Shared" directory for code shared between targets - Rework `WindowInfo` API - Adjust code across the app to work with new changes --- Ice.xcodeproj/project.pbxproj | 171 +++++++++++- Ice/Events/EventManager.swift | 14 +- .../EventMonitors/GlobalEventMonitor.swift | 14 +- .../EventMonitors/LocalEventMonitor.swift | 16 +- .../RunLoopLocalEventMonitor.swift | 18 +- .../EventMonitors/UniversalEventMonitor.swift | 16 +- Ice/Main/AppState.swift | 43 ++- .../Appearance/MenuBarOverlayPanel.swift | 12 +- Ice/MenuBar/IceBar/IceBar.swift | 7 + Ice/MenuBar/IceBar/IceBarColorManager.swift | 6 +- .../LayoutBar/LayoutBarPaddingView.swift | 26 +- Ice/MenuBar/MenuBarItems/MenuBarItem.swift | 40 ++- .../MenuBarItems/MenuBarItemManager.swift | 149 +++++----- .../MenuBarItemServiceConnection.swift | 161 +++++++++++ .../MenuBarItems/MenuBarItemSourceCache.swift | 208 -------------- Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift | 19 +- Ice/MenuBar/MenuBarManager.swift | 76 ++--- Ice/MenuBar/Search/MenuBarSearchModel.swift | 23 ++ Ice/MenuBar/Search/MenuBarSearchPanel.swift | 58 ++-- .../Spacing/MenuBarItemSpacingManager.swift | 2 +- .../MenuBarLayoutSettingsPane.swift | 17 ++ Ice/Settings/SettingsView.swift | 1 + Ice/Updates/UpdatesManager.swift | 2 +- Ice/Utilities/Extensions.swift | 35 +-- Ice/Utilities/Predicates.swift | 28 -- MenuBarItemService/AXHelpers.swift | 39 +++ MenuBarItemService/Listener.swift | 99 +++++++ MenuBarItemService/Resources/Info.plist | 15 + MenuBarItemService/Service.swift | 14 + MenuBarItemService/SourcePIDCache.swift | 259 ++++++++++++++++++ {Ice => Shared}/Bridging/Bridging.swift | 6 +- {Ice => Shared}/Bridging/Shims.swift | 2 +- Shared/Services/MenuBarItemService.swift | 22 ++ {Ice => Shared}/Utilities/Logging.swift | 6 +- Shared/Utilities/SharedExtensions.swift | 44 +++ {Ice => Shared}/Utilities/WindowInfo.swift | 94 ++++--- 36 files changed, 1241 insertions(+), 521 deletions(-) create mode 100644 Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift delete mode 100644 Ice/MenuBar/MenuBarItems/MenuBarItemSourceCache.swift create mode 100644 Ice/MenuBar/Search/MenuBarSearchModel.swift create mode 100644 MenuBarItemService/AXHelpers.swift create mode 100644 MenuBarItemService/Listener.swift create mode 100644 MenuBarItemService/Resources/Info.plist create mode 100644 MenuBarItemService/Service.swift create mode 100644 MenuBarItemService/SourcePIDCache.swift rename {Ice => Shared}/Bridging/Bridging.swift (99%) rename {Ice => Shared}/Bridging/Shims.swift (99%) create mode 100644 Shared/Services/MenuBarItemService.swift rename {Ice => Shared}/Utilities/Logging.swift (77%) create mode 100644 Shared/Utilities/SharedExtensions.swift rename {Ice => Shared}/Utilities/WindowInfo.swift (51%) diff --git a/Ice.xcodeproj/project.pbxproj b/Ice.xcodeproj/project.pbxproj index a58b7081c..8765a8121 100644 --- a/Ice.xcodeproj/project.pbxproj +++ b/Ice.xcodeproj/project.pbxproj @@ -12,13 +12,47 @@ 1787C4272B16890B002F50DF /* AXSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 1787C4262B16890B002F50DF /* AXSwift */; }; 17F71BB52B880B4500905CBA /* CompactSlider in Frameworks */ = {isa = PBXBuildFile; productRef = 17F71BB42B880B4500905CBA /* CompactSlider */; }; 7127A9FF2C4886D100D99DEF /* IfritStatic in Frameworks */ = {isa = PBXBuildFile; productRef = 7127A9FE2C4886D100D99DEF /* IfritStatic */; }; + 7168EE532E281CBC00FF9830 /* AXSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 7168EE522E281CBC00FF9830 /* AXSwift */; }; + 7188A68C2E27F9ED008F131D /* MenuBarItemService.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 7188A6832E27F9ED008F131D /* MenuBarItemService.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 7188A68A2E27F9ED008F131D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 716683222A767E6A006ABF84 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7188A6822E27F9ED008F131D; + remoteInfo = MenuBarItemService; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 7188A68D2E27F9ED008F131D /* Embed XPC Services */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/XPCServices"; + dstSubfolderSpec = 16; + files = ( + 7188A68C2E27F9ED008F131D /* MenuBarItemService.xpc in Embed XPC Services */, + ); + name = "Embed XPC Services"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 7166832A2A767E6A006ABF84 /* Ice.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ice.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7188A6832E27F9ED008F131D /* MenuBarItemService.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = MenuBarItemService.xpc; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 7188A6912E27F9ED008F131D /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Resources/Info.plist, + ); + target = 7188A6822E27F9ED008F131D /* MenuBarItemService */; + }; 71BDFC6C2C978E2A00EF145F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -30,6 +64,8 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 7188A6842E27F9ED008F131D /* MenuBarItemService */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (7188A6912E27F9ED008F131D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = MenuBarItemService; sourceTree = ""; }; + 7188A69E2E280BB4008F131D /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; }; 71BDFBE12C978E2A00EF145F /* Ice */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (71BDFC6C2C978E2A00EF145F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Ice; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -46,13 +82,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7188A6802E27F9ED008F131D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7168EE532E281CBC00FF9830 /* AXSwift in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 716683212A767E6A006ABF84 = { isa = PBXGroup; children = ( + 7188A69E2E280BB4008F131D /* Shared */, 71BDFBE12C978E2A00EF145F /* Ice */, + 7188A6842E27F9ED008F131D /* MenuBarItemService */, 7166832B2A767E6A006ABF84 /* Products */, ); sourceTree = ""; @@ -61,6 +107,7 @@ isa = PBXGroup; children = ( 7166832A2A767E6A006ABF84 /* Ice.app */, + 7188A6832E27F9ED008F131D /* MenuBarItemService.xpc */, ); name = Products; sourceTree = ""; @@ -76,12 +123,15 @@ 716683272A767E6A006ABF84 /* Frameworks */, 716683282A767E6A006ABF84 /* Resources */, 1720D48F2BB9B60500A7AC63 /* SwiftLint */, + 7188A68D2E27F9ED008F131D /* Embed XPC Services */, ); buildRules = ( ); dependencies = ( + 7188A68B2E27F9ED008F131D /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( + 7188A69E2E280BB4008F131D /* Shared */, 71BDFBE12C978E2A00EF145F /* Ice */, ); name = Ice; @@ -96,6 +146,30 @@ productReference = 7166832A2A767E6A006ABF84 /* Ice.app */; productType = "com.apple.product-type.application"; }; + 7188A6822E27F9ED008F131D /* MenuBarItemService */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7188A6902E27F9ED008F131D /* Build configuration list for PBXNativeTarget "MenuBarItemService" */; + buildPhases = ( + 7188A67F2E27F9ED008F131D /* Sources */, + 7188A6802E27F9ED008F131D /* Frameworks */, + 7188A6812E27F9ED008F131D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 7188A6842E27F9ED008F131D /* MenuBarItemService */, + 7188A69E2E280BB4008F131D /* Shared */, + ); + name = MenuBarItemService; + packageProductDependencies = ( + 7168EE522E281CBC00FF9830 /* AXSwift */, + ); + productName = MenuBarItemService; + productReference = 7188A6832E27F9ED008F131D /* MenuBarItemService.xpc */; + productType = "com.apple.product-type.xpc-service"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -103,12 +177,15 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1430; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 2600; TargetAttributes = { 716683292A767E6A006ABF84 = { CreatedOnToolsVersion = 14.3.1; }; + 7188A6822E27F9ED008F131D = { + CreatedOnToolsVersion = 26.0; + }; }; }; buildConfigurationList = 716683252A767E6A006ABF84 /* Build configuration list for PBXProject "Ice" */; @@ -132,6 +209,7 @@ projectRoot = ""; targets = ( 716683292A767E6A006ABF84 /* Ice */, + 7188A6822E27F9ED008F131D /* MenuBarItemService */, ); }; /* End PBXProject section */ @@ -144,6 +222,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7188A6812E27F9ED008F131D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -176,8 +261,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7188A67F2E27F9ED008F131D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 7188A68B2E27F9ED008F131D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7188A6822E27F9ED008F131D /* MenuBarItemService */; + targetProxy = 7188A68A2E27F9ED008F131D /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 716683372A767E6B006ABF84 /* Debug */ = { isa = XCBuildConfiguration; @@ -370,6 +470,61 @@ }; name = Release; }; + 7188A68E2E27F9ED008F131D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MenuBarItemService/Resources/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "MenuBarItemService (Ice)"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jordanbaird.Ice.MenuBarItemService; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 7188A68F2E27F9ED008F131D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MenuBarItemService/Resources/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "MenuBarItemService (Ice)"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.jordanbaird.Ice.MenuBarItemService; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -391,6 +546,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 7188A6902E27F9ED008F131D /* Build configuration list for PBXNativeTarget "MenuBarItemService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7188A68E2E27F9ED008F131D /* Debug */, + 7188A68F2E27F9ED008F131D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -462,6 +626,11 @@ package = 7127A9FB2C4881BC00D99DEF /* XCRemoteSwiftPackageReference "Ifrit" */; productName = IfritStatic; }; + 7168EE522E281CBC00FF9830 /* AXSwift */ = { + isa = XCSwiftPackageProductDependency; + package = 1787C4252B16890B002F50DF /* XCRemoteSwiftPackageReference "AXSwift" */; + productName = AXSwift; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 716683222A767E6A006ABF84 /* Project object */; diff --git a/Ice/Events/EventManager.swift b/Ice/Events/EventManager.swift index c04eb0380..f941499fe 100644 --- a/Ice/Events/EventManager.swift +++ b/Ice/Events/EventManager.swift @@ -239,7 +239,7 @@ extension EventManager { // Get the window that the user has clicked into. guard let mouseLocation = MouseCursor.locationCoreGraphics, - let windowUnderMouse = WindowInfo.getWindows(option: .onScreen) + let windowUnderMouse = WindowInfo.createWindows(option: .onScreen) .filter({ $0.layer < CGWindowLevelForKey(.cursorWindow) }) .first(where: { $0.bounds.contains(mouseLocation) && $0.title?.isEmpty == false }), let owningApplication = windowUnderMouse.owningApplication @@ -479,12 +479,12 @@ extension EventManager { guard let mouseLocation = MouseCursor.locationCoreGraphics else { return false } - let menuBarItems = MenuBarItem.getMenuBarItems( - on: screen.displayID, - option: [.onScreen, .activeSpace] - ) - return menuBarItems.contains { item in - item.bounds.contains(mouseLocation) + let windowIDs = Bridging.getMenuBarWindowList(option: [.onScreen, .activeSpace, .itemsOnly]) + return windowIDs.contains { windowID in + guard let bounds = Bridging.getWindowBounds(for: windowID) else { + return false + } + return bounds.contains(mouseLocation) } } diff --git a/Ice/Events/EventMonitors/GlobalEventMonitor.swift b/Ice/Events/EventMonitors/GlobalEventMonitor.swift index 81fe93628..82cfb50d2 100644 --- a/Ice/Events/EventMonitors/GlobalEventMonitor.swift +++ b/Ice/Events/EventMonitors/GlobalEventMonitor.swift @@ -72,15 +72,17 @@ extension GlobalEventMonitor { extension GlobalEventMonitor.GlobalEventPublisher { private final class GlobalEventSubscription>: Subscription { - var subscriber: S? - let monitor: GlobalEventMonitor + let mask: NSEvent.EventTypeMask + private var subscriber: S? + + private lazy var monitor = GlobalEventMonitor(mask: mask) { [weak self] event in + _ = self?.subscriber?.receive(event) + } init(mask: NSEvent.EventTypeMask, subscriber: S) { + self.mask = mask self.subscriber = subscriber - self.monitor = GlobalEventMonitor(mask: mask) { event in - _ = subscriber.receive(event) - } - monitor.start() + self.monitor.start() } func request(_ demand: Subscribers.Demand) { } diff --git a/Ice/Events/EventMonitors/LocalEventMonitor.swift b/Ice/Events/EventMonitors/LocalEventMonitor.swift index 814c0488a..bbbc46ab8 100644 --- a/Ice/Events/EventMonitors/LocalEventMonitor.swift +++ b/Ice/Events/EventMonitors/LocalEventMonitor.swift @@ -72,16 +72,18 @@ extension LocalEventMonitor { extension LocalEventMonitor.LocalEventPublisher { private final class LocalEventSubscription>: Subscription { - var subscriber: S? - let monitor: LocalEventMonitor + let mask: NSEvent.EventTypeMask + private var subscriber: S? + + private lazy var monitor = LocalEventMonitor(mask: mask) { [weak self] event in + _ = self?.subscriber?.receive(event) + return event + } init(mask: NSEvent.EventTypeMask, subscriber: S) { + self.mask = mask self.subscriber = subscriber - self.monitor = LocalEventMonitor(mask: mask) { event in - _ = subscriber.receive(event) - return event - } - monitor.start() + self.monitor.start() } func request(_ demand: Subscribers.Demand) { } diff --git a/Ice/Events/EventMonitors/RunLoopLocalEventMonitor.swift b/Ice/Events/EventMonitors/RunLoopLocalEventMonitor.swift index 57ecd519f..685363e4a 100644 --- a/Ice/Events/EventMonitors/RunLoopLocalEventMonitor.swift +++ b/Ice/Events/EventMonitors/RunLoopLocalEventMonitor.swift @@ -101,16 +101,20 @@ extension RunLoopLocalEventMonitor { extension RunLoopLocalEventMonitor.RunLoopLocalEventPublisher { private final class RunLoopLocalEventSubscription>: Subscription { - var subscriber: S? - let monitor: RunLoopLocalEventMonitor + let mask: NSEvent.EventTypeMask + let mode: RunLoop.Mode + private var subscriber: S? + + private lazy var monitor = RunLoopLocalEventMonitor(mask: mask, mode: mode) { [weak self] event in + _ = self?.subscriber?.receive(event) + return event + } init(mask: NSEvent.EventTypeMask, mode: RunLoop.Mode, subscriber: S) { + self.mask = mask + self.mode = mode self.subscriber = subscriber - self.monitor = RunLoopLocalEventMonitor(mask: mask, mode: mode) { event in - _ = subscriber.receive(event) - return event - } - monitor.start() + self.monitor.start() } func request(_ demand: Subscribers.Demand) { } diff --git a/Ice/Events/EventMonitors/UniversalEventMonitor.swift b/Ice/Events/EventMonitors/UniversalEventMonitor.swift index 98d0e8067..c84bbe8d8 100644 --- a/Ice/Events/EventMonitors/UniversalEventMonitor.swift +++ b/Ice/Events/EventMonitors/UniversalEventMonitor.swift @@ -64,16 +64,18 @@ extension UniversalEventMonitor { extension UniversalEventMonitor.UniversalEventPublisher { private final class UniversalEventSubscription>: Subscription { - var subscriber: S? - let monitor: UniversalEventMonitor + let mask: NSEvent.EventTypeMask + private var subscriber: S? + + private lazy var monitor = UniversalEventMonitor(mask: mask) { [weak self] event in + _ = self?.subscriber?.receive(event) + return event + } init(mask: NSEvent.EventTypeMask, subscriber: S) { + self.mask = mask self.subscriber = subscriber - self.monitor = UniversalEventMonitor(mask: mask) { event in - _ = subscriber.receive(event) - return event - } - monitor.start() + self.monitor.start() } func request(_ demand: Subscribers.Demand) { } diff --git a/Ice/Main/AppState.swift b/Ice/Main/AppState.swift index 1ceeac5ad..02ed9ac5a 100644 --- a/Ice/Main/AppState.swift +++ b/Ice/Main/AppState.swift @@ -55,28 +55,25 @@ final class AppState: ObservableObject { /// Logger for the app state. private let logger = Logger(category: "AppState") - /// Setup actions, run once on first access. - private lazy var setupActions: () = { - logger.info("Running setup actions") - + /// Async setup actions, run once on first access. + private lazy var setupTask = Task { permissions.stopAllChecks() if #available(macOS 26.0, *) { - MenuBarItemSourceCache.start(with: permissions) + await MenuBarItemService.Connection.shared.start() } settings.performSetup(with: self) - menuBarManager.performSetup(with: self) appearanceManager.performSetup(with: self) eventManager.performSetup(with: self) - itemManager.performSetup(with: self) + await itemManager.performSetup(with: self) imageCache.performSetup(with: self) updatesManager.performSetup(with: self) userNotificationManager.performSetup(with: self) configureCancellables() - }() + } /// Performs app state setup. /// @@ -84,11 +81,15 @@ final class AppState: ObservableObject { /// If `false`, prompts the user to grant permissions. func performSetup(hasPermissions: Bool) { if hasPermissions { - _ = setupActions + Task { + logger.debug("Setting up app state") + await setupTask.value + logger.debug("Finished setting up app state") + } } else { Task { // Delay to prevent conflicts with the app delegate. - try await Task.sleep(for: .milliseconds(100)) + try? await Task.sleep(for: .milliseconds(100)) activate(withPolicy: .regular) dismissWindow(.settings) // Shouldn't be open anyway. openWindow(.permissions) @@ -120,24 +121,36 @@ final class AppState: ObservableObject { Bridging.isActiveSpaceFullscreen() } .removeDuplicates() - .assign(to: &$isActiveSpaceFullscreen) + .sink { [weak self] isFullscreen in + self?.isActiveSpaceFullscreen = isFullscreen + } + .store(in: &c) NSWorkspace.shared.publisher(for: \.frontmostApplication) .receive(on: DispatchQueue.main) .map { $0 == .current } .removeDuplicates() - .assign(to: &navigationState.$isAppFrontmost) + .sink { [weak self] isFrontmost in + self?.navigationState.isAppFrontmost = isFrontmost + } + .store(in: &c) publisherForWindow(.settings) .flatMap { $0.publisher } // Short circuit if nil. .flatMap { $0.publisher(for: \.isVisible) } .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) .removeDuplicates() - .assign(to: &navigationState.$isSettingsPresented) + .sink { [weak self] isPresented in + self?.navigationState.isSettingsPresented = isPresented + } + .store(in: &c) eventManager.$isDraggingMenuBarItem .removeDuplicates() - .assign(to: &$isDraggingMenuBarItem) + .sink { [weak self] isDragging in + self?.isDraggingMenuBarItem = isDragging + } + .store(in: &c) Publishers.CombineLatest( navigationState.$isAppFrontmost, @@ -191,7 +204,7 @@ final class AppState: ObservableObject { /// Returns a publisher for the window with the given identifier. func publisherForWindow(_ id: IceWindowIdentifier) -> some Publisher { - return NSApp.publisher(for: \.windows).mergeMap { window in + NSApp.publisher(for: \.windows).mergeMap { window in window.publisher(for: \.identifier) .map { [weak window] identifier in guard identifier?.rawValue == id.rawValue else { diff --git a/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift b/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift index ca7696586..fe6927444 100644 --- a/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift +++ b/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift @@ -228,7 +228,7 @@ final class MenuBarOverlayPanel: NSPanel { // Must be run async, or this will not remove the flags. self.updateFlags.removeAll() } - let windows = WindowInfo.getWindows(option: .onScreen) + let windows = WindowInfo.createWindows(option: .onScreen) guard let owningDisplay = self.validate(for: .updates, with: windows) else { return } @@ -294,8 +294,8 @@ final class MenuBarOverlayPanel: NSPanel { /// of the given display. private func updateDesktopWallpaper(for display: CGDirectDisplayID, with windows: [WindowInfo]) { guard - let wallpaperWindow = WindowInfo.getWallpaperWindow(from: windows, for: display), - let menuBarWindow = WindowInfo.getMenuBarWindow(from: windows, for: display) + let wallpaperWindow = WindowInfo.wallpaperWindow(from: windows, for: display), + let menuBarWindow = WindowInfo.menuBarWindow(from: windows, for: display) else { return } @@ -557,11 +557,11 @@ private final class MenuBarOverlayPanelContentView: NSView { return CGRect(x: rect.minX, y: rect.minY, width: maxX, height: rect.height) }() let trailingPathBounds: CGRect = { - let items = MenuBarItem.getMenuBarItems(on: screen.displayID, option: .onScreen) - guard !items.isEmpty else { + let itemWindows = MenuBarItem.getMenuBarItemWindows(on: screen.displayID, option: .onScreen) + guard !itemWindows.isEmpty else { return .zero } - let totalWidth = items.reduce(into: 0) { width, item in + let totalWidth = itemWindows.reduce(into: 0) { width, item in width += item.bounds.width } var position = rect.maxX - totalWidth diff --git a/Ice/MenuBar/IceBar/IceBar.swift b/Ice/MenuBar/IceBar/IceBar.swift index cfdde3f55..b2d4bc0ab 100644 --- a/Ice/MenuBar/IceBar/IceBar.swift +++ b/Ice/MenuBar/IceBar/IceBar.swift @@ -359,6 +359,13 @@ private struct IceBarContentView: View { } else if menuBarManager.isMenuBarHiddenBySystemUserDefaults { Text("Ice cannot display menu bar items for automatically hidden menu bars") .padding(.horizontal, 10) + } else if itemManager.itemCache.managedItems.isEmpty { + HStack { + Text("Loading menu bar items…") + ProgressView() + .controlSize(.small) + } + .padding(.horizontal, 10) } else if imageCache.cacheFailed(for: section) { Text("Unable to display menu bar items") .padding(.horizontal, 10) diff --git a/Ice/MenuBar/IceBar/IceBarColorManager.swift b/Ice/MenuBar/IceBar/IceBarColorManager.swift index f6c4a6844..c13c1457f 100644 --- a/Ice/MenuBar/IceBar/IceBarColorManager.swift +++ b/Ice/MenuBar/IceBar/IceBarColorManager.swift @@ -115,12 +115,12 @@ final class IceBarColorManager: ObservableObject { } private func updateWindowImageInfo(for screen: NSScreen) { - let windows = WindowInfo.getWindows(option: .onScreen) + let windows = WindowInfo.createWindows(option: .onScreen) let displayID = screen.displayID guard - let menuBarWindow = WindowInfo.getMenuBarWindow(from: windows, for: displayID), - let wallpaperWindow = WindowInfo.getWallpaperWindow(from: windows, for: displayID) + let menuBarWindow = WindowInfo.menuBarWindow(from: windows, for: displayID), + let wallpaperWindow = WindowInfo.wallpaperWindow(from: windows, for: displayID) else { return } diff --git a/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift b/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift index bb2182958..ea1ee0961 100644 --- a/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift +++ b/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift @@ -98,18 +98,20 @@ final class LayoutBarPaddingView: NSView { if let index = arrangedViews.firstIndex(of: draggingSource) { if arrangedViews.count == 1 { - // dragging source is the only view in the layout bar, so we - // need to find a target item - let items = MenuBarItem.getMenuBarItems(option: .activeSpace) - let targetItem: MenuBarItem? = switch section.name { - case .visible: nil // visible section always has more than 1 item - case .hidden: items.first(matching: .hiddenControlItem) - case .alwaysHidden: items.first(matching: .alwaysHiddenControlItem) - } - if let targetItem { - move(item: draggingSource.item, to: .leftOfItem(targetItem)) - } else { - Logger.general.error("No target item for layout bar drag") + Task { + // dragging source is the only view in the layout bar, so we + // need to find a target item + let items = await MenuBarItem.getMenuBarItems(option: .activeSpace) + let targetItem: MenuBarItem? = switch section.name { + case .visible: nil // visible section always has more than 1 item + case .hidden: items.first(matching: .hiddenControlItem) + case .alwaysHidden: items.first(matching: .alwaysHiddenControlItem) + } + if let targetItem { + move(item: draggingSource.item, to: .leftOfItem(targetItem)) + } else { + Logger.general.error("No target item for layout bar drag") + } } } else if arrangedViews.indices.contains(index + 1) { // we have a view to the right of the dragging source diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift index beedc69a1..da86a36c5 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift @@ -103,8 +103,8 @@ struct MenuBarItem: CustomStringConvertible { // "PasswordsMenuBarExtra" -> "Passwords" // "WeatherMenu" -> "Weather" String(toTitleCase(bestName).prefix { !$0.isWhitespace }) - case .textInput: - "Text Input" + case .textInputMenuAgent: + toTitleCase(bestName).components(separatedBy: .whitespaces).prefix { $0 != "Agent" }.joined(separator: " ") case .controlCenter where title.hasPrefix("BentoBox"): bestName case .controlCenter where title == "WiFi": @@ -115,6 +115,7 @@ struct MenuBarItem: CustomStringConvertible { case .systemUIServer where title.contains("TimeMachine"): // Sonoma: "TimeMachine.TMMenuExtraHost" // Sequoia: "TimeMachineMenuExtra.TMMenuExtraHost" + // Tahoe: "com.apple.menuextra.TimeMachine" "Time Machine" case .controlCenter, .systemUIServer: // Most system items are hosted by one of these two apps. They @@ -222,11 +223,14 @@ extension MenuBarItem { /// Creates and returns a list of menu bar items using experimental /// source pid retrieval for macOS 26. @available(macOS 26.0, *) - private static func getMenuBarItemsExperimental(on display: CGDirectDisplayID?, option: ListOption) -> [MenuBarItem] { - getMenuBarItemWindows(on: display, option: option).map { window in - let sourcePID = MenuBarItemSourceCache.getCachedPID(for: window) - return MenuBarItem(uncheckedItemWindow: window, sourcePID: sourcePID) + private static func getMenuBarItemsExperimental(on display: CGDirectDisplayID?, option: ListOption) async -> [MenuBarItem] { + var items = [MenuBarItem]() + for window in getMenuBarItemWindows(on: display, option: option) { + let sourcePID = await MenuBarItemService.Connection.shared.sourcePID(for: window) + let item = MenuBarItem(uncheckedItemWindow: window, sourcePID: sourcePID) + items.append(item) } + return items } /// Creates and returns a list of menu bar items, defaulting to the @@ -244,9 +248,9 @@ extension MenuBarItem { /// items across all available displays. /// - option: Options that filter the returned list. Pass an empty option set /// to return all available menu bar items. - static func getMenuBarItems(on display: CGDirectDisplayID? = nil, option: ListOption) -> [MenuBarItem] { + static func getMenuBarItems(caller: String = #function, on display: CGDirectDisplayID? = nil, option: ListOption) async -> [MenuBarItem] { if #available(macOS 26.0, *) { - getMenuBarItemsExperimental(on: display, option: option) + await getMenuBarItemsExperimental(on: display, option: option) } else { getMenuBarItemsLegacyMethod(on: display, option: option) } @@ -313,6 +317,26 @@ private extension MenuBarItemTag { } self.title = title } + +// /// Creates a tag without checks. +// /// +// /// This initializer does not perform validity checks on its parameters. +// /// Only call it if you are certain the window is a valid menu bar item. +// init(uncheckedItemWindow itemWindow: WindowInfo) { +// self.namespace = Namespace(uncheckedItemWindow: itemWindow) +// self.title = itemWindow.title ?? "" +// } +// +// /// Creates a tag without checks. +// /// +// /// This initializer does not perform validity checks on its parameters. +// /// Only call it if you are certain the window is a valid menu bar item +// /// and the source pid belongs to the application that created it. +// @available(macOS 26.0, *) +// init(uncheckedItemWindow itemWindow: WindowInfo, sourcePID: pid_t?) { +// self.namespace = Namespace(uncheckedItemWindow: itemWindow, sourcePID: sourcePID) +// self.title = itemWindow.title ?? "" +// } } // MARK: - MenuBarItemTag.Namespace Helper diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index 62a908533..90c13d36c 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -117,6 +117,8 @@ final class MenuBarItemManager: ObservableObject { /// The last time a menu bar item was moved. private var lastItemMoveStartDate: Date? + private var cacheTask: Task? + /// A Boolean value that indicates whether a menu bar item has /// recently moved. var itemHasRecentlyMoved: Bool { @@ -127,8 +129,9 @@ final class MenuBarItemManager: ObservableObject { } /// Sets up the manager. - func performSetup(with appState: AppState) { + func performSetup(with appState: AppState) async { self.appState = appState + await cacheItemsRegardless() configureCancellables(with: appState) } @@ -260,18 +263,24 @@ extension MenuBarItemManager { /// Caches the current menu bar items, regardless of the current item /// state, ensuring that the control items are in the correct order. func cacheItemsRegardless(_ currentItemWindowIDs: [CGWindowID]? = nil) async { - var items = MenuBarItem.getMenuBarItems(option: .activeSpace) - cachedItemWindowIDs = currentItemWindowIDs ?? items.reversed().map { $0.windowID } + cacheTask?.cancel() + cacheTask = Task { + logger.debug("Preparing to cache menu bar items") - guard let controlItems = ControlItemSet(items: &items) else { - logger.warning("Missing control item for hidden section") - logger.debug("Clearing menu bar item cache") - itemCache.clear() - return - } + var items = await MenuBarItem.getMenuBarItems(option: .activeSpace) + cachedItemWindowIDs = currentItemWindowIDs ?? items.reversed().map { $0.windowID } + + guard let controlItems = ControlItemSet(items: &items) else { + logger.warning("Missing control item for hidden section") + logger.debug("Clearing menu bar item cache") + itemCache.clear() + return + } - await enforceControlItemOrder(controlItems: controlItems) - uncheckedCacheItems(controlItems: controlItems, otherItems: items) + await enforceControlItemOrder(controlItems: controlItems) + uncheckedCacheItems(controlItems: controlItems, otherItems: items) + } + await cacheTask?.value } /// Caches the current menu bar items if needed, ensuring that the @@ -1382,81 +1391,81 @@ extension MenuBarItemManager { logger.info("Temporarily showing \(item.logString, privacy: .public)") - var items = MenuBarItem.getMenuBarItems(option: .activeSpace) + Task { + var items = await MenuBarItem.getMenuBarItems(option: .activeSpace) - guard let destination = getReturnDestination(for: item, in: items) else { - logger.warning("No return destination for \(item.logString, privacy: .public)") - return - } + guard let destination = getReturnDestination(for: item, in: items) else { + logger.warning("No return destination for \(item.logString, privacy: .public)") + return + } - // Remove all items up to the hidden control item. - items.trimPrefix { $0.tag != .hiddenControlItem } - // Remove the hidden control item. - items.removeFirst() + // Remove all items up to the hidden control item. + items.trimPrefix { $0.tag != .hiddenControlItem } + // Remove the hidden control item. + items.removeFirst() - // Remove all offscreen items. - if #available(macOS 26.0, *) { - // TODO: isOnScreen doesn't work properly as of macOS 26 Developer Beta 1. Remove this if/when it works again. - items.trimPrefix { !Bridging.isWindowOnDisplay($0.windowID, displayID) } - } else { - items.trimPrefix { !$0.isOnScreen } - } + // Remove all offscreen items. + if #available(macOS 26.0, *) { + // TODO: isOnScreen doesn't work properly as of macOS 26 Developer Beta 1. Remove this if/when it works again. + items.trimPrefix { !Bridging.isWindowOnDisplay($0.windowID, displayID) } + } else { + items.trimPrefix { !$0.isOnScreen } + } - let maxX = if let rightArea = screen.auxiliaryTopRightArea { - max(rightArea.minX + 20, applicationMenuFrame.maxX) - } else { - applicationMenuFrame.maxX - } + let maxX = if let rightArea = screen.auxiliaryTopRightArea { + max(rightArea.minX + 20, applicationMenuFrame.maxX) + } else { + applicationMenuFrame.maxX + } - // Remove items until we have enough room to show this item. - items.trimPrefix { $0.bounds.minX - item.bounds.width <= maxX } + // Remove items until we have enough room to show this item. + items.trimPrefix { $0.bounds.minX - item.bounds.width <= maxX } - guard let targetItem = items.first else { - let alert = NSAlert() - alert.messageText = "Not enough room to show \"\(item.displayName)\"" - alert.runModal() - return - } + guard let targetItem = items.first else { + let alert = NSAlert() + alert.messageText = "Not enough room to show \"\(item.displayName)\"" + alert.runModal() + return + } - let contextTask = Task { - try await slowMove(item: item, to: .leftOfItem(targetItem)) - await eventSleep() + let contextTask = Task { + try await slowMove(item: item, to: .leftOfItem(targetItem)) + await eventSleep() - let context: TempShownItemContext + let context: TempShownItemContext - if clickWhenFinished { - let beforeWindows = WindowInfo.getWindows(option: .onScreen) + if clickWhenFinished { + let beforeWindows = WindowInfo.createWindows(option: .onScreen) - await eventSleep() - try await click(item: item, with: mouseButton) - await eventSleep(for: .seconds(0.25)) + await eventSleep() + try await click(item: item, with: mouseButton) + await eventSleep(for: .seconds(0.25)) - let afterWindows = WindowInfo.getWindows(option: .onScreen) + let afterWindows = WindowInfo.createWindows(option: .onScreen) - let shownInterfaceWindow = afterWindows.first { afterWindow in - afterWindow.ownerPID == item.sourcePID && - !beforeWindows.contains { beforeWindow in - afterWindow.windowID == beforeWindow.windowID + let shownInterfaceWindow = afterWindows.first { afterWindow in + afterWindow.ownerPID == item.sourcePID && + !beforeWindows.contains { beforeWindow in + afterWindow.windowID == beforeWindow.windowID + } } + + context = TempShownItemContext( + tag: item.tag, + returnDestination: destination, + shownInterfaceWindow: shownInterfaceWindow + ) + } else { + context = TempShownItemContext( + tag: item.tag, + returnDestination: destination, + shownInterfaceWindow: nil + ) } - context = TempShownItemContext( - tag: item.tag, - returnDestination: destination, - shownInterfaceWindow: shownInterfaceWindow - ) - } else { - context = TempShownItemContext( - tag: item.tag, - returnDestination: destination, - shownInterfaceWindow: nil - ) + return context } - return context - } - - Task { do { let context = try await contextTask.value tempShownItemContexts.append(context) @@ -1491,7 +1500,7 @@ extension MenuBarItemManager { var failedContexts = [TempShownItemContext]() - let items = MenuBarItem.getMenuBarItems(option: .activeSpace) + let items = await MenuBarItem.getMenuBarItems(option: .activeSpace) while let context = tempShownItemContexts.popLast() { guard let item = items.first(where: { $0.tag == context.tag }) else { diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift new file mode 100644 index 000000000..0f73e4d9e --- /dev/null +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift @@ -0,0 +1,161 @@ +// +// MenuBarItemServiceConnection.swift +// Ice +// + +import Foundation +import OSLog + +// MARK: - MenuBarItemService.Connection + +@available(macOS 26.0, *) +extension MenuBarItemService { + /// A connection to the `MenuBarItemService` XPC process. + final class Connection: Sendable { + /// The shared connection. + static let shared = Connection() + + /// The connection's underlying session. + private let session: Session + + /// The connection's target queue. + private let queue: DispatchQueue + + /// The connection's logger. + private let logger: Logger + + /// Creates a new connection. + private init() { + let queue = DispatchQueue.targetingGlobal(label: "MenuBarItemService.Connection.queue", qos: .userInteractive) + let logger = Logger(category: "MenuBarItemService.Connection") + self.session = Session(queue: queue, logger: logger) + self.queue = queue + self.logger = logger + } + + /// Starts the connection. + func start() async { + logger.debug("Starting MenuBarItemService connection") + + await withCheckedContinuation { continuation in + guard let response = session.send(request: .start) else { + logger.error("Start request returned nil") + continuation.resume() + return + } + if case .start = response { + continuation.resume() + } else { + logger.error("Start request returned invalid response \(String(describing: response))") + continuation.resume() + } + } + } + + /// Returns the source process identifier for the given window. + func sourcePID(for window: WindowInfo) async -> pid_t? { + await withCheckedContinuation { continuation in + guard let response = session.send(request: .sourcePID(window)) else { + logger.error("Source PID request returned nil") + continuation.resume(returning: nil) + return + } + if case .sourcePID(let pid) = response { + continuation.resume(returning: pid) + } else { + logger.error("Source PID request returned invalid response \(String(describing: response))") + continuation.resume(returning: nil) + } + } + } + } +} + +// MARK: - MenuBarItemService.Session + +@available(macOS 26.0, *) +extension MenuBarItemService { + /// A wrapper around an XPC session. + private final class Session: Sendable { + /// A session's underlying storage. + private final class Storage: @unchecked Sendable { + private let name = MenuBarItemService.name + private var session: XPCSession? + private let queue: DispatchQueue + private let logger: Logger + + init(queue: DispatchQueue, logger: Logger) { + self.queue = queue + self.logger = logger + } + + private func getOrCreateSession() throws -> XPCSession { + if let session { + return session + } + let session = try XPCSession(xpcService: name, options: .inactive) { [weak self] error in + guard let self else { + return + } + logger.warning("Session was cancelled with error \(error.localizedDescription)") + self.session = nil + } + session.setPeerRequirement(.isFromSameTeam()) + session.setTargetQueue(queue) + try session.activate() + self.session = session + return session + } + + func cancel(reason: String) { + guard let session = session.take() else { + return + } + session.cancel(reason: reason) + } + + func send(request: Request) -> Response? { + do { + let session = try getOrCreateSession() + let reply = try session.sendSync(request) + return try reply.decode(as: Response.self) + } catch { + logger.error("Session failed with error \(error)") + return nil + } + } + } + + /// Protected storage for the underlying XPC session. + private let storage: OSAllocatedUnfairLock + + /// The session's target queue. + private let queue: DispatchQueue + + /// The session's logger. + private let logger: Logger + + /// Creates a new session. + init(queue: DispatchQueue, logger: Logger) { + self.storage = OSAllocatedUnfairLock(initialState: Storage(queue: queue, logger: logger)) + self.queue = queue + self.logger = logger + } + + deinit { + cancel(reason: "Session deinitialized") + } + + /// Cancels the session. + func cancel(reason: String) { + storage.withLock { $0.cancel(reason: reason) } + } + + /// Sends the given request to the service and returns the response. + func send(request: Request) -> Response? { + storage.withLock { storage in + storage.send(request: request) + } + } + } +} diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemSourceCache.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemSourceCache.swift deleted file mode 100644 index e5c4a1445..000000000 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemSourceCache.swift +++ /dev/null @@ -1,208 +0,0 @@ -// -// MenuBarItemSourceCache.swift -// Ice -// - -import AXSwift -import Cocoa -import Combine -import os.lock - -// MARK: - MenuBarItemSourceCache - -@available(macOS 26.0, *) -enum MenuBarItemSourceCache { - private static let axQueue = DispatchQueue.queue( - label: "MenuBarItemSourceCache.axQueue", - qos: .utility, - attributes: .concurrent - ) - private static let serialWorkQueue = DispatchQueue( - label: "MenuBarItemSourceCache.serialWorkQueue", - qos: .userInteractive - ) - private static let concurrentWorkQueue = DispatchQueue( - label: "MenuBarItemSourceCache.concurrentWorkQueue", - qos: .userInteractive, - attributes: .concurrent - ) - - private final class CachedApplication: Sendable { - private struct ExtrasMenuBarLazyStorage: @unchecked Sendable { - var extrasMenuBar: UIElement? - var hasInitialized = false - } - - private let runningApp: NSRunningApplication - private let extrasMenuBarState = OSAllocatedUnfairLock(initialState: ExtrasMenuBarLazyStorage()) - - var processIdentifier: pid_t { - runningApp.processIdentifier - } - - var extrasMenuBar: UIElement? { - extrasMenuBarState.withLock { storage in - if storage.hasInitialized { - return storage.extrasMenuBar - } - - defer { - storage.hasInitialized = true - } - - // These checks help limit blocks that can occur when - // calling the AX APIs (app could be unresponsive, or - // in some other invalid state). - guard - !Bridging.isProcessUnresponsive(processIdentifier), - runningApp.isFinishedLaunching, - !runningApp.isTerminated, - runningApp.activationPolicy != .prohibited - else { - return nil - } - - storage.extrasMenuBar = axQueue.sync { - guard let app = Application(runningApp) else { - return nil - } - return try? app.attribute(.extrasMenuBar) - } - - return storage.extrasMenuBar - } - } - - init(_ runningApp: NSRunningApplication) { - self.runningApp = runningApp - } - } - - private struct State: Sendable { - var apps = [CachedApplication]() - var pids = [CGWindowID: pid_t]() - - mutating func updateCachedPID(for window: WindowInfo) { - let windowID = window.windowID - - for app in apps { - // Since we're running concurrently, we could have a pid - // at any point. - if pids[windowID] != nil { - return - } - - guard let bar = app.extrasMenuBar else { - continue - } - - for child in axQueue.sync(execute: { bar.children }) { - if pids[windowID] != nil { - return - } - - // Item window may have moved. Get the current bounds. - guard let windowBounds = Bridging.getWindowBounds(for: windowID) else { - pids.removeValue(forKey: windowID) - return - } - - guard windowBounds == window.bounds else { - return - } - - guard - let childFrame = axQueue.sync(execute: { child.frame }), - childFrame.center.distance(to: windowBounds.center) <= 10 - else { - continue - } - - pids[windowID] = app.processIdentifier - return - } - } - } - } - - private static let state = OSAllocatedUnfairLock(initialState: State()) - private static var cancellable: AnyCancellable? - - @MainActor - static func start(with permissions: AppPermissions) { - cancellable = NSWorkspace.shared.publisher(for: \.runningApplications) - .receive(on: serialWorkQueue) - .sink { [weak permissions] runningApps in - guard - let permissions, - permissions.accessibility.hasPermission - else { - return - } - - state.withLock { state in - // Convert the cached state to dictionaries keyed by pid to - // allow for efficient repeated access. - let appMappings = state.apps.reduce(into: [:]) { result, app in - result[app.processIdentifier] = app - } - let pidMappings = state.pids.reduce(into: [:]) { result, pair in - result[pair.value, default: []].append(pair) - } - - // Create a new state that matches the current running apps. - state = runningApps.reduce(into: State()) { result, app in - let pid = app.processIdentifier - - if let app = appMappings[pid] { - // Prefer the cached app, as it may have already done - // the work to initialize its extras menu bar. - result.apps.append(app) - } else { - // App wasn't in the cache, so it must be new. - result.apps.append(CachedApplication(app)) - } - - if let pids = pidMappings[pid] { - result.pids.merge(pids) { (_, new) in new } - } - } - } - - for window in MenuBarItem.getMenuBarItemWindows(option: []) { - concurrentWorkQueue.async { - state.withLock { state in - state.updateCachedPID(for: window) - } - } - } - } - } - - static func getCachedPID(for window: WindowInfo) -> pid_t? { - concurrentWorkQueue.sync { - state.withLock { state in - if let pid = state.pids[window.windowID] { - return pid - } - state.updateCachedPID(for: window) - return state.pids[window.windowID] - } - } - } -} - -// MARK: - DispatchQueue Helper - -private extension DispatchQueue { - /// Creates and returns a new dispatch queue that targets the global - /// system queue with the specified quality-of-service class. - static func queue( - label: String, - qos: DispatchQoS.QoSClass, - attributes: Attributes = [] - ) -> DispatchQueue { - let target: DispatchQueue = .global(qos: qos) - return DispatchQueue(label: label, attributes: attributes, target: target) - } -} diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift index e175d2ff6..41f0c027e 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift @@ -277,29 +277,28 @@ extension MenuBarItemTag { // MARK: MenuBarItemTag.Namespace Constants extension MenuBarItemTag.Namespace { - /// The namespace for menu bar items created by Ice. + /// The namespace for the "Ice" process. static let ice = Self(Constants.bundleIdentifier) - /// The namespace for menu bar items created by Control Center. + /// The namespace for the "Control Center" process. static let controlCenter = Self("com.apple.controlcenter") - /// The namespace for the "Passwords" menu bar item. + /// The namespace for the "PasswordsMenuBarExtra" process. static let passwords = Self("com.apple.Passwords.MenuBarExtra") - /// The namespace for the "stop recording" menu bar item that appears - /// during screen recordings started by the macOS "Screenshot" tool. + /// The namespace for the "screencaptureui" process. static let screenCaptureUI = Self("com.apple.screencaptureui") - /// The namespace for the "Spotlight" menu bar item. + /// The namespace for the "Spotlight" process. static let spotlight = Self("com.apple.Spotlight") - /// The namespace for menu bar items created by SystemUIServer. + /// The namespace for the "SystemUIServer" process. static let systemUIServer = Self("com.apple.systemuiserver") - /// The namespace for the "Text Input" menu bar item. - static let textInput = Self("com.apple.TextInputMenuAgent") + /// The namespace for the "TextInputMenuAgent" process. + static let textInputMenuAgent = Self("com.apple.TextInputMenuAgent") - /// The namespace for the "Weather" menu bar item. + /// The namespace for the "WeatherMenu" process. static let weather = Self("com.apple.weather.menu") /// The null namespace. diff --git a/Ice/MenuBar/MenuBarManager.swift b/Ice/MenuBar/MenuBarManager.swift index 7f89357d4..457563e89 100644 --- a/Ice/MenuBar/MenuBarManager.swift +++ b/Ice/MenuBar/MenuBarManager.swift @@ -190,34 +190,36 @@ final class MenuBarManager: ObservableObject { return } - // Get all items. - var items = MenuBarItem.getMenuBarItems(on: displayID, option: .activeSpace) - - // Filter the items down according to the currently enabled/shown sections. - if - let alwaysHiddenSection = section(withName: .alwaysHidden), - alwaysHiddenSection.isEnabled - { - if alwaysHiddenSection.controlItem.state == .hideSection { - if let alwaysHiddenControlItem = items.firstIndex(matching: .alwaysHiddenControlItem).map({ items.remove(at: $0) }) { - items.trimPrefix { $0.bounds.maxX <= alwaysHiddenControlItem.bounds.minX } + Task { + // Get all items. + var items = await MenuBarItem.getMenuBarItems(on: displayID, option: .activeSpace) + + // Filter the items down according to the currently enabled/shown sections. + if + let alwaysHiddenSection = self.section(withName: .alwaysHidden), + alwaysHiddenSection.isEnabled + { + if alwaysHiddenSection.controlItem.state == .hideSection { + if let alwaysHiddenControlItem = items.firstIndex(matching: .alwaysHiddenControlItem).map({ items.remove(at: $0) }) { + items.trimPrefix { $0.bounds.maxX <= alwaysHiddenControlItem.bounds.minX } + } + } + } else { + if let hiddenControlItem = items.firstIndex(matching: .hiddenControlItem).map({ items.remove(at: $0) }) { + items.trimPrefix { $0.bounds.maxX <= hiddenControlItem.bounds.minX } } } - } else { - if let hiddenControlItem = items.firstIndex(matching: .hiddenControlItem).map({ items.remove(at: $0) }) { - items.trimPrefix { $0.bounds.maxX <= hiddenControlItem.bounds.minX } - } - } - // Get the leftmost item on the screen. - guard let leftmostItem = items.min(by: { $0.bounds.minX < $1.bounds.minX }) else { - return - } + // Get the leftmost item on the screen. + guard let leftmostItem = items.min(by: { $0.bounds.minX < $1.bounds.minX }) else { + return + } - // If the minX of the item is less than or equal to the maxX of the - // application menu frame, activate the app to hide the menu. - if leftmostItem.bounds.minX <= applicationMenuFrame.maxX { - hideApplicationMenus() + // If the minX of the item is less than or equal to the maxX of the + // application menu frame, activate the app to hide the menu. + if leftmostItem.bounds.minX <= applicationMenuFrame.maxX { + self.hideApplicationMenus() + } } } else if isHidingApplicationMenus { showApplicationMenus() @@ -241,11 +243,11 @@ final class MenuBarManager: ObservableObject { let image: CGImage? let source: MenuBarAverageColorInfo.Source - let windows = WindowInfo.getWindows(option: .onScreen) + let windows = WindowInfo.createWindows(option: .onScreen) let displayID = screen.displayID if #available(macOS 26.0, *) { - if let window = WindowInfo.getWallpaperWindow(from: windows, for: displayID) { + if let window = WindowInfo.wallpaperWindow(from: windows, for: displayID) { var bounds = window.bounds bounds.size.height = 1 bounds.origin.x = bounds.midX @@ -257,7 +259,7 @@ final class MenuBarManager: ObservableObject { return } } else { - if let window = WindowInfo.getMenuBarWindow(from: windows, for: displayID) { + if let window = WindowInfo.menuBarWindow(from: windows, for: displayID) { var bounds = window.bounds bounds.size.height = 1 bounds.origin.x = bounds.maxX - (bounds.width / 4) @@ -265,7 +267,7 @@ final class MenuBarManager: ObservableObject { image = ScreenCapture.captureWindow(window.windowID, screenBounds: bounds, option: .nominalResolution) source = .menuBarWindow - } else if let window = WindowInfo.getWallpaperWindow(from: windows, for: displayID) { + } else if let window = WindowInfo.wallpaperWindow(from: windows, for: displayID) { var bounds = window.bounds bounds.size.height = 1 bounds.origin.x = bounds.midX @@ -295,12 +297,11 @@ final class MenuBarManager: ObservableObject { /// Returns a Boolean value that indicates whether the given display /// has a valid menu bar. func hasValidMenuBar(in windows: [WindowInfo], for display: CGDirectDisplayID) -> Bool { - guard let menuBarWindow = WindowInfo.getMenuBarWindow(from: windows, for: display) else { + guard let window = WindowInfo.menuBarWindow(from: windows, for: display) else { return false } - let position = menuBarWindow.bounds.origin do { - let uiElement = try systemWideElement.elementAtPosition(Float(position.x), Float(position.y)) + let uiElement = try systemWideElement.elementAtPosition(window.bounds.origin) return try uiElement?.role() == .menuBar } catch { return false @@ -312,16 +313,19 @@ final class MenuBarManager: ObservableObject { let displayBounds = CGDisplayBounds(displayID) guard - let menuBar = try? systemWideElement.elementAtPosition(Float(displayBounds.origin.x), Float(displayBounds.origin.y)), + let menuBar = try? systemWideElement.elementAtPosition(displayBounds.origin), let role = try? menuBar.role(), - role == .menuBar, - let items: [UIElement] = try? menuBar.arrayAttribute(.children)?.filter({ (try? $0.attribute(.enabled)) == true }) + role == .menuBar else { return nil } - let itemFrames = items.lazy.compactMap { try? $0.attribute(.frame) as CGRect? } - let applicationMenuFrame = itemFrames.reduce(.null, CGRectUnion) + let applicationMenuFrame = menuBar.children.reduce(CGRect.null) { result, item in + guard item.isEnabled, let frame = item.frame else { + return result + } + return result.union(frame) + } if applicationMenuFrame.width <= 0 { return nil diff --git a/Ice/MenuBar/Search/MenuBarSearchModel.swift b/Ice/MenuBar/Search/MenuBarSearchModel.swift new file mode 100644 index 000000000..d957c053e --- /dev/null +++ b/Ice/MenuBar/Search/MenuBarSearchModel.swift @@ -0,0 +1,23 @@ +// +// MenuBarSearchModel.swift +// Ice +// + +import Combine +import Ifrit + +@MainActor +final class MenuBarSearchModel: ObservableObject { + enum ItemID: Hashable { + case header(MenuBarSection.Name) + case item(MenuBarItemTag) + } + + typealias ListItem = SectionedListItem + + @Published var searchText = "" + @Published var displayedItems = [ListItem]() + @Published var selection: ItemID? + + let fuse = Fuse(threshold: 0.5) +} diff --git a/Ice/MenuBar/Search/MenuBarSearchPanel.swift b/Ice/MenuBar/Search/MenuBarSearchPanel.swift index d61bdf19e..d1a644cba 100644 --- a/Ice/MenuBar/Search/MenuBarSearchPanel.swift +++ b/Ice/MenuBar/Search/MenuBarSearchPanel.swift @@ -20,6 +20,9 @@ final class MenuBarSearchPanel: NSPanel { /// Storage for internal observers. private var cancellables = Set() + /// Model for menu bar item search. + private let model = MenuBarSearchModel() + /// Monitor for mouse down events. private lazy var mouseDownMonitor = UniversalEventMonitor( mask: [.leftMouseDown, .rightMouseDown, .otherMouseDown] @@ -108,7 +111,7 @@ final class MenuBarSearchPanel: NSPanel { Task { await appState.imageCache.updateCache() - let hostingView = MenuBarSearchHostingView(appState: appState, displayID: screen.displayID, panel: self) + let hostingView = MenuBarSearchHostingView(appState: appState, model: model, displayID: screen.displayID, panel: self) hostingView.setFrameSize(hostingView.intrinsicContentSize) setFrame(hostingView.frame, display: true) @@ -154,11 +157,13 @@ private final class MenuBarSearchHostingView: NSHostingView { init( appState: AppState, + model: MenuBarSearchModel, displayID: CGDirectDisplayID, panel: MenuBarSearchPanel ) { super.init( rootView: MenuBarSearchContentView( + model: model, displayID: displayID, closePanel: { [weak panel] in panel?.close() } ) @@ -181,21 +186,12 @@ private final class MenuBarSearchHostingView: NSHostingView { } private struct MenuBarSearchContentView: View { - private typealias ListItem = SectionedListItem - - private enum ItemID: Hashable { - case header(MenuBarSection.Name) - case item(MenuBarItemTag) - } + private typealias ListItem = MenuBarSearchModel.ListItem @EnvironmentObject var itemManager: MenuBarItemManager - @State private var searchText = "" - @State private var displayedItems = [SectionedListItem]() - @State private var selection: ItemID? + @ObservedObject var model: MenuBarSearchModel @FocusState private var searchFieldIsFocused: Bool - private let fuse = Fuse(threshold: 0.5) - let displayID: CGDirectDisplayID let closePanel: () -> Void @@ -209,7 +205,7 @@ private struct MenuBarSearchContentView: View { var body: some View { VStack(spacing: 0) { - TextField(text: $searchText, prompt: Text("Search menu bar items…")) { + TextField(text: $model.searchText, prompt: Text("Search menu bar items…")) { Text("Search menu bar items…") } .labelsHidden() @@ -221,15 +217,23 @@ private struct MenuBarSearchContentView: View { Divider() - if #available(macOS 26.0, *) { + if itemManager.itemCache.managedItems.isEmpty { + VStack { + Text("Loading menu bar items…") + .font(.title2) + ProgressView() + .controlSize(.small) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if #available(macOS 26.0, *) { GlassEffectContainer(spacing: 0) { - SectionedList(selection: $selection, items: $displayedItems) + SectionedList(selection: $model.selection, items: $model.displayedItems) .contentPadding(8) .scrollContentBackground(.hidden) } .clipped() } else { - SectionedList(selection: $selection, items: $displayedItems) + SectionedList(selection: $model.selection, items: $model.displayedItems) .contentPadding(8) .scrollContentBackground(.hidden) } @@ -248,7 +252,7 @@ private struct MenuBarSearchContentView: View { Spacer() if - let selection, + let selection = model.selection, let item = menuBarItem(for: selection) { ShowItemButton(item: item, displayID: displayID) { @@ -268,17 +272,20 @@ private struct MenuBarSearchContentView: View { .task { searchFieldIsFocused = true } - .onChange(of: searchText, initial: true) { + .onChange(of: model.searchText, initial: true) { updateDisplayedItems() selectFirstDisplayedItem() } .onChange(of: itemManager.itemCache, initial: true) { updateDisplayedItems() + if model.selection == nil { + selectFirstDisplayedItem() + } } } private func selectFirstDisplayedItem() { - selection = displayedItems.first { $0.isSelectable }?.id + model.selection = model.displayedItems.first { $0.isSelectable }?.id } private func updateDisplayedItems() { @@ -306,8 +313,8 @@ private struct MenuBarSearchContentView: View { } } - if searchText.isEmpty { - displayedItems = searchItems.map { $0.listItem } + if model.searchText.isEmpty { + model.displayedItems = searchItems.map { $0.listItem } } else { let selectableItems = searchItems.compactMap { searchItem in if searchItem.listItem.isSelectable { @@ -315,12 +322,12 @@ private struct MenuBarSearchContentView: View { } return nil } - let results = fuse.searchSync(searchText, in: selectableItems.map { $0.title }) - displayedItems = results.map { selectableItems[$0.index].listItem } + let results = model.fuse.searchSync(model.searchText, in: selectableItems.map { $0.title }) + model.displayedItems = results.map { selectableItems[$0.index].listItem } } } - private func menuBarItem(for selection: ItemID) -> MenuBarItem? { + private func menuBarItem(for selection: MenuBarSearchModel.ItemID) -> MenuBarItem? { switch selection { case .item(let tag): itemManager.itemCache.managedItems.first(matching: tag) case .header: nil @@ -443,6 +450,7 @@ private struct ShowItemButton: View { } } +@MainActor private let controlCenterIcon: NSImage? = { guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.controlcenter").first else { return nil @@ -475,7 +483,7 @@ private struct MenuBarSearchItemView: View { return nil } switch item.tag.namespace { - case .controlCenter, .systemUIServer, .textInput: + case .controlCenter, .systemUIServer, .textInputMenuAgent: return controlCenterIcon default: return sourceApplication.icon diff --git a/Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift b/Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift index 321ae2919..ad1502af6 100644 --- a/Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift +++ b/Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift @@ -158,7 +158,7 @@ final class MenuBarItemSpacingManager { try? await Task.sleep(for: .milliseconds(100)) - let items = MenuBarItem.getMenuBarItems(option: .activeSpace) + let items = await MenuBarItem.getMenuBarItems(option: .activeSpace) let pids = Set(items.map { $0.sourcePID ?? $0.ownerPID }) var failedApps = [String]() diff --git a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift index 65f60e15d..11528b0b9 100644 --- a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift @@ -7,6 +7,11 @@ import SwiftUI struct MenuBarLayoutSettingsPane: View { @EnvironmentObject var appState: AppState + @EnvironmentObject var itemManager: MenuBarItemManager + + private var hasItems: Bool { + !itemManager.itemCache.managedItems.isEmpty + } var body: some View { if !ScreenCapture.cachedCheckPermissions() { @@ -39,6 +44,18 @@ struct MenuBarLayoutSettingsPane: View { layoutBar(for: section) } } + .opacity(hasItems ? 1 : 0.75) + .blur(radius: hasItems ? 0 : 5) + .allowsHitTesting(hasItems) + .overlay { + if !hasItems { + VStack { + Text("Loading menu bar items…") + .font(.title) + ProgressView() + } + } + } } @ViewBuilder diff --git a/Ice/Settings/SettingsView.swift b/Ice/Settings/SettingsView.swift index 7d5258d8a..9bcfe61ec 100644 --- a/Ice/Settings/SettingsView.swift +++ b/Ice/Settings/SettingsView.swift @@ -97,6 +97,7 @@ struct SettingsView: View { GeneralSettingsPane(settings: appState.settings.general) case .menuBarLayout: MenuBarLayoutSettingsPane() + .environmentObject(appState.itemManager) case .menuBarAppearance: MenuBarAppearanceSettingsPane() case .hotkeys: diff --git a/Ice/Updates/UpdatesManager.swift b/Ice/Updates/UpdatesManager.swift index faa2333fc..a743f8b57 100644 --- a/Ice/Updates/UpdatesManager.swift +++ b/Ice/Updates/UpdatesManager.swift @@ -80,7 +80,7 @@ final class UpdatesManager: NSObject, ObservableObject { } // Activate the app in case an alert needs to be displayed. appState.activate(withPolicy: .regular) - appState.openSettingsWindow() + appState.openWindow(.settings) updater.checkForUpdates() #endif } diff --git a/Ice/Utilities/Extensions.swift b/Ice/Utilities/Extensions.swift index ba61137cd..9265b9c36 100644 --- a/Ice/Utilities/Extensions.swift +++ b/Ice/Utilities/Extensions.swift @@ -65,28 +65,6 @@ extension CGColor { } } -// MARK: - CGError - -extension CGError { - /// A string to use for logging purposes. - var logString: String { - switch self { - case .success: "\(rawValue): success" - case .failure: "\(rawValue): failure" - case .illegalArgument: "\(rawValue): illegalArgument" - case .invalidConnection: "\(rawValue): invalidConnection" - case .invalidContext: "\(rawValue): invalidContext" - case .cannotComplete: "\(rawValue): cannotComplete" - case .notImplemented: "\(rawValue): notImplemented" - case .rangeCheck: "\(rawValue): rangeCheck" - case .typeCheck: "\(rawValue): typeCheck" - case .invalidOperation: "\(rawValue): invalidOperation" - case .noneAvailable: "\(rawValue): noneAvailable" - @unknown default: "\(rawValue): unknown" - } - } -} - // MARK: - CGImage extension CGImage { @@ -485,7 +463,7 @@ extension NSScreen { /// Returns the height of the menu bar on this screen. func getMenuBarHeight() -> CGFloat? { - let menuBarWindow = WindowInfo.getMenuBarWindow(for: displayID) + let menuBarWindow = WindowInfo.menuBarWindow(for: displayID) return menuBarWindow?.bounds.height } } @@ -601,13 +579,18 @@ extension SystemWideElement { // MARK: - UIElement extension UIElement { + /// The element's child elements. + var children: [UIElement] { + (try? arrayAttribute(.children)) ?? [] + } + /// The element's frame. var frame: CGRect? { try? attribute(.frame) } - /// The element's child elements. - var children: [UIElement] { - (try? arrayAttribute(.children)) ?? [] + /// A Boolean value that indicates whether the element is enabled. + var isEnabled: Bool { + (try? attribute(.enabled)) == true } } diff --git a/Ice/Utilities/Predicates.swift b/Ice/Utilities/Predicates.swift index b6fb04517..f8086bbf6 100644 --- a/Ice/Utilities/Predicates.swift +++ b/Ice/Utilities/Predicates.swift @@ -34,34 +34,6 @@ enum Predicates { } } -// MARK: - Window Predicates - -extension Predicates where Input == WindowInfo { - /// Creates a predicate that returns whether a window is the wallpaper window - /// for the given display. - static func wallpaperWindow(for display: CGDirectDisplayID) -> NonThrowingPredicate { - predicate { window in - // wallpaper window belongs to the Dock process - window.owningApplication?.bundleIdentifier == "com.apple.dock" && - window.title?.hasPrefix("Wallpaper") == true && - CGDisplayBounds(display).contains(window.bounds) - } - } - - /// Creates a predicate that returns whether a window is the menu bar window for - /// the given display. - static func menuBarWindow(for display: CGDirectDisplayID) -> NonThrowingPredicate { - predicate { window in - // menu bar window belongs to the WindowServer process - window.isWindowServerWindow && - window.isOnScreen && - window.layer == kCGMainMenuWindowLevel && - window.title == "Menubar" && - CGDisplayBounds(display).contains(window.bounds) - } - } -} - // MARK: - Menu Bar Item Predicates extension Predicates where Input == MenuBarItem { diff --git a/MenuBarItemService/AXHelpers.swift b/MenuBarItemService/AXHelpers.swift new file mode 100644 index 000000000..ba63e0378 --- /dev/null +++ b/MenuBarItemService/AXHelpers.swift @@ -0,0 +1,39 @@ +// +// AXHelpers.swift +// MenuBarItemService +// + +import AXSwift +import Cocoa + +enum AXHelpers { + private static let queue = DispatchQueue.targetingGlobal( + label: "AXHelpers.queue", + qos: .utility, + attributes: .concurrent + ) + + static func isProcessTrusted() -> Bool { + queue.sync { checkIsProcessTrusted(prompt: false) } + } + + static func application(for runningApp: NSRunningApplication) -> Application? { + queue.sync { Application(runningApp) } + } + + static func extrasMenuBar(for app: Application) -> UIElement? { + queue.sync { try? app.attribute(.extrasMenuBar) } + } + + static func children(for element: UIElement) -> [UIElement] { + queue.sync { try? element.arrayAttribute(.children) } ?? [] + } + + static func isEnabled(_ element: UIElement) -> Bool { + queue.sync { try? element.attribute(.enabled) } ?? false + } + + static func frame(for element: UIElement) -> CGRect? { + queue.sync { try? element.attribute(.frame) } + } +} diff --git a/MenuBarItemService/Listener.swift b/MenuBarItemService/Listener.swift new file mode 100644 index 000000000..b15bf7afe --- /dev/null +++ b/MenuBarItemService/Listener.swift @@ -0,0 +1,99 @@ +// +// Listener.swift +// MenuBarItemService +// + +import OSLog +import XPC + +/// A wrapper around an xpc listener object. +final class Listener { + /// An error that can be thrown during listener activation. + enum ActivationError: Error, CustomStringConvertible { + case alreadyActive + case failure(any Error) + + var description: String { + switch self { + case .alreadyActive: + "Listener is already active" + case .failure(let error): + "Listener activation failed with error \(error)" + } + } + } + + /// The shared listener. + static let shared = Listener() + + /// The service name. + private let name = MenuBarItemService.name + + /// The underlying xpc listener object. + private var listener: XPCListener? + + /// Creates the shared listener. + private init() { } + + /// Handles a received message. + private func handleMessage(_ message: XPCReceivedMessage) -> MenuBarItemService.Response? { + do { + let request = try message.decode(as: MenuBarItemService.Request.self) + switch request { + case .start: + SourcePIDCache.shared.start() + return .start + case .sourcePID(let window): + let pid = SourcePIDCache.shared.pid(for: window) + return .sourcePID(pid) + } + } catch { + Logger.general.error("Service failed with error \(error)") + return nil + } + } + + /// Activates the listener without checking if it is already active, + /// with the requirement that session peers must be signed with the + /// same team identifier as the service process. + @available(macOS 26.0, *) + private func uncheckedActivateWithSameTeamRequirement() throws { + listener = try XPCListener(service: name, requirement: .isFromSameTeam()) { [weak self] request in + request.accept { message in + self?.handleMessage(message) + } + } + } + + /// Activates the listener without checking if it is already active. + private func uncheckedActivate() throws { + listener = try XPCListener(service: name) { [weak self] request in + request.accept { message in + self?.handleMessage(message) + } + } + } + + /// Activates the listener. + /// + /// - Note: This method throws an error if called on an active listener. + func activate() throws { + guard listener == nil else { + throw ActivationError.alreadyActive + } + do { + if #available(macOS 26.0, *) { + try uncheckedActivateWithSameTeamRequirement() + } else { + try uncheckedActivate() + } + } catch { + throw ActivationError.failure(error) + } + } + + /// Cancels the listener. + func cancel() { + listener.take()?.cancel() + } +} diff --git a/MenuBarItemService/Resources/Info.plist b/MenuBarItemService/Resources/Info.plist new file mode 100644 index 000000000..2ab43d9c5 --- /dev/null +++ b/MenuBarItemService/Resources/Info.plist @@ -0,0 +1,15 @@ + + + + + XPCService + + ServiceType + Application + JoinExistingSession + + RunLoopType + NSRunLoop + + + diff --git a/MenuBarItemService/Service.swift b/MenuBarItemService/Service.swift new file mode 100644 index 000000000..e8f33ab2c --- /dev/null +++ b/MenuBarItemService/Service.swift @@ -0,0 +1,14 @@ +// +// Service.swift +// MenuBarItemService +// + +import Foundation + +@main +enum Service { + static func main() throws { + try Listener.shared.activate() + RunLoop.current.run() + } +} diff --git a/MenuBarItemService/SourcePIDCache.swift b/MenuBarItemService/SourcePIDCache.swift new file mode 100644 index 000000000..c623ce18b --- /dev/null +++ b/MenuBarItemService/SourcePIDCache.swift @@ -0,0 +1,259 @@ +// +// SourcePIDCache.swift +// MenuBarItemService +// + +import AXSwift +import Cocoa +import Combine +import os.lock + +/// A cache for the source process identifiers for menu bar item windows. +/// +/// We use the term "source process" to refer to the process that created +/// a menu bar item. We used to be able to use the window's `ownerPID` to +/// determine this information, but in macOS 26 Tahoe, all item windows +/// are owned by Control Center. We need to be able to accurately identify +/// each item, and the source process is a good way to do that. Knowing +/// the source process also gives us an accurate name to show in various +/// places throughout the interface. +/// +/// We can find what we need using the Accessibility API, but it's quite +/// an intensive process. Since Accessibility blocks the main thread, the +/// cache lives in a separate XPC process, which the main process queries +/// asynchronously. +final class SourcePIDCache { + /// An object that contains a running application and provides an + /// interface to access relevant information, such as its process + /// identifier and extras menu bar. + private final class CachedApplication { + private let runningApp: NSRunningApplication + private var extrasMenuBar: UIElement? + + /// The app's process identifier. + var processIdentifier: pid_t { + runningApp.processIdentifier + } + + /// A Boolean value indicating whether the app's extras menu + /// bar has been successfully created and stored. + var hasExtrasMenuBar: Bool { + extrasMenuBar != nil + } + + /// A Boolean value indicating whether the app is in a valid + /// state for making accessibility calls. + var isValidForAccessibility: Bool { + // These checks help prevent blocking that can occur when + // calling AX APIs while the app is an invalid state. + runningApp.isFinishedLaunching && + !runningApp.isTerminated && + runningApp.activationPolicy != .prohibited && + !Bridging.isProcessUnresponsive(processIdentifier) + } + + /// Creates a `CachedApplication` instance with the given running + /// application. + init(_ runningApp: NSRunningApplication) { + self.runningApp = runningApp + } + + /// Returns the accessibility element representing the app's extras + /// menu bar, creating it if necessary. + /// + /// When the element is first created, it gets stored for efficient + /// access on subsequent calls. + func getOrCreateExtrasMenuBar() -> UIElement? { + if let extrasMenuBar { + return extrasMenuBar + } + guard + isValidForAccessibility, + let app = AXHelpers.application(for: runningApp), + let bar = AXHelpers.extrasMenuBar(for: app) + else { + return nil + } + extrasMenuBar = bar + return bar + } + } + + /// State for the cache. + private struct State { + var apps = [CachedApplication]() + var pids = [CGWindowID: pid_t]() + + /// Returns the latest bounds of the given window after ensuring + /// that the bounds are stable (a.k.a. not currently changing). + /// + /// This method blocks until stable bounds can be determined, or + /// until retrieving the bounds for the window fails. + private func stableBounds(for window: WindowInfo) -> CGRect? { + var cachedBounds = window.bounds + + for n in 1...5 { + guard let latestBounds = window.getLatestBounds() else { + // Failure here means the window probably doesn't + // exist anymore. + return nil + } + if latestBounds == cachedBounds { + return latestBounds + } + cachedBounds = latestBounds + // Sleep interval increases with each attempt. + Thread.sleep(forTimeInterval: TimeInterval(n) / 100) + } + + return nil + } + + /// Reorders the cached apps so that those that are confirmed + /// to have an extras menu bar are first in the array. + private mutating func partitionApps() { + var lhs = [CachedApplication]() + var rhs = [CachedApplication]() + + for app in apps { + if app.hasExtrasMenuBar { + lhs.append(app) + } else { + rhs.append(app) + } + } + + apps = lhs + rhs + } + + /// Updates the cached process identifier for the given window. + mutating func updatePID(for window: WindowInfo) { + guard + AXHelpers.isProcessTrusted(), + let windowBounds = stableBounds(for: window) + else { + return + } + + partitionApps() + + for app in apps { + guard let bar = app.getOrCreateExtrasMenuBar() else { + continue + } + for child in AXHelpers.children(for: bar) { + guard AXHelpers.isEnabled(child) else { + continue + } + guard + let childFrame = AXHelpers.frame(for: child), + childFrame.center.distance(to: windowBounds.center) <= 1 + else { + continue + } + pids[window.windowID] = app.processIdentifier + return + } + } + } + } + + /// The shared cache. + static let shared = SourcePIDCache() + + /// The cache's protected state. + private let state = OSAllocatedUnfairLock(initialState: State()) + + /// Storage for the cache's observers. + private var cancellables = Set() + + /// Creates the shared cache. + private init() { } + + /// Starts the observers for the cache. + func start() { + var c = Set() + + NSWorkspace.shared.publisher(for: \.runningApplications) + .sink { [weak self] runningApps in + guard let self else { + return + } + + let windowIDs = Bridging.getMenuBarWindowList(option: .itemsOnly) + + state.withLock { state in + // Convert the cached state to dictionaries keyed by pid to + // allow for efficient repeated access. + let appMappings = state.apps.reduce(into: [:]) { result, app in + result[app.processIdentifier] = app + } + let pidMappings: [pid_t: [CGWindowID: pid_t]] = windowIDs.reduce(into: [:]) { result, windowID in + if let pid = state.pids[windowID] { + result[pid, default: [:]][windowID] = pid + } + } + + // Create a new state that matches the current running apps. + state = runningApps.reduce(into: State()) { result, app in + let pid = app.processIdentifier + + if let app = appMappings[pid] { + // Prefer the cached app, as it may have already done + // the work to initialize its extras menu bar. + result.apps.append(app) + } else { + // App wasn't in the cache, so it must be new. + result.apps.append(CachedApplication(app)) + } + + if let pids = pidMappings[pid] { + result.pids.merge(pids) { (_, new) in new } + } + } + } + } + .store(in: &c) + + cancellables = c + } + + /// Returns the cached process identifier for the given window, + /// updating the cache if needed. + func pid(for window: WindowInfo) -> pid_t? { + state.withLock { state in + if let pid = state.pids[window.windowID] { + return pid + } + state.updatePID(for: window) + return state.pids[window.windowID] + } + } +} + +// MARK: - CGPoint Extension + +private extension CGPoint { + /// Returns the distance between this point and another point. + func distance(to other: CGPoint) -> CGFloat { + hypot(x - other.x, y - other.y) + } +} + +// MARK: - CGRect Extension + +private extension CGRect { + /// The center point of the rectangle. + var center: CGPoint { + CGPoint(x: midX, y: midY) + } +} + +// MARK: - WindowInfo Extension + +private extension WindowInfo { + /// Returns the latest bounds of the window. + func getLatestBounds() -> CGRect? { + Bridging.getWindowBounds(for: windowID) + } +} diff --git a/Ice/Bridging/Bridging.swift b/Shared/Bridging/Bridging.swift similarity index 99% rename from Ice/Bridging/Bridging.swift rename to Shared/Bridging/Bridging.swift index 6cbeb7b48..6adacd85b 100644 --- a/Ice/Bridging/Bridging.swift +++ b/Shared/Bridging/Bridging.swift @@ -1,6 +1,6 @@ // // Bridging.swift -// Ice +// Shared // import Cocoa @@ -305,8 +305,8 @@ extension Bridging { return list } - /// Returns a list of window identifiers for the elements of - /// the menu bar. + /// Returns a list of window identifiers for elements in the + /// menu bar. /// /// - Parameter option: Options that filter the returned list. /// Pass an empty option set to return all available windows. diff --git a/Ice/Bridging/Shims.swift b/Shared/Bridging/Shims.swift similarity index 99% rename from Ice/Bridging/Shims.swift rename to Shared/Bridging/Shims.swift index 0a1b57082..832103421 100644 --- a/Ice/Bridging/Shims.swift +++ b/Shared/Bridging/Shims.swift @@ -1,6 +1,6 @@ // // Shims.swift -// Ice +// Shared // import ApplicationServices diff --git a/Shared/Services/MenuBarItemService.swift b/Shared/Services/MenuBarItemService.swift new file mode 100644 index 000000000..b82f7c12f --- /dev/null +++ b/Shared/Services/MenuBarItemService.swift @@ -0,0 +1,22 @@ +// +// MenuBarItemService.swift +// Shared +// + +import Foundation + +enum MenuBarItemService { + static let name = "com.jordanbaird.Ice.MenuBarItemService" +} + +extension MenuBarItemService { + enum Request: Codable { + case start + case sourcePID(WindowInfo) + } + + enum Response: Codable { + case start + case sourcePID(pid_t?) + } +} diff --git a/Ice/Utilities/Logging.swift b/Shared/Utilities/Logging.swift similarity index 77% rename from Ice/Utilities/Logging.swift rename to Shared/Utilities/Logging.swift index 4819f0d11..efb88ef54 100644 --- a/Ice/Utilities/Logging.swift +++ b/Shared/Utilities/Logging.swift @@ -1,14 +1,16 @@ // // Logging.swift -// Ice +// Shared // import OSLog extension Logger { + private static let subsystem = Bundle.main.bundleIdentifier ?? "" + /// Creates a logger using the specified category. init(category: String) { - self.init(subsystem: Constants.bundleIdentifier, category: category) + self.init(subsystem: Self.subsystem, category: category) } } diff --git a/Shared/Utilities/SharedExtensions.swift b/Shared/Utilities/SharedExtensions.swift new file mode 100644 index 000000000..7dde1b651 --- /dev/null +++ b/Shared/Utilities/SharedExtensions.swift @@ -0,0 +1,44 @@ +// +// SharedExtensions.swift +// Shared +// + +import CoreGraphics +import Dispatch + +// MARK: - CGError + +extension CGError { + /// A string to use for logging purposes. + var logString: String { + switch self { + case .success: "\(rawValue): success" + case .failure: "\(rawValue): failure" + case .illegalArgument: "\(rawValue): illegalArgument" + case .invalidConnection: "\(rawValue): invalidConnection" + case .invalidContext: "\(rawValue): invalidContext" + case .cannotComplete: "\(rawValue): cannotComplete" + case .notImplemented: "\(rawValue): notImplemented" + case .rangeCheck: "\(rawValue): rangeCheck" + case .typeCheck: "\(rawValue): typeCheck" + case .invalidOperation: "\(rawValue): invalidOperation" + case .noneAvailable: "\(rawValue): noneAvailable" + @unknown default: "\(rawValue): unknown" + } + } +} + +// MARK: - DispatchQueue + +extension DispatchQueue { + /// Creates and returns a new dispatch queue that targets the global + /// system queue with the specified quality-of-service class. + static func targetingGlobal( + label: String, + qos: DispatchQoS.QoSClass, + attributes: Attributes = [] + ) -> DispatchQueue { + let target = DispatchQueue.global(qos: qos) + return DispatchQueue(label: label, attributes: attributes, target: target) + } +} diff --git a/Ice/Utilities/WindowInfo.swift b/Shared/Utilities/WindowInfo.swift similarity index 51% rename from Ice/Utilities/WindowInfo.swift rename to Shared/Utilities/WindowInfo.swift index 99939dfbb..6f11456c7 100644 --- a/Ice/Utilities/WindowInfo.swift +++ b/Shared/Utilities/WindowInfo.swift @@ -1,6 +1,6 @@ // // WindowInfo.swift -// Ice +// Shared // import Cocoa @@ -64,57 +64,89 @@ struct WindowInfo { } /// Creates a window with the given window identifier. + /// + /// - Parameter windowID: A window identifier. init?(windowID: CGWindowID) { + guard let window = WindowInfo.createWindows(from: [windowID]).first else { + return nil + } + self = window + } + + // MARK: Create Windows + + /// Creates a list of windows from the given list of window identifiers. + /// + /// - Parameter windowIDs: A list of window identifiers. + static func createWindows(from windowIDs: [CGWindowID]) -> [WindowInfo] { guard - let array = Bridging.createCGWindowArray(with: [windowID]), - let list = CGWindowListCreateDescriptionFromArray(array) as? [CFDictionary], - let dictionary = list.first + let array = Bridging.createCGWindowArray(with: windowIDs), + let list = CGWindowListCreateDescriptionFromArray(array) as? [CFDictionary] else { - return nil + return [] } - self.init(dictionary: dictionary) + return list.compactMap { WindowInfo(dictionary: $0) } } -} -// MARK: - WindowList Operations + /// Creates a list of windows using the given options. + /// + /// - Parameter option: Options that filter the returned list. + /// Pass an empty option set to return all available windows. + static func createWindows(option: Bridging.WindowListOption = []) -> [WindowInfo] { + createWindows(from: Bridging.getWindowList(option: option)) + } -// MARK: All Windows -extension WindowInfo { - /// Returns a list of windows using the given options. + /// Creates a list of windows for the elements in the menu bar + /// using the given options. /// /// - Parameter option: Options that filter the returned list. /// Pass an empty option set to return all available windows. - static func getWindows(option: Bridging.WindowListOption = []) -> [WindowInfo] { - Bridging.getWindowList(option: option).compactMap { WindowInfo(windowID: $0) } + static func createMenuBarWindows(option: Bridging.MenuBarWindowListOption = []) -> [WindowInfo] { + createWindows(from: Bridging.getMenuBarWindowList(option: option)) } -} -// MARK: Wallpaper Window -extension WindowInfo { - /// Returns the wallpaper window in the given windows for the given display. - static func getWallpaperWindow(from windows: [WindowInfo], for display: CGDirectDisplayID) -> WindowInfo? { - windows.first(where: Predicates.wallpaperWindow(for: display)) + // MARK: Wallpaper Window + + /// Returns the wallpaper window for the given display from the + /// given list of windows. + static func wallpaperWindow(from windows: [WindowInfo], for display: CGDirectDisplayID) -> WindowInfo? { + windows.first { window in + // Wallpaper window belongs to the Dock process. + window.owningApplication?.bundleIdentifier == "com.apple.dock" && + window.title?.hasPrefix("Wallpaper") == true && + CGDisplayBounds(display).contains(window.bounds) + } } - /// Returns the wallpaper window for the given display. - static func getWallpaperWindow(for display: CGDirectDisplayID) -> WindowInfo? { - getWallpaperWindow(from: getWindows(option: .onScreen), for: display) + /// Creates and returns the wallpaper window for the given display. + static func wallpaperWindow(for display: CGDirectDisplayID) -> WindowInfo? { + wallpaperWindow(from: createWindows(option: .onScreen), for: display) } -} -// MARK: Menu Bar Window -extension WindowInfo { - /// Returns the menu bar window for the given display. - static func getMenuBarWindow(from windows: [WindowInfo], for display: CGDirectDisplayID) -> WindowInfo? { - windows.first(where: Predicates.menuBarWindow(for: display)) + // MARK: Menu Bar Window + + /// Returns the menu bar window for the given display from the + /// given list of windows. + static func menuBarWindow(from windows: [WindowInfo], for display: CGDirectDisplayID) -> WindowInfo? { + windows.first { window in + // Menu bar window belongs to the WindowServer process. + window.isWindowServerWindow && + window.isOnScreen && + window.layer == kCGMainMenuWindowLevel && + window.title == "Menubar" && + CGDisplayBounds(display).contains(window.bounds) + } } - /// Returns the menu bar window for the given display. - static func getMenuBarWindow(for display: CGDirectDisplayID) -> WindowInfo? { - getMenuBarWindow(from: getWindows(option: .onScreen), for: display) + /// Creates and returns the menu bar window for the given display. + static func menuBarWindow(for display: CGDirectDisplayID) -> WindowInfo? { + menuBarWindow(from: createMenuBarWindows(option: .onScreen), for: display) } } +// MARK: WindowInfo: Codable +extension WindowInfo: Codable { } + // MARK: WindowInfo: Equatable extension WindowInfo: Equatable { static func == (lhs: WindowInfo, rhs: WindowInfo) -> Bool { From 9ce3ed3dc8532ba3291aaa46ee69b90d606a33f0 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 18 Jul 2025 10:41:16 -0600 Subject: [PATCH 38/80] Minor UI adjustments --- .../SettingsPanes/AboutSettingsPane.swift | 11 ++++--- Ice/UI/IceUI/IceGroupBox.swift | 31 ++++++++++++------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/Ice/Settings/SettingsPanes/AboutSettingsPane.swift b/Ice/Settings/SettingsPanes/AboutSettingsPane.swift index 695685edb..3cf9223f0 100644 --- a/Ice/Settings/SettingsPanes/AboutSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/AboutSettingsPane.swift @@ -74,22 +74,23 @@ struct AboutSettingsPane: View { Image(nsImage: nsImage) .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 225) + .frame(width: 230) } VStack(alignment: .leading) { Text("Ice") - .font(.system(size: 72, weight: .medium)) + .font(.system(size: 80)) .foregroundStyle(.primary) Text("Version \(Constants.versionString)") - .font(.system(size: 18)) + .font(.system(size: 15)) .foregroundStyle(.secondary) Text(Constants.copyrightString) - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.tertiary) + .font(.system(size: 14)) + .foregroundStyle(.secondary.opacity(0.67)) } + .fontWeight(.medium) } } } diff --git a/Ice/UI/IceUI/IceGroupBox.swift b/Ice/UI/IceUI/IceGroupBox.swift index c33177f39..6b863d4d4 100644 --- a/Ice/UI/IceUI/IceGroupBox.swift +++ b/Ice/UI/IceUI/IceGroupBox.swift @@ -157,21 +157,30 @@ struct IceGroupBox: View { var body: some View { VStack(alignment: .leading) { header - - VStack { - content - } - .padding(padding) - .background { - backgroundShape - .fill(.quinary.opacity(0.67)) - .strokeBorder(.quaternary) - } - .containerShape(backgroundShape) + .padding(.top, 8) + .padding(.bottom, 2) + .padding(.leading, 8) + + contentStack + .padding(padding) + .background { + backgroundShape + .fill(.quinary.opacity(0.67)) + .strokeBorder(.quaternary) + } + .containerShape(backgroundShape) footer + .padding(.top, 2) + .padding(.bottom, 8) + .padding(.leading, 8) } } + + @ViewBuilder + private var contentStack: some View { + VStack { content } + } } extension EdgeInsets { From 8eaec4b8afbb222a613b053fb1711a8905582907 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Sat, 19 Jul 2025 00:09:42 -0600 Subject: [PATCH 39/80] Make search interface respect menu bar average color --- Ice/MenuBar/Search/MenuBarSearchModel.swift | 63 ++++++++++++++++++++- Ice/MenuBar/Search/MenuBarSearchPanel.swift | 10 ++-- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/Ice/MenuBar/Search/MenuBarSearchModel.swift b/Ice/MenuBar/Search/MenuBarSearchModel.swift index d957c053e..4fcfa19ea 100644 --- a/Ice/MenuBar/Search/MenuBarSearchModel.swift +++ b/Ice/MenuBar/Search/MenuBarSearchModel.swift @@ -3,6 +3,7 @@ // Ice // +import Cocoa import Combine import Ifrit @@ -13,11 +14,67 @@ final class MenuBarSearchModel: ObservableObject { case item(MenuBarItemTag) } - typealias ListItem = SectionedListItem - @Published var searchText = "" - @Published var displayedItems = [ListItem]() + @Published var displayedItems = [SectionedListItem]() @Published var selection: ItemID? + @Published private(set) var averageColorInfo: MenuBarAverageColorInfo? + + private var cancellables = Set() let fuse = Fuse(threshold: 0.5) + + func performSetup(with panel: MenuBarSearchPanel) { + configureCancellables(with: panel) + } + + private func configureCancellables(with panel: MenuBarSearchPanel) { + var c = Set() + + Publishers.CombineLatest( + panel.publisher(for: \.screen), + panel.publisher(for: \.isVisible) + ) + .compactMap { screen, isVisible in + isVisible ? screen : nil + } + .sink { [weak self] screen in + self?.updateAverageColorInfo(for: screen) + } + .store(in: &c) + + cancellables = c + } + + private func updateAverageColorInfo(for screen: NSScreen) { + let windows = WindowInfo.createWindows(option: .onScreen) + let displayID = screen.displayID + + guard + let menuBarWindow = WindowInfo.menuBarWindow(from: windows, for: displayID), + let wallpaperWindow = WindowInfo.wallpaperWindow(from: windows, for: displayID) + else { + return + } + + let windowIDs = [menuBarWindow.windowID, wallpaperWindow.windowID] + let option: CGWindowImageOption = .nominalResolution + let bounds = with(wallpaperWindow.bounds) { bounds in + bounds.size.height = 1 + bounds.origin.x = bounds.midX + bounds.size.width /= 2 + } + + guard + let image = ScreenCapture.captureWindows(windowIDs, screenBounds: bounds, option: option), + let color = image.averageColor(makeOpaque: true) + else { + return + } + + let info = MenuBarAverageColorInfo(color: color, source: .menuBarWindow) + + if averageColorInfo != info { + averageColorInfo = info + } + } } diff --git a/Ice/MenuBar/Search/MenuBarSearchPanel.swift b/Ice/MenuBar/Search/MenuBarSearchPanel.swift index d1a644cba..c7a49e389 100644 --- a/Ice/MenuBar/Search/MenuBarSearchPanel.swift +++ b/Ice/MenuBar/Search/MenuBarSearchPanel.swift @@ -74,6 +74,7 @@ final class MenuBarSearchPanel: NSPanel { func performSetup(with appState: AppState) { self.appState = appState configureCancellables() + model.performSetup(with: self) } /// Configures the internal observers for the panel. @@ -163,13 +164,13 @@ private final class MenuBarSearchHostingView: NSHostingView { ) { super.init( rootView: MenuBarSearchContentView( - model: model, displayID: displayID, closePanel: { [weak panel] in panel?.close() } ) .environmentObject(appState) .environmentObject(appState.itemManager) .environmentObject(appState.imageCache) + .environmentObject(model) .erasedToAnyView() ) } @@ -186,10 +187,10 @@ private final class MenuBarSearchHostingView: NSHostingView { } private struct MenuBarSearchContentView: View { - private typealias ListItem = MenuBarSearchModel.ListItem + private typealias ListItem = SectionedListItem @EnvironmentObject var itemManager: MenuBarItemManager - @ObservedObject var model: MenuBarSearchModel + @EnvironmentObject var model: MenuBarSearchModel @FocusState private var searchFieldIsFocused: Bool let displayID: CGDirectDisplayID @@ -461,6 +462,7 @@ private let controlCenterIcon: NSImage? = { private struct MenuBarSearchItemView: View { @EnvironmentObject var appState: AppState @EnvironmentObject var imageCache: MenuBarItemImageCache + @EnvironmentObject var model: MenuBarSearchModel let item: MenuBarItem @@ -556,7 +558,7 @@ private struct MenuBarSearchItemView: View { @ViewBuilder private var imageViewWithBackground: some View { imageView - .layoutBarStyle(appState: appState, averageColorInfo: appState.menuBarManager.averageColorInfo) + .layoutBarStyle(appState: appState, averageColorInfo: model.averageColorInfo) .clipShape(backgroundShape) .overlay { backgroundShape From eaa5d9355f7a3977ce2be7aa613270080c8e69a7 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Sat, 19 Jul 2025 10:28:18 -0600 Subject: [PATCH 40/80] Better fuzzy search --- Ice/MenuBar/Search/MenuBarSearchPanel.swift | 25 ++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/Ice/MenuBar/Search/MenuBarSearchPanel.swift b/Ice/MenuBar/Search/MenuBarSearchPanel.swift index c7a49e389..a1db03e56 100644 --- a/Ice/MenuBar/Search/MenuBarSearchPanel.swift +++ b/Ice/MenuBar/Search/MenuBarSearchPanel.swift @@ -314,7 +314,9 @@ private struct MenuBarSearchContentView: View { } } - if model.searchText.isEmpty { + let searchText = model.searchText + + if searchText.isEmpty { model.displayedItems = searchItems.map { $0.listItem } } else { let selectableItems = searchItems.compactMap { searchItem in @@ -323,8 +325,25 @@ private struct MenuBarSearchContentView: View { } return nil } - let results = model.fuse.searchSync(model.searchText, in: selectableItems.map { $0.title }) - model.displayedItems = results.map { selectableItems[$0.index].listItem } + + let fuseResults = model.fuse.searchSync(searchText, in: selectableItems.map { $0.title }) + let maxFuseScore = Double(fuseResults.count) + + let scoredItems: [(listItem: ListItem, score: Double)] = fuseResults.enumerated().map { index, result in + let searchItem = selectableItems[result.index] + let fuseScore = maxFuseScore - Double(index) + + guard let match = bestMatch(query: searchText, input: searchItem.title, boundaryBonus: 16, camelCaseBonus: 16) else { + return (searchItem.listItem, fuseScore) + } + + let matchScore = Double(match.score.value) + let averageScore = (matchScore + fuseScore) / 2 + + return (searchItem.listItem, averageScore) + } + + model.displayedItems = scoredItems.lazy.sorted { $0.score > $1.score }.map { $0.listItem } } } From eb5d14ad74988ad6d91300e65e6d449d325e6bff Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Mon, 21 Jul 2025 13:06:17 -0600 Subject: [PATCH 41/80] Lots of refactoring --- Ice.xcodeproj/project.pbxproj | 6 +- Ice/Events/EventManager.swift | 48 +- Ice/Events/EventMonitor.swift | 396 ++++ .../EventMonitors/GlobalEventMonitor.swift | 95 - .../EventMonitors/LocalEventMonitor.swift | 96 - .../EventMonitors/UniversalEventMonitor.swift | 88 - Ice/Events/EventTap.swift | 301 ++- .../RunLoopLocalEventMonitor.swift | 0 Ice/Main/AppDelegate.swift | 27 +- Ice/Main/AppState.swift | 96 +- .../Updates.swift} | 2 +- .../MenuBarAppearanceConfigurationV1.swift | 24 +- .../MenuBarAppearanceConfigurationV2.swift | 21 +- .../MenuBarAppearanceEditor.swift | 121 +- .../MenuBarAppearanceEditorPanel.swift | 165 +- .../MenuBarShapePicker.swift | 75 +- .../Appearance/MenuBarAppearanceManager.swift | 7 +- .../Appearance/MenuBarOverlayPanel.swift | 59 +- Ice/MenuBar/Appearance/MenuBarShape.swift | 2 +- Ice/MenuBar/Appearance/MenuBarTintKind.swift | 4 +- Ice/MenuBar/ControlItem/ControlItem.swift | 65 +- Ice/MenuBar/IceBar/IceBar.swift | 36 +- Ice/MenuBar/IceBar/IceBarColorManager.swift | 36 +- Ice/MenuBar/LayoutBar/LayoutBar.swift | 43 +- .../LayoutBar/LayoutBarContainer.swift | 17 +- .../LayoutBar/LayoutBarPaddingView.swift | 18 +- .../LayoutBar/LayoutBarScrollView.swift | 11 +- Ice/MenuBar/MenuBarItems/MenuBarItem.swift | 24 +- .../MenuBarItems/MenuBarItemImageCache.swift | 86 +- .../MenuBarItems/MenuBarItemManager.swift | 1960 ++++++++--------- Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift | 125 +- Ice/MenuBar/MenuBarManager.swift | 139 +- Ice/MenuBar/MenuBarSection.swift | 9 +- Ice/MenuBar/Search/MenuBarSearchModel.swift | 16 +- Ice/MenuBar/Search/MenuBarSearchPanel.swift | 18 +- Ice/Permissions/PermissionsWindow.swift | 2 +- .../AccentColor.colorset/Contents.json | 0 .../AppIcon.appiconset/Contents.json | 0 .../AppIcon.appiconset/icon_128x128.png | Bin .../AppIcon.appiconset/icon_128x128@2x.png | Bin .../AppIcon.appiconset/icon_16x16.png | Bin .../AppIcon.appiconset/icon_16x16@2x.png | Bin .../AppIcon.appiconset/icon_256x256.png | Bin .../AppIcon.appiconset/icon_256x256@2x.png | Bin .../AppIcon.appiconset/icon_32x32.png | Bin .../AppIcon.appiconset/icon_32x32@2x.png | Bin .../AppIcon.appiconset/icon_512x512.png | Bin .../AppIcon.appiconset/icon_512x512@2x.png | Bin .../Assets.xcassets/Contents.json | 0 .../ControlItemImages/Contents.json | 0 .../ControlItemImages/Dot/Contents.json | 0 .../Dot/DotFill.imageset/Contents.json | 0 .../Dot/DotFill.imageset/DotFill.png | Bin .../Dot/DotStroke.imageset/Contents.json | 0 .../Dot/DotStroke.imageset/DotStroke.png | Bin .../ControlItemImages/Ellipsis/Contents.json | 0 .../EllipsisFill.imageset/Contents.json | 0 .../EllipsisFill.imageset/EllipsisFill.png | Bin .../EllipsisStroke.imageset/Contents.json | 0 .../EllipsisStroke.png | Bin .../ControlItemImages/IceCube/Contents.json | 0 .../IceCubeFill.imageset/Contents.json | 0 .../IceCubeFill.imageset/IceCubeFill.png | Bin .../IceCubeStroke.imageset/Contents.json | 0 .../IceCubeStroke.imageset/IceCubeStroke.png | Bin .../Contents.json | 0 .../Warning.imageset/Contents.json | 0 .../Warning.imageset/Warning.png | Bin Ice/{ => Resources}/Info.plist | 0 Ice/Settings/Models/HotkeysSettings.swift | 2 +- .../MenuBarLayoutSettingsPane.swift | 8 +- Ice/UI/IceUI/IceColorPicker.swift | 171 ++ Ice/UI/IceUI/IceGradientPicker.swift | 397 ++++ .../CustomColorPicker/CustomColorPicker.swift | 130 -- .../CustomGradientPicker/ColorStop.swift | 41 - .../CustomGradientPicker/CustomGradient.swift | 118 - .../CustomGradientPicker.swift | 446 ---- .../Utilities/IceColor.swift} | 12 +- Ice/UI/Utilities/IceGradient.swift | 257 +++ Ice/UI/ViewModifiers/LayoutBarStyle.swift | 56 - .../LocalEventMonitorModifier.swift | 64 +- Ice/UI/ViewModifiers/OnKeyDown.swift | 28 +- Ice/UI/Views/DismissWindowButton.swift | 49 + Ice/UI/Views/HotkeyRecorder.swift | 20 +- Ice/UI/Views/MenuBarItemContainer.swift | 117 + Ice/UI/Views/SectionedList.swift | 13 +- Ice/Utilities/Extensions.swift | 217 +- Ice/Utilities/Helpers.swift | 39 + Ice/Utilities/Injection.swift | 34 - Ice/Utilities/Migration.swift | 2 +- Ice/Utilities/Notifications.swift | 11 - Ice/Utilities/Predicates.swift | 59 - Ice/Utilities/ScreenCapture.swift | 20 +- Ice/Utilities/SpaceInfo.swift | 38 + MenuBarItemService/Service.swift | 1 + MenuBarItemService/SourcePIDCache.swift | 18 - Shared/Bridging/Bridging.swift | 171 +- Shared/Bridging/Shims.swift | 49 +- Shared/Utilities/SharedExtensions.swift | 18 + 99 files changed, 3586 insertions(+), 3258 deletions(-) create mode 100644 Ice/Events/EventMonitor.swift delete mode 100644 Ice/Events/EventMonitors/GlobalEventMonitor.swift delete mode 100644 Ice/Events/EventMonitors/LocalEventMonitor.swift delete mode 100644 Ice/Events/EventMonitors/UniversalEventMonitor.swift rename Ice/Events/{EventMonitors => }/RunLoopLocalEventMonitor.swift (100%) rename Ice/{Updates/UpdatesManager.swift => Main/Updates.swift} (99%) rename Ice/{ => Resources}/Assets.xcassets/AccentColor.colorset/Contents.json (100%) rename Ice/{ => Resources}/Assets.xcassets/AppIcon.appiconset/Contents.json (100%) rename Ice/{ => Resources}/Assets.xcassets/AppIcon.appiconset/icon_128x128.png (100%) rename Ice/{ => Resources}/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png (100%) rename Ice/{ => Resources}/Assets.xcassets/AppIcon.appiconset/icon_16x16.png (100%) rename Ice/{ => Resources}/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png (100%) rename Ice/{ => Resources}/Assets.xcassets/AppIcon.appiconset/icon_256x256.png (100%) rename Ice/{ => Resources}/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png (100%) rename Ice/{ => Resources}/Assets.xcassets/AppIcon.appiconset/icon_32x32.png (100%) rename Ice/{ => Resources}/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png (100%) rename Ice/{ => Resources}/Assets.xcassets/AppIcon.appiconset/icon_512x512.png (100%) rename Ice/{ => Resources}/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png (100%) rename Ice/{ => Resources}/Assets.xcassets/Contents.json (100%) rename Ice/{ => Resources}/Assets.xcassets/ControlItemImages/Contents.json (100%) rename Ice/{ => Resources}/Assets.xcassets/ControlItemImages/Dot/Contents.json (100%) rename Ice/{ => Resources}/Assets.xcassets/ControlItemImages/Dot/DotFill.imageset/Contents.json (100%) rename Ice/{ => Resources}/Assets.xcassets/ControlItemImages/Dot/DotFill.imageset/DotFill.png (100%) rename Ice/{ => Resources}/Assets.xcassets/ControlItemImages/Dot/DotStroke.imageset/Contents.json (100%) rename Ice/{ => Resources}/Assets.xcassets/ControlItemImages/Dot/DotStroke.imageset/DotStroke.png (100%) rename Ice/{ => Resources}/Assets.xcassets/ControlItemImages/Ellipsis/Contents.json (100%) rename Ice/{ => Resources}/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisFill.imageset/Contents.json (100%) rename Ice/{ => Resources}/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisFill.imageset/EllipsisFill.png (100%) rename Ice/{ => Resources}/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisStroke.imageset/Contents.json (100%) rename Ice/{ => Resources}/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisStroke.imageset/EllipsisStroke.png (100%) rename Ice/{ => Resources}/Assets.xcassets/ControlItemImages/IceCube/Contents.json (100%) rename Ice/{ => Resources}/Assets.xcassets/ControlItemImages/IceCube/IceCubeFill.imageset/Contents.json (100%) rename Ice/{ => Resources}/Assets.xcassets/ControlItemImages/IceCube/IceCubeFill.imageset/IceCubeFill.png (100%) rename Ice/{ => Resources}/Assets.xcassets/ControlItemImages/IceCube/IceCubeStroke.imageset/Contents.json (100%) rename Ice/{ => Resources}/Assets.xcassets/ControlItemImages/IceCube/IceCubeStroke.imageset/IceCubeStroke.png (100%) rename Ice/{ => Resources}/Assets.xcassets/DefaultLayoutBarColor.colorset/Contents.json (100%) rename Ice/{ => Resources}/Assets.xcassets/Warning.imageset/Contents.json (100%) rename Ice/{ => Resources}/Assets.xcassets/Warning.imageset/Warning.png (100%) rename Ice/{ => Resources}/Info.plist (100%) create mode 100644 Ice/UI/IceUI/IceColorPicker.swift create mode 100644 Ice/UI/IceUI/IceGradientPicker.swift delete mode 100644 Ice/UI/Pickers/CustomColorPicker/CustomColorPicker.swift delete mode 100644 Ice/UI/Pickers/CustomGradientPicker/ColorStop.swift delete mode 100644 Ice/UI/Pickers/CustomGradientPicker/CustomGradient.swift delete mode 100644 Ice/UI/Pickers/CustomGradientPicker/CustomGradientPicker.swift rename Ice/{Utilities/CodableColor.swift => UI/Utilities/IceColor.swift} (92%) create mode 100644 Ice/UI/Utilities/IceGradient.swift delete mode 100644 Ice/UI/ViewModifiers/LayoutBarStyle.swift create mode 100644 Ice/UI/Views/DismissWindowButton.swift create mode 100644 Ice/UI/Views/MenuBarItemContainer.swift create mode 100644 Ice/Utilities/Helpers.swift delete mode 100644 Ice/Utilities/Injection.swift delete mode 100644 Ice/Utilities/Notifications.swift create mode 100644 Ice/Utilities/SpaceInfo.swift diff --git a/Ice.xcodeproj/project.pbxproj b/Ice.xcodeproj/project.pbxproj index 8765a8121..66f518698 100644 --- a/Ice.xcodeproj/project.pbxproj +++ b/Ice.xcodeproj/project.pbxproj @@ -56,8 +56,8 @@ 71BDFC6C2C978E2A00EF145F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - Info.plist, Resources/Acknowledgements.rtf, + Resources/Info.plist, ); target = 716683292A767E6A006ABF84 /* Ice */; }; @@ -420,7 +420,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Ice/Info.plist; + INFOPLIST_FILE = Ice/Resources/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Jordan Baird"; @@ -454,7 +454,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Ice/Info.plist; + INFOPLIST_FILE = Ice/Resources/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Jordan Baird"; diff --git a/Ice/Events/EventManager.swift b/Ice/Events/EventManager.swift index f941499fe..883aeddda 100644 --- a/Ice/Events/EventManager.swift +++ b/Ice/Events/EventManager.swift @@ -21,8 +21,8 @@ final class EventManager: ObservableObject { // MARK: Monitors /// Monitor for mouse down events. - private(set) lazy var mouseDownMonitor = UniversalEventMonitor( - mask: [.leftMouseDown, .rightMouseDown] + private(set) lazy var mouseDownMonitor = EventMonitor.universal( + for: [.leftMouseDown, .rightMouseDown] ) { [weak self] event in guard let self, let appState, let screen = bestScreen(appState: appState) else { return event @@ -41,16 +41,16 @@ final class EventManager: ObservableObject { } /// Monitor for mouse up events. - private(set) lazy var mouseUpMonitor = UniversalEventMonitor( - mask: .leftMouseUp + private(set) lazy var mouseUpMonitor = EventMonitor.universal( + for: .leftMouseUp ) { [weak self] event in self?.handleLeftMouseUp() return event } /// Monitor for mouse dragged events. - private(set) lazy var mouseDraggedMonitor = UniversalEventMonitor( - mask: .leftMouseDragged + private(set) lazy var mouseDraggedMonitor = EventMonitor.universal( + for: .leftMouseDragged ) { [weak self] event in if let self, let appState, let screen = bestScreen(appState: appState) { handleLeftMouseDragged(with: event, appState: appState, screen: screen) @@ -62,9 +62,9 @@ final class EventManager: ObservableObject { private(set) lazy var mouseMovedTap = EventTap( options: .listenOnly, location: .hidEventTap, - place: .tailAppendEventTap, - types: [.mouseMoved] - ) { [weak self] _, _, event in + placement: .tailAppendEventTap, + type: .mouseMoved + ) { [weak self] _, event in if let self, let appState, let screen = bestScreen(appState: appState) { handleShowOnHover(appState: appState, screen: screen) } @@ -72,8 +72,8 @@ final class EventManager: ObservableObject { } /// Monitor for scroll wheel events. - private(set) lazy var scrollWheelMonitor = UniversalEventMonitor( - mask: .scrollWheel + private(set) lazy var scrollWheelMonitor = EventMonitor.universal( + for: .scrollWheel ) { [weak self] event in if let self, let appState, let screen = bestScreen(appState: appState) { handleShowOnScroll(with: event, appState: appState, screen: screen) @@ -111,7 +111,7 @@ final class EventManager: ObservableObject { // menu bar, and run the show-on-hover check when it changes. Publishers.CombineLatest3( hiddenSection.controlItem.$frame, - appState.$isActiveSpaceFullscreen, + appState.$activeSpace.map(\.isFullscreen), appState.menuBarManager.$isMenuBarHiddenBySystem ) .receive(on: DispatchQueue.main) @@ -269,14 +269,18 @@ extension EventManager { // MARK: Handle Show Secondary Context Menu private func handleShowSecondaryContextMenu(appState: AppState, screen: NSScreen) { - guard - appState.settings.advanced.enableSecondaryContextMenu, - isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen), - let mouseLocation = MouseCursor.locationAppKit - else { - return + Task { + guard + appState.settings.advanced.enableSecondaryContextMenu, + isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen), + let mouseLocation = MouseCursor.locationAppKit + else { + return + } + // This delay prevents the menu from immediately closing. + try await Task.sleep(for: .milliseconds(100)) + appState.menuBarManager.showSecondaryContextMenu(at: mouseLocation) } - appState.menuBarManager.showSecondaryContextMenu(at: mouseLocation) } // MARK: Handle Prevent Show On Hover @@ -430,7 +434,7 @@ extension EventManager { /// Returns the best screen to use for event manager calculations. func bestScreen(appState: AppState) -> NSScreen? { guard - appState.isActiveSpaceFullscreen, + appState.activeSpace.isFullscreen, let screen = NSScreen.screenWithMouse else { return NSScreen.main @@ -464,7 +468,7 @@ extension EventManager { func isMouseInsideApplicationMenu(appState: AppState, screen: NSScreen) -> Bool { guard let mouseLocation = MouseCursor.locationCoreGraphics, - var applicationMenuFrame = appState.menuBarManager.getApplicationMenuFrame(for: screen.displayID) + var applicationMenuFrame = screen.getApplicationMenuFrame() else { return false } @@ -549,7 +553,7 @@ private protocol EventMonitorProtocol { func stop() } -extension UniversalEventMonitor: EventMonitorProtocol { } +extension EventMonitor: EventMonitorProtocol { } extension EventTap: EventMonitorProtocol { fileprivate func start() { diff --git a/Ice/Events/EventMonitor.swift b/Ice/Events/EventMonitor.swift new file mode 100644 index 000000000..e3f3715aa --- /dev/null +++ b/Ice/Events/EventMonitor.swift @@ -0,0 +1,396 @@ +// +// EventMonitor.swift +// Ice +// + +import Cocoa +import Combine +import os.lock + +struct EventMonitor: Sendable { + private final class LocalMonitorState: @unchecked Sendable { + private let mask: NSEvent.EventTypeMask + private let handler: (NSEvent) -> NSEvent? + private var monitor: Any? + + init( + mask: NSEvent.EventTypeMask, + handler: @escaping (NSEvent) -> NSEvent? + ) { + self.mask = mask + self.handler = handler + } + + deinit { + stop() + } + + func start() { + guard monitor == nil else { + return + } + monitor = NSEvent.addLocalMonitorForEvents(matching: mask) { [weak self] event in + guard let self else { + return event + } + return handler(event) + } + } + + func stop() { + guard let monitor = monitor.take() else { + return + } + NSEvent.removeMonitor(monitor) + } + } + + private final class GlobalMonitorState: @unchecked Sendable { + private let mask: NSEvent.EventTypeMask + private let handler: (NSEvent) -> Void + private var monitor: Any? + + init( + mask: NSEvent.EventTypeMask, + handler: @escaping (NSEvent) -> Void + ) { + self.mask = mask + self.handler = handler + } + + deinit { + stop() + } + + func start() { + guard monitor == nil else { + return + } + monitor = NSEvent.addGlobalMonitorForEvents(matching: mask) { [weak self] event in + guard let self else { + return + } + handler(event) + } + } + + func stop() { + guard let monitor = monitor.take() else { + return + } + NSEvent.removeMonitor(monitor) + } + } + + private final class UniversalMonitorState: @unchecked Sendable { + private let mask: NSEvent.EventTypeMask + private let localHandler: (NSEvent) -> NSEvent? + private let globalHandler: (NSEvent) -> Void + private var monitors: (local: Any, global: Any)? + + init( + mask: NSEvent.EventTypeMask, + localHandler: @escaping (NSEvent) -> NSEvent?, + globalHandler: @escaping (NSEvent) -> Void + ) { + self.mask = mask + self.localHandler = localHandler + self.globalHandler = globalHandler + } + + init( + mask: NSEvent.EventTypeMask, + handler: @escaping (NSEvent) -> NSEvent? + ) { + self.mask = mask + self.localHandler = handler + self.globalHandler = { _ = handler($0) } + } + + deinit { + stop() + } + + func start() { + guard monitors == nil else { + return + } + + let local = NSEvent.addLocalMonitorForEvents(matching: mask) { [weak self] event in + guard let self else { + return event + } + return localHandler(event) + } + + guard let local else { + return + } + + let global = NSEvent.addGlobalMonitorForEvents(matching: mask) { [weak self] event in + guard let self else { + return + } + globalHandler(event) + } + + guard let global else { + NSEvent.removeMonitor(local) + return + } + + monitors = (local, global) + } + + func stop() { + guard let monitors = monitors.take() else { + return + } + NSEvent.removeMonitor(monitors.local) + NSEvent.removeMonitor(monitors.global) + } + } + + private enum State: @unchecked Sendable { + case local(LocalMonitorState) + case global(GlobalMonitorState) + case universal(UniversalMonitorState) + + var scope: Scope { + switch self { + case .local: .local + case .global: .global + case .universal: .universal + } + } + + func start() { + switch self { + case .local(let state): state.start() + case .global(let state): state.start() + case .universal(let state): state.start() + } + } + + func stop() { + switch self { + case .local(let state): state.stop() + case .global(let state): state.stop() + case .universal(let state): state.stop() + } + } + } + + /// Scopes where an event monitor can listen for events. + enum Scope { + case local + case global + case universal + } + + private let state: OSAllocatedUnfairLock + + /// The scope where the monitor listens for events. + var scope: Scope { + state.withLock { $0.scope } + } + + private init(state: State) { + self.state = OSAllocatedUnfairLock(initialState: state) + } + + private init( + mask: NSEvent.EventTypeMask, + scope: Scope, + passiveHandler: @escaping (NSEvent) -> Void + ) { + lazy var activeHandler: (NSEvent) -> NSEvent? = { event in + passiveHandler(event) + return event + } + switch scope { + case .local: + let baseState = LocalMonitorState(mask: mask, handler: activeHandler) + self.init(state: .local(baseState)) + case .global: + let baseState = GlobalMonitorState(mask: mask, handler: passiveHandler) + self.init(state: .global(baseState)) + case .universal: + let baseState = UniversalMonitorState(mask: mask, localHandler: activeHandler, globalHandler: passiveHandler) + self.init(state: .universal(baseState)) + } + } + + /// Installs the monitor and begins listening for events. + func start() { + state.withLock { $0.start() } + } + + /// Uninstalls the monitor and stops listening for events. + func stop() { + state.withLock { $0.stop() } + } +} + +extension EventMonitor { + static func local( + for mask: NSEvent.EventTypeMask, + handler: @escaping (NSEvent) -> NSEvent? + ) -> EventMonitor { + let state = LocalMonitorState(mask: mask, handler: handler) + return EventMonitor(state: .local(state)) + } + + static func global( + for mask: NSEvent.EventTypeMask, + handler: @escaping (NSEvent) -> Void + ) -> EventMonitor { + let state = GlobalMonitorState(mask: mask, handler: handler) + return EventMonitor(state: .global(state)) + } + + static func universal( + for mask: NSEvent.EventTypeMask, + localHandler: @escaping (NSEvent) -> NSEvent?, + globalHandler: @escaping (NSEvent) -> Void + ) -> EventMonitor { + let state = UniversalMonitorState( + mask: mask, + localHandler: localHandler, + globalHandler: globalHandler + ) + return EventMonitor(state: .universal(state)) + } + + static func universal( + for mask: NSEvent.EventTypeMask, + handler: @escaping (NSEvent) -> NSEvent? + ) -> EventMonitor { + let state = UniversalMonitorState(mask: mask, handler: handler) + return EventMonitor(state: .universal(state)) + } + + static func passive( + for mask: NSEvent.EventTypeMask, + scope: Scope, + handler: @escaping (NSEvent) -> Void + ) -> EventMonitor { + EventMonitor(mask: mask, scope: scope, passiveHandler: handler) + } +} + +extension EventMonitor { + @discardableResult + static func startLocal( + for mask: NSEvent.EventTypeMask, + handler: @escaping (NSEvent) -> NSEvent? + ) -> EventMonitor { + let monitor = local(for: mask, handler: handler) + monitor.start() + return monitor + } + + @discardableResult + static func startGlobal( + for mask: NSEvent.EventTypeMask, + handler: @escaping (NSEvent) -> Void + ) -> EventMonitor { + let monitor = global(for: mask, handler: handler) + monitor.start() + return monitor + } + + @discardableResult + static func startUniversal( + for mask: NSEvent.EventTypeMask, + localHandler: @escaping (NSEvent) -> NSEvent?, + globalHandler: @escaping (NSEvent) -> Void + ) -> EventMonitor { + let monitor = universal(for: mask, localHandler: localHandler, globalHandler: globalHandler) + monitor.start() + return monitor + } + + @discardableResult + static func startUniversal( + for mask: NSEvent.EventTypeMask, + handler: @escaping (NSEvent) -> NSEvent? + ) -> EventMonitor { + let monitor = universal(for: mask, handler: handler) + monitor.start() + return monitor + } + + @discardableResult + static func startPassive( + for mask: NSEvent.EventTypeMask, + scope: Scope, + handler: @escaping (NSEvent) -> Void + ) -> EventMonitor { + let monitor = passive(for: mask, scope: scope, handler: handler) + monitor.start() + return monitor + } +} + +extension EventMonitor { + /// A publisher that emits events received within a defined scope. + struct EventPublisher: Publisher { + typealias Output = NSEvent + typealias Failure = Never + + /// The event type mask that determines the events the publisher receives. + let mask: NSEvent.EventTypeMask + + /// The scope where the publisher receives events. + let scope: EventMonitor.Scope + + func receive(subscriber: S) where S.Input == Output, S.Failure == Failure { + let subscription = EventSubscription(mask: mask, scope: scope, subscriber: subscriber) + subscriber.receive(subscription: subscription) + } + } + + /// Returns a publisher that emits events received within a defined scope. + /// + /// - Parameters: + /// - events: A mask that determines the events the publisher receives. + /// - scope: A scope that determines where the publisher receives events. + static func publish(events: NSEvent.EventTypeMask, scope: Scope) -> EventPublisher { + EventPublisher(mask: events, scope: scope) + } +} + +extension EventMonitor.EventPublisher { + private final class EventSubscription: Subscription where S.Input == Output, S.Failure == Failure { + private final class SubscriberBox { + private let subscriber: S + + init(subscriber: S) { + self.subscriber = subscriber + } + + @discardableResult + func receive(_ event: NSEvent) -> Subscribers.Demand { + subscriber.receive(event) + } + } + + private var box: SubscriberBox? + private let monitor: EventMonitor + + init(mask: NSEvent.EventTypeMask, scope: EventMonitor.Scope, subscriber: S) { + self.box = SubscriberBox(subscriber: subscriber) + self.monitor = .startPassive(for: mask, scope: scope) { [weak box] event in + box?.receive(event) + } + } + + func request(_ demand: Subscribers.Demand) { } + + func cancel() { + box = nil + monitor.stop() + } + } +} diff --git a/Ice/Events/EventMonitors/GlobalEventMonitor.swift b/Ice/Events/EventMonitors/GlobalEventMonitor.swift deleted file mode 100644 index 82cfb50d2..000000000 --- a/Ice/Events/EventMonitors/GlobalEventMonitor.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// GlobalEventMonitor.swift -// Ice -// - -import Cocoa -import Combine - -/// A type that monitors for events outside the scope of the current process. -final class GlobalEventMonitor { - private let mask: NSEvent.EventTypeMask - private let handler: (NSEvent) -> Void - private var monitor: Any? - - /// Creates an event monitor with the given event type mask and handler. - /// - /// - Parameters: - /// - mask: An event type mask specifying which events to monitor. - /// - handler: A handler to execute when the event monitor receives - /// an event corresponding to the event types in `mask`. - init(mask: NSEvent.EventTypeMask, handler: @escaping (_ event: NSEvent) -> Void) { - self.mask = mask - self.handler = handler - } - - deinit { - stop() - } - - /// Starts monitoring for events. - func start() { - guard monitor == nil else { - return - } - monitor = NSEvent.addGlobalMonitorForEvents( - matching: mask, - handler: handler - ) - } - - /// Stops monitoring for events. - func stop() { - guard let monitor else { - return - } - NSEvent.removeMonitor(monitor) - self.monitor = nil - } -} - -extension GlobalEventMonitor { - /// A publisher that emits global events for an event type mask. - struct GlobalEventPublisher: Publisher { - typealias Output = NSEvent - typealias Failure = Never - - let mask: NSEvent.EventTypeMask - - func receive>(subscriber: S) { - let subscription = GlobalEventSubscription(mask: mask, subscriber: subscriber) - subscriber.receive(subscription: subscription) - } - } - - /// Returns a publisher that emits global events for the given event type mask. - /// - /// - Parameter mask: An event type mask specifying which events to publish. - static func publisher(for mask: NSEvent.EventTypeMask) -> GlobalEventPublisher { - GlobalEventPublisher(mask: mask) - } -} - -extension GlobalEventMonitor.GlobalEventPublisher { - private final class GlobalEventSubscription>: Subscription { - let mask: NSEvent.EventTypeMask - private var subscriber: S? - - private lazy var monitor = GlobalEventMonitor(mask: mask) { [weak self] event in - _ = self?.subscriber?.receive(event) - } - - init(mask: NSEvent.EventTypeMask, subscriber: S) { - self.mask = mask - self.subscriber = subscriber - self.monitor.start() - } - - func request(_ demand: Subscribers.Demand) { } - - func cancel() { - monitor.stop() - subscriber = nil - } - } -} diff --git a/Ice/Events/EventMonitors/LocalEventMonitor.swift b/Ice/Events/EventMonitors/LocalEventMonitor.swift deleted file mode 100644 index bbbc46ab8..000000000 --- a/Ice/Events/EventMonitors/LocalEventMonitor.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// LocalEventMonitor.swift -// Ice -// - -import Cocoa -import Combine - -/// A type that monitors for events within the scope of the current process. -final class LocalEventMonitor { - private let mask: NSEvent.EventTypeMask - private let handler: (NSEvent) -> NSEvent? - private var monitor: Any? - - /// Creates an event monitor with the given event type mask and handler. - /// - /// - Parameters: - /// - mask: An event type mask specifying which events to monitor. - /// - handler: A handler to execute when the event monitor receives - /// an event corresponding to the event types in `mask`. - init(mask: NSEvent.EventTypeMask, handler: @escaping (_ event: NSEvent) -> NSEvent?) { - self.mask = mask - self.handler = handler - } - - deinit { - stop() - } - - /// Starts monitoring for events. - func start() { - guard monitor == nil else { - return - } - monitor = NSEvent.addLocalMonitorForEvents( - matching: mask, - handler: handler - ) - } - - /// Stops monitoring for events. - func stop() { - guard let monitor else { - return - } - NSEvent.removeMonitor(monitor) - self.monitor = nil - } -} - -extension LocalEventMonitor { - /// A publisher that emits local events for an event type mask. - struct LocalEventPublisher: Publisher { - typealias Output = NSEvent - typealias Failure = Never - - let mask: NSEvent.EventTypeMask - - func receive>(subscriber: S) { - let subscription = LocalEventSubscription(mask: mask, subscriber: subscriber) - subscriber.receive(subscription: subscription) - } - } - - /// Returns a publisher that emits local events for the given event type mask. - /// - /// - Parameter mask: An event type mask specifying which events to publish. - static func publisher(for mask: NSEvent.EventTypeMask) -> LocalEventPublisher { - LocalEventPublisher(mask: mask) - } -} - -extension LocalEventMonitor.LocalEventPublisher { - private final class LocalEventSubscription>: Subscription { - let mask: NSEvent.EventTypeMask - private var subscriber: S? - - private lazy var monitor = LocalEventMonitor(mask: mask) { [weak self] event in - _ = self?.subscriber?.receive(event) - return event - } - - init(mask: NSEvent.EventTypeMask, subscriber: S) { - self.mask = mask - self.subscriber = subscriber - self.monitor.start() - } - - func request(_ demand: Subscribers.Demand) { } - - func cancel() { - monitor.stop() - subscriber = nil - } - } -} diff --git a/Ice/Events/EventMonitors/UniversalEventMonitor.swift b/Ice/Events/EventMonitors/UniversalEventMonitor.swift deleted file mode 100644 index c84bbe8d8..000000000 --- a/Ice/Events/EventMonitors/UniversalEventMonitor.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// UniversalEventMonitor.swift -// Ice -// - -import Cocoa -import Combine - -/// A type that monitors for local and global events. -final class UniversalEventMonitor { - private let local: LocalEventMonitor - private let global: GlobalEventMonitor - - /// Creates an event monitor with the given event type mask and handler. - /// - /// - Parameters: - /// - mask: An event type mask specifying which events to monitor. - /// - handler: A handler to execute when the event monitor receives - /// an event corresponding to the event types in `mask`. - init(mask: NSEvent.EventTypeMask, handler: @escaping (_ event: NSEvent) -> NSEvent?) { - self.local = LocalEventMonitor(mask: mask, handler: handler) - self.global = GlobalEventMonitor(mask: mask, handler: { _ = handler($0) }) - } - - deinit { - stop() - } - - /// Starts monitoring for events. - func start() { - local.start() - global.start() - } - - /// Stops monitoring for events. - func stop() { - local.stop() - global.stop() - } -} - -extension UniversalEventMonitor { - /// A publisher that emits local and global events for an event type mask. - struct UniversalEventPublisher: Publisher { - typealias Output = NSEvent - typealias Failure = Never - - let mask: NSEvent.EventTypeMask - - func receive>(subscriber: S) { - let subscription = UniversalEventSubscription(mask: mask, subscriber: subscriber) - subscriber.receive(subscription: subscription) - } - } - - /// Returns a publisher that emits local and global events for the given - /// event type mask. - /// - /// - Parameter mask: An event type mask specifying which events to publish. - static func publisher(for mask: NSEvent.EventTypeMask) -> UniversalEventPublisher { - UniversalEventPublisher(mask: mask) - } -} - -extension UniversalEventMonitor.UniversalEventPublisher { - private final class UniversalEventSubscription>: Subscription { - let mask: NSEvent.EventTypeMask - private var subscriber: S? - - private lazy var monitor = UniversalEventMonitor(mask: mask) { [weak self] event in - _ = self?.subscriber?.receive(event) - return event - } - - init(mask: NSEvent.EventTypeMask, subscriber: S) { - self.mask = mask - self.subscriber = subscriber - self.monitor.start() - } - - func request(_ demand: Subscribers.Demand) { } - - func cancel() { - monitor.stop() - subscriber = nil - } - } -} diff --git a/Ice/Events/EventTap.swift b/Ice/Events/EventTap.swift index 975ffc0da..2f76f25af 100644 --- a/Ice/Events/EventTap.swift +++ b/Ice/Events/EventTap.swift @@ -8,7 +8,6 @@ import OSLog /// A type that receives system events from various locations within the /// event stream. -@MainActor final class EventTap { /// Constants that specify the possible tapping locations for events. enum Location { @@ -37,65 +36,45 @@ final class EventTap { } } - /// A proxy for an event tap. - /// - /// Event tap proxies are passed to an event tap's callback, and can be - /// used to post additional events to the tap before the callback returns - /// or to disable the tap from within the callback. - @MainActor - struct Proxy { - private let tap: EventTap - private let pointer: CGEventTapProxy - - /// The label associated with the event tap. - var label: String { - tap.label - } - - /// A Boolean value that indicates whether the event tap is enabled. - var isEnabled: Bool { - tap.isEnabled - } - - fileprivate init(tap: EventTap, pointer: CGEventTapProxy) { - self.tap = tap - self.pointer = pointer - } - - /// Posts an event into the event stream from the location of this tap. - func postEvent(_ event: CGEvent) { - event.tapPostEvent(pointer) - } - - /// Enables the event tap. - func enable() { - tap.enable() - } - - /// Enables the event tap with the given timeout. - func enable(timeout: Duration, onTimeout: @escaping () -> Void) { - tap.enable(timeout: timeout, onTimeout: onTimeout) - } - - /// Disables the event tap. - func disable() { - tap.disable() + private static let logger = Logger(category: "EventTap") + private static let concurrentQueue = DispatchQueue( + label: "EventTap.concurrentQueue", + qos: .userInteractive, + attributes: .concurrent + ) + + private static let eventTapCallBack: CGEventTapCallBack = { _, type, event, refcon in + concurrentQueue.sync { + guard let refcon else { + return Unmanaged.passUnretained(event) + } + let tap: EventTap = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + return tap.callbackQueue.sync { + if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { + tap.enable() + return nil + } + guard tap.isEnabled else { + return Unmanaged.passUnretained(event) + } + return tap.callback(tap, event).map { eventFromCallback in + Unmanaged.passUnretained(eventFromCallback) + } + } } } - /// Shared logger for event taps. - private static let logger = Logger(category: "EventTap") - - private let runLoop = CFRunLoopGetCurrent() - private let mode: CFRunLoopMode = .commonModes - private nonisolated let callback: (EventTap, CGEventTapProxy, CGEventType, CGEvent) -> Unmanaged? - private var machPort: CFMachPort? + private var runLoop: CFRunLoop? private var source: CFRunLoopSource? + private let callback: (EventTap, CGEvent) -> CGEvent? /// The label associated with the event tap. let label: String + /// The queue that performs the tap's callback. + var callbackQueue: DispatchQueue + /// A Boolean value that indicates whether the event tap is enabled. var isEnabled: Bool { guard let machPort else { @@ -104,159 +83,149 @@ final class EventTap { return CGEvent.tapIsEnabled(tap: machPort) } - /// Creates a new event tap. + /// Creates a new event tap for the given event types. /// /// - Parameters: /// - label: The label associated with the tap. - /// - kind: The kind of tap to create. - /// - location: The location to listen for events. + /// - options: A constant that specifies whether the tap is an active + /// filter or a passive listener. + /// - location: The location of the tap. /// - placement: The placement of the tap relative to other active taps. - /// - types: The event types to listen for. - /// - callback: A callback function to perform when the tap receives events. + /// - types: The set of event types observed by the tap. + /// - callbackQueue: A dispatch queue that performs the tap's callback. + /// - callback: A callback function to perform when events are received. init( label: String = #function, options: CGEventTapOptions, location: Location, - place: CGEventTapPlacement, - types: [CGEventType], - callback: @MainActor @escaping (_ proxy: Proxy, _ type: CGEventType, _ event: CGEvent) -> CGEvent? + placement: CGEventTapPlacement, + types: Set, + callbackQueue: DispatchQueue? = nil, + callback: @escaping (_ tap: EventTap, _ event: CGEvent) -> CGEvent? ) { self.label = label - self.callback = { @MainActor tap, pointer, type, event in - callback(Proxy(tap: tap, pointer: pointer), type, event).map(Unmanaged.passUnretained) - } - guard let machPort = Self.createTapMachPort( - location: location, - place: place, - options: options, - eventsOfInterest: types.reduce(into: 0) { $0 |= 1 << $1.rawValue }, - callback: handleEvent, - userInfo: Unmanaged.passUnretained(self).toOpaque() - ) else { - EventTap.logger.error("Error creating mach port for event tap \"\(self.label, privacy: .public)\"") - return - } - guard let source = CFMachPortCreateRunLoopSource(nil, machPort, 0) else { - EventTap.logger.error("Error creating run loop source for event tap \"\(self.label, privacy: .public)\"") + self.callback = callback + self.callbackQueue = callbackQueue ?? DispatchQueue(label: label) + + guard + let machPort = createMachPort( + location: location, + placement: placement, + options: options, + types: types + ), + let runLoop = CFRunLoopGetCurrent(), + let source = CFMachPortCreateRunLoopSource(nil, machPort, 0) + else { + EventTap.logger.error(#"Error creating event tap "\#(label, privacy: .public)""#) return } + + CFRunLoopAddSource(runLoop, source, .commonModes) + self.machPort = machPort + self.runLoop = runLoop self.source = source } - deinit { - guard let machPort else { - return - } - CFRunLoopRemoveSource(runLoop, source, mode) - CGEvent.tapEnable(tap: machPort, enable: false) - CFMachPortInvalidate(machPort) + /// Creates a new event tap for a single event type. + /// + /// - Parameters: + /// - label: The label associated with the tap. + /// - options: A constant that specifies whether the tap is an active + /// filter or a passive listener. + /// - location: The location of the tap. + /// - placement: The placement of the tap relative to other active taps. + /// - types: The event type observed by the tap. + /// - callbackQueue: A dispatch queue that performs the tap's callback. + /// - callback: A callback function to perform when events are received. + convenience init( + label: String = #function, + options: CGEventTapOptions, + location: Location, + placement: CGEventTapPlacement, + type: CGEventType, + callbackQueue: DispatchQueue? = nil, + callback: @escaping (_ tap: EventTap, _ event: CGEvent) -> CGEvent? + ) { + self.init( + label: label, + options: options, + location: location, + placement: placement, + types: [type], + callbackQueue: callbackQueue, + callback: callback + ) } - fileprivate nonisolated static func performCallback( - for eventTap: EventTap, - proxy: CGEventTapProxy, - type: CGEventType, - event: CGEvent - ) -> Unmanaged? { - let callback = eventTap.callback - return callback(eventTap, proxy, type, event) + deinit { + if let runLoop, let source { + CFRunLoopRemoveSource(runLoop, source, .commonModes) + } + if let machPort { + CGEvent.tapEnable(tap: machPort, enable: false) + CFMachPortInvalidate(machPort) + } } - private static func createTapMachPort( + private func createMachPort( location: Location, - place: CGEventTapPlacement, + placement: CGEventTapPlacement, options: CGEventTapOptions, - eventsOfInterest: CGEventMask, - callback: CGEventTapCallBack, - userInfo: UnsafeMutableRawPointer? + types: Set ) -> CFMachPort? { - if case .pid(let pid) = location { - return CGEvent.tapCreateForPid( - pid: pid, - place: place, - options: options, - eventsOfInterest: eventsOfInterest, - callback: callback, - userInfo: userInfo - ) + func createEventMask() -> CGEventMask { + types.reduce(0) { $0 | (1 << $1.rawValue) } } - let tap: CGEventTapLocation? = switch location { - case .hidEventTap: .cghidEventTap - case .sessionEventTap: .cgSessionEventTap - case .annotatedSessionEventTap: .cgAnnotatedSessionEventTap - case .pid: nil + func createUserInfo() -> UnsafeMutableRawPointer { + Unmanaged.passUnretained(self).toOpaque() } - guard let tap else { - return nil + func createMachPortForLocation(_ location: CGEventTapLocation) -> CFMachPort? { + CGEvent.tapCreate( + tap: location, + place: placement, + options: options, + eventsOfInterest: createEventMask(), + callback: EventTap.eventTapCallBack, + userInfo: createUserInfo() + ) } - return CGEvent.tapCreate( - tap: tap, - place: place, - options: options, - eventsOfInterest: eventsOfInterest, - callback: callback, - userInfo: userInfo - ) - } - - private func withUnwrappedComponents(body: @MainActor (CFRunLoop, CFRunLoopSource, CFMachPort) -> Void) { - guard let runLoop else { - EventTap.logger.error("Missing run loop for event tap \"\(self.label, privacy: .public)\"") - return - } - guard let source else { - EventTap.logger.error("Missing run loop source for event tap \"\(self.label, privacy: .public)\"") - return + func createMachPortForPid(_ pid: pid_t) -> CFMachPort? { + CGEvent.tapCreateForPid( + pid: pid, + place: placement, + options: options, + eventsOfInterest: createEventMask(), + callback: EventTap.eventTapCallBack, + userInfo: createUserInfo() + ) } - guard let machPort else { - EventTap.logger.error("Missing mach port for event tap \"\(self.label, privacy: .public)\"") - return + + switch location { + case .hidEventTap: + return createMachPortForLocation(.cghidEventTap) + case .sessionEventTap: + return createMachPortForLocation(.cgSessionEventTap) + case .annotatedSessionEventTap: + return createMachPortForLocation(.cgAnnotatedSessionEventTap) + case .pid(let pid): + return createMachPortForPid(pid) } - body(runLoop, source, machPort) } /// Enables the event tap. func enable() { - withUnwrappedComponents { runLoop, source, machPort in - CFRunLoopAddSource(runLoop, source, mode) - CGEvent.tapEnable(tap: machPort, enable: true) - } - } - - /// Enables the event tap with the given timeout. - func enable(timeout: Duration, onTimeout: @escaping () -> Void) { - enable() - Task { [weak self] in - try await Task.sleep(for: timeout) - if self?.isEnabled == true { - onTimeout() - } - } + guard let machPort else { return } + CGEvent.tapEnable(tap: machPort, enable: true) } /// Disables the event tap. func disable() { - withUnwrappedComponents { runLoop, source, machPort in - CFRunLoopRemoveSource(runLoop, source, mode) - CGEvent.tapEnable(tap: machPort, enable: false) - } - } -} - -// MARK: - Handle Event -private func handleEvent( - proxy: CGEventTapProxy, - type: CGEventType, - event: CGEvent, - refcon: UnsafeMutableRawPointer? -) -> Unmanaged? { - guard let refcon else { - return Unmanaged.passRetained(event) + guard let machPort else { return } + CGEvent.tapEnable(tap: machPort, enable: false) } - let eventTap = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - return EventTap.performCallback(for: eventTap, proxy: proxy, type: type, event: event) } diff --git a/Ice/Events/EventMonitors/RunLoopLocalEventMonitor.swift b/Ice/Events/RunLoopLocalEventMonitor.swift similarity index 100% rename from Ice/Events/EventMonitors/RunLoopLocalEventMonitor.swift rename to Ice/Events/RunLoopLocalEventMonitor.swift diff --git a/Ice/Main/AppDelegate.swift b/Ice/Main/AppDelegate.swift index 1067a210a..b21aa5a3a 100644 --- a/Ice/Main/AppDelegate.swift +++ b/Ice/Main/AppDelegate.swift @@ -11,8 +11,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// The shared app state. let appState = AppState() - /// Logger for the delegate. - private let logger = Logger(category: "AppDelegate") + /// Logger for the app delegate. + let logger = Logger(category: "AppDelegate") // MARK: NSApplicationDelegate Methods @@ -21,18 +21,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSSplitViewItem.swizzle() MigrationManager(appState: appState).migrateAll() Bridging.setConnectionProperty(true, forKey: "SetsCursorInBackground") + NSColorPanel.shared.animationBehavior = .none + NSColorPanel.shared.hidesOnDeactivate = false + NSColorPanel.shared.styleMask.insert(.nonactivatingPanel) } func applicationDidFinishLaunching(_ notification: Notification) { - // Hide the main menu to make more space in the menu bar. - if let mainMenu = NSApp.mainMenu { - for item in mainMenu.items { - item.isHidden = true - } + // Hide the main menu's items to make more room in the menu bar. + for item in NSApp.mainMenu?.items ?? [] { + item.isHidden = true } #if DEBUG - // Stop here if running as a preview. + // Don't perform setup if running as a preview. if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { return } @@ -42,13 +43,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // or prompt to grant permissions. switch appState.permissions.permissionsState { case .hasAll: - appState.permissions.logger.info("Passed all permissions checks") + appState.permissions.logger.debug("Passed all permissions checks") appState.performSetup(hasPermissions: true) case .hasRequired: - appState.permissions.logger.info("Passed required permissions checks") + appState.permissions.logger.debug("Passed required permissions checks") appState.performSetup(hasPermissions: true) case .missing: - appState.permissions.logger.info("Failed required permissions checks") + appState.permissions.logger.debug("Failed required permissions checks") appState.performSetup(hasPermissions: false) } } @@ -65,7 +66,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { sender.activationPolicy() != .accessory, appState.navigationState.isAppFrontmost { - logger.debug("All windows closed - deactivating") + logger.debug("All windows closed - deactivating with accessory activation policy") appState.deactivate(withPolicy: .accessory) } return false @@ -79,7 +80,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Opens the settings window and activates the app. @objc func openSettingsWindow() { - // Small delay makes this more reliable. + // Delay makes this more reliable for some reason. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [appState] in appState.activate(withPolicy: .regular) appState.openWindow(.settings) diff --git a/Ice/Main/AppState.swift b/Ice/Main/AppState.swift index 02ed9ac5a..b9ea57e49 100644 --- a/Ice/Main/AppState.swift +++ b/Ice/Main/AppState.swift @@ -10,8 +10,8 @@ import SwiftUI /// The model for app-wide state. @MainActor final class AppState: ObservableObject { - /// A Boolean value that indicates whether the active space is fullscreen. - @Published private(set) var isActiveSpaceFullscreen = Bridging.isActiveSpaceFullscreen() + /// Information for the active space. + @Published private(set) var activeSpace = SpaceInfo.activeSpace() /// A Boolean value that indicates whether the user is dragging a menu bar item. @Published private(set) var isDraggingMenuBarItem = false @@ -101,30 +101,31 @@ final class AppState: ObservableObject { private func configureCancellables() { var c = Set() - Publishers.Merge3( - NSWorkspace.shared.notificationCenter - .publisher(for: NSWorkspace.activeSpaceDidChangeNotification) - .replace(with: ()), - // Frontmost application change can indicate a space change from one display to - // another, which gets ignored by NSWorkspace.activeSpaceDidChangeNotification. - NSWorkspace.shared - .publisher(for: \.frontmostApplication) - .replace(with: ()), - // Clicking into a fullscreen space from another space is also ignored. - UniversalEventMonitor - .publisher(for: .leftMouseDown) - .delay(for: 0.1, scheduler: DispatchQueue.main) - .replace(with: ()) - ) - .receive(on: DispatchQueue.main) - .replace { - Bridging.isActiveSpaceFullscreen() - } - .removeDuplicates() - .sink { [weak self] isFullscreen in - self?.isActiveSpaceFullscreen = isFullscreen - } - .store(in: &c) + // Listen for changes to the active space. We need handle some special + // cases that NSWorkspace.shared.notificationCenter seems to miss. + // + // Special cases: + // + // * Changes to the frontmost application -- may indicate that a space + // on another display was made active. + // * Left mouse down -- user may have clicked into a fullscreen space. + // To account for variations in system timing, we publish a value + // immediately upon receipt of the event, then publish another value + // after a delay. + NSWorkspace.shared.notificationCenter + .publisher(for: NSWorkspace.activeSpaceDidChangeNotification) + .discardMerge(NSWorkspace.shared.publisher(for: \.frontmostApplication)) + .discardMerge(EventMonitor.publish(events: .leftMouseDown, scope: .universal).flatMap { _ in + let initial = Just(()) + let delayed = initial.delay(for: 0.1, scheduler: DispatchQueue.main) + return Publishers.Merge(initial, delayed) + }) + .replace { Bridging.getActiveSpaceID() } + .removeDuplicates() + .sink { [weak self] spaceID in + self?.activeSpace = SpaceInfo(spaceID: spaceID) + } + .store(in: &c) NSWorkspace.shared.publisher(for: \.frontmostApplication) .receive(on: DispatchQueue.main) @@ -136,9 +137,9 @@ final class AppState: ObservableObject { .store(in: &c) publisherForWindow(.settings) - .flatMap { $0.publisher } // Short circuit if nil. - .flatMap { $0.publisher(for: \.isVisible) } + .publisher(for: \.isVisible) .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) + .replaceNil(with: false) .removeDuplicates() .sink { [weak self] isPresented in self?.navigationState.isSettingsPresented = isPresented @@ -236,39 +237,28 @@ final class AppState: ObservableObject { } /// Activates the app and sets its activation policy. - func activate(withPolicy policy: NSApplication.ActivationPolicy) { - - // What follows is NOT at all straightforward, but it seems to - // make app activation (mostly) reliable after changes made in - // macOS 14. - - let current = NSRunningApplication.current - let workspace = NSWorkspace.shared + func activate(withPolicy policy: NSApplication.ActivationPolicy? = nil) { + if let policy { + NSApp.setActivationPolicy(policy) + } - NSApp.setActivationPolicy(policy) - NSApp.yieldActivation(to: current) + // NSApplication.activate(ignoringOtherApps:) is deprecated, with + // no suitable alternative for explicit activation, so we're using + // NSRunningApplication for now. - guard var frontmost = workspace.frontmostApplication else { - current.activate() + guard let frontmost = NSWorkspace.shared.frontmostApplication else { + NSRunningApplication.current.activate() return } - - if - current.isActive, - let next = workspace.menuBarOwningApplication, - !next.isActive - { - next.activate(from: frontmost) - frontmost = next - } - - current.activate(from: frontmost) + NSRunningApplication.current.activate(from: frontmost) } /// Deactivates the app and sets its activation policy. - func deactivate(withPolicy policy: NSApplication.ActivationPolicy) { + func deactivate(withPolicy policy: NSApplication.ActivationPolicy? = nil) { + if let policy { + NSApp.setActivationPolicy(policy) + } NSApp.deactivate() - NSApp.setActivationPolicy(policy) } } diff --git a/Ice/Updates/UpdatesManager.swift b/Ice/Main/Updates.swift similarity index 99% rename from Ice/Updates/UpdatesManager.swift rename to Ice/Main/Updates.swift index a743f8b57..30e4a8dd8 100644 --- a/Ice/Updates/UpdatesManager.swift +++ b/Ice/Main/Updates.swift @@ -1,5 +1,5 @@ // -// UpdatesManager.swift +// Updates.swift // Ice // diff --git a/Ice/MenuBar/Appearance/Configurations/MenuBarAppearanceConfigurationV1.swift b/Ice/MenuBar/Appearance/Configurations/MenuBarAppearanceConfigurationV1.swift index 1d0fa049d..674f0d178 100644 --- a/Ice/MenuBar/Appearance/Configurations/MenuBarAppearanceConfigurationV1.swift +++ b/Ice/MenuBar/Appearance/Configurations/MenuBarAppearanceConfigurationV1.swift @@ -18,11 +18,11 @@ struct MenuBarAppearanceConfigurationV1: Hashable { var splitShapeInfo: MenuBarSplitShapeInfo var tintKind: MenuBarTintKind var tintColor: CGColor - var tintGradient: CustomGradient + var tintGradient: IceGradient var hasRoundedShape: Bool { switch shapeKind { - case .none: false + case .noShape: false case .full: fullShapeInfo.hasRoundedShape case .split: splitShapeInfo.hasRoundedShape } @@ -49,13 +49,13 @@ struct MenuBarAppearanceConfigurationV1: Hashable { } if let borderColorData = Defaults.data(forKey: .menuBarBorderColor) { - configuration.borderColor = try decoder.decode(CodableColor.self, from: borderColorData).cgColor + configuration.borderColor = try decoder.decode(IceColor.self, from: borderColorData).cgColor } if let tintColorData = Defaults.data(forKey: .menuBarTintColor) { - configuration.tintColor = try decoder.decode(CodableColor.self, from: tintColorData).cgColor + configuration.tintColor = try decoder.decode(IceColor.self, from: tintColorData).cgColor } if let tintGradientData = Defaults.data(forKey: .menuBarTintGradient) { - configuration.tintGradient = try decoder.decode(CustomGradient.self, from: tintGradientData) + configuration.tintGradient = try decoder.decode(IceGradient.self, from: tintGradientData) } if let shapeKindData = Defaults.data(forKey: .menuBarShapeKind) { configuration.shapeKind = try decoder.decode(MenuBarShapeKind.self, from: shapeKindData) @@ -101,10 +101,10 @@ extension MenuBarAppearanceConfigurationV1 { isInset: true, borderColor: .black, borderWidth: 1, - shapeKind: .none, + shapeKind: .noShape, fullShapeInfo: .default, splitShapeInfo: .default, - tintKind: .none, + tintKind: .noTint, tintColor: .black, tintGradient: .defaultMenuBarTint ) @@ -132,14 +132,14 @@ extension MenuBarAppearanceConfigurationV1: Codable { hasShadow: container.decodeIfPresent(Bool.self, forKey: .hasShadow) ?? Self.defaultConfiguration.hasShadow, hasBorder: container.decodeIfPresent(Bool.self, forKey: .hasBorder) ?? Self.defaultConfiguration.hasBorder, isInset: container.decodeIfPresent(Bool.self, forKey: .isInset) ?? Self.defaultConfiguration.isInset, - borderColor: container.decodeIfPresent(CodableColor.self, forKey: .borderColor)?.cgColor ?? Self.defaultConfiguration.borderColor, + borderColor: container.decodeIfPresent(IceColor.self, forKey: .borderColor)?.cgColor ?? Self.defaultConfiguration.borderColor, borderWidth: container.decodeIfPresent(Double.self, forKey: .borderWidth) ?? Self.defaultConfiguration.borderWidth, shapeKind: container.decodeIfPresent(MenuBarShapeKind.self, forKey: .shapeKind) ?? Self.defaultConfiguration.shapeKind, fullShapeInfo: container.decodeIfPresent(MenuBarFullShapeInfo.self, forKey: .fullShapeInfo) ?? Self.defaultConfiguration.fullShapeInfo, splitShapeInfo: container.decodeIfPresent(MenuBarSplitShapeInfo.self, forKey: .splitShapeInfo) ?? Self.defaultConfiguration.splitShapeInfo, tintKind: container.decodeIfPresent(MenuBarTintKind.self, forKey: .tintKind) ?? Self.defaultConfiguration.tintKind, - tintColor: container.decodeIfPresent(CodableColor.self, forKey: .tintColor)?.cgColor ?? Self.defaultConfiguration.tintColor, - tintGradient: container.decodeIfPresent(CustomGradient.self, forKey: .tintGradient) ?? Self.defaultConfiguration.tintGradient + tintColor: container.decodeIfPresent(IceColor.self, forKey: .tintColor)?.cgColor ?? Self.defaultConfiguration.tintColor, + tintGradient: container.decodeIfPresent(IceGradient.self, forKey: .tintGradient) ?? Self.defaultConfiguration.tintGradient ) } @@ -148,13 +148,13 @@ extension MenuBarAppearanceConfigurationV1: Codable { try container.encode(hasShadow, forKey: .hasShadow) try container.encode(hasBorder, forKey: .hasBorder) try container.encode(isInset, forKey: .isInset) - try container.encode(CodableColor(cgColor: borderColor), forKey: .borderColor) + try container.encode(IceColor(cgColor: borderColor), forKey: .borderColor) try container.encode(borderWidth, forKey: .borderWidth) try container.encode(shapeKind, forKey: .shapeKind) try container.encode(fullShapeInfo, forKey: .fullShapeInfo) try container.encode(splitShapeInfo, forKey: .splitShapeInfo) try container.encode(tintKind, forKey: .tintKind) - try container.encode(CodableColor(cgColor: tintColor), forKey: .tintColor) + try container.encode(IceColor(cgColor: tintColor), forKey: .tintColor) try container.encode(tintGradient, forKey: .tintGradient) } } diff --git a/Ice/MenuBar/Appearance/Configurations/MenuBarAppearanceConfigurationV2.swift b/Ice/MenuBar/Appearance/Configurations/MenuBarAppearanceConfigurationV2.swift index 9daaeeac6..b78f18cf1 100644 --- a/Ice/MenuBar/Appearance/Configurations/MenuBarAppearanceConfigurationV2.swift +++ b/Ice/MenuBar/Appearance/Configurations/MenuBarAppearanceConfigurationV2.swift @@ -3,8 +3,7 @@ // Ice // -import CoreGraphics -import Foundation +import SwiftUI struct MenuBarAppearanceConfigurationV2: Hashable { var lightModeConfiguration: MenuBarAppearancePartialConfiguration @@ -18,7 +17,7 @@ struct MenuBarAppearanceConfigurationV2: Hashable { var hasRoundedShape: Bool { switch shapeKind { - case .none: false + case .noShape: false case .full: fullShapeInfo.hasRoundedShape case .split: splitShapeInfo.hasRoundedShape } @@ -42,7 +41,7 @@ extension MenuBarAppearanceConfigurationV2 { lightModeConfiguration: .defaultConfiguration, darkModeConfiguration: .defaultConfiguration, staticConfiguration: .defaultConfiguration, - shapeKind: .none, + shapeKind: .noShape, fullShapeInfo: .default, splitShapeInfo: .default, isInset: true, @@ -98,7 +97,7 @@ struct MenuBarAppearancePartialConfiguration: Hashable { var borderWidth: Double var tintKind: MenuBarTintKind var tintColor: CGColor - var tintGradient: CustomGradient + var tintGradient: IceGradient } // MARK: Default Partial Configuration @@ -108,7 +107,7 @@ extension MenuBarAppearancePartialConfiguration { hasBorder: false, borderColor: .black, borderWidth: 1, - tintKind: .none, + tintKind: .noTint, tintColor: .black, tintGradient: .defaultMenuBarTint ) @@ -134,11 +133,11 @@ extension MenuBarAppearancePartialConfiguration: Codable { try self.init( hasShadow: container.decodeIfPresent(Bool.self, forKey: .hasShadow) ?? Self.defaultConfiguration.hasShadow, hasBorder: container.decodeIfPresent(Bool.self, forKey: .hasBorder) ?? Self.defaultConfiguration.hasBorder, - borderColor: container.decodeIfPresent(CodableColor.self, forKey: .borderColor)?.cgColor ?? Self.defaultConfiguration.borderColor, + borderColor: container.decodeIfPresent(IceColor.self, forKey: .borderColor)?.cgColor ?? Self.defaultConfiguration.borderColor, borderWidth: container.decodeIfPresent(Double.self, forKey: .borderWidth) ?? Self.defaultConfiguration.borderWidth, tintKind: container.decodeIfPresent(MenuBarTintKind.self, forKey: .tintKind) ?? Self.defaultConfiguration.tintKind, - tintColor: container.decodeIfPresent(CodableColor.self, forKey: .tintColor)?.cgColor ?? Self.defaultConfiguration.tintColor, - tintGradient: container.decodeIfPresent(CustomGradient.self, forKey: .tintGradient) ?? Self.defaultConfiguration.tintGradient + tintColor: container.decodeIfPresent(IceColor.self, forKey: .tintColor)?.cgColor ?? Self.defaultConfiguration.tintColor, + tintGradient: container.decodeIfPresent(IceGradient.self, forKey: .tintGradient) ?? Self.defaultConfiguration.tintGradient ) } @@ -146,10 +145,10 @@ extension MenuBarAppearancePartialConfiguration: Codable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(hasShadow, forKey: .hasShadow) try container.encode(hasBorder, forKey: .hasBorder) - try container.encode(CodableColor(cgColor: borderColor), forKey: .borderColor) + try container.encode(IceColor(cgColor: borderColor), forKey: .borderColor) try container.encode(borderWidth, forKey: .borderWidth) try container.encode(tintKind, forKey: .tintKind) - try container.encode(CodableColor(cgColor: tintColor), forKey: .tintColor) + try container.encode(IceColor(cgColor: tintColor), forKey: .tintColor) try container.encode(tintGradient, forKey: .tintGradient) } } diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift index 3eaa7299a..3a1b8e90e 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift @@ -8,7 +8,7 @@ import SwiftUI struct MenuBarAppearanceEditor: View { enum Location { case settings - case popover(closePopover: () -> Void) + case panel } @EnvironmentObject var appState: AppState @@ -17,38 +17,22 @@ struct MenuBarAppearanceEditor: View { let location: Location private var mainFormPadding: EdgeInsets { - with(EdgeInsets.iceFormDefaultPadding) { insets in + withMutableCopy(of: EdgeInsets.iceFormDefaultPadding) { insets in switch location { case .settings: break - case .popover: insets.top = 0 + case .panel: insets.top = insets.bottom } } } var body: some View { - VStack(alignment: .leading, spacing: 0) { - stackHeader - stackBody + bodyContent.safeAreaInset(edge: .bottom, spacing: 0) { + bottomBar } } @ViewBuilder - private var stackHeader: some View { - if case .popover(let closePopover) = location { - ZStack { - Text("Menu Bar Appearance") - .font(.title2) - .frame(maxWidth: .infinity, alignment: .center) - Button("Done", action: closePopover) - .controlSize(.large) - .frame(maxWidth: .infinity, alignment: .trailing) - } - .padding(20) - } - } - - @ViewBuilder - private var stackBody: some View { + private var bodyContent: some View { if appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults { cannotEdit } else { @@ -56,6 +40,13 @@ struct MenuBarAppearanceEditor: View { } } + @ViewBuilder + private var cannotEdit: some View { + Text("Ice cannot edit the appearance of automatically hidden menu bars.") + .font(.title3) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + @ViewBuilder private var mainForm: some View { IceForm(padding: mainFormPadding) { @@ -72,15 +63,21 @@ struct MenuBarAppearanceEditor: View { isDynamicToggle } if appearanceManager.configuration.isDynamic { - LabeledPartialEditor(appearance: .light) - LabeledPartialEditor(appearance: .dark) + LabeledPartialEditor(configuration: $appearanceManager.configuration, appearance: .light) + LabeledPartialEditor(configuration: $appearanceManager.configuration, appearance: .dark) } else { - StaticPartialEditor() + StaticPartialEditor(configuration: $appearanceManager.configuration) } IceSection("Menu Bar Shape") { shapePicker isInset } + } + } + + @ViewBuilder + private var bottomBar: some View { + let stack = HStack { if !appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults, appearanceManager.configuration != .defaultConfiguration @@ -88,37 +85,42 @@ struct MenuBarAppearanceEditor: View { Button("Reset") { appearanceManager.configuration = .defaultConfiguration } - .controlSize(.large) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) } + + Spacer() + + if case .panel = location { + DismissWindowButton("Done") + } + } + .controlSize(.large) + .padding(10) + + if case .panel = location { + stack.background(.ultraThickMaterial) + } else { + stack.background(.bar) } } @ViewBuilder private var isDynamicToggle: some View { - Toggle("Use dynamic appearance", isOn: appearanceManager.bindings.configuration.isDynamic) + Toggle("Use dynamic appearance", isOn: $appearanceManager.configuration.isDynamic) .annotation("Apply different settings based on the current system appearance.") } - @ViewBuilder - private var cannotEdit: some View { - Text("Ice cannot edit the appearance of automatically hidden menu bars.") - .font(.title3) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - } - @ViewBuilder private var shapePicker: some View { - MenuBarShapePicker() + MenuBarShapePicker(configuration: $appearanceManager.configuration) .fixedSize(horizontal: false, vertical: true) } @ViewBuilder private var isInset: some View { - if appearanceManager.configuration.shapeKind != .none { + if appearanceManager.configuration.shapeKind != .noShape { Toggle( "Use inset shape on screens with notch", - isOn: appearanceManager.bindings.configuration.isInset + isOn: $appearanceManager.configuration.isInset ) } } @@ -151,21 +153,22 @@ private struct UnlabeledPartialEditor: View { .labelsHidden() switch configuration.tintKind { - case .none: + case .noTint: EmptyView() case .solid: - CustomColorPicker( + IceColorPicker( + configuration.tintKind.localized, selection: $configuration.tintColor, - supportsOpacity: false, - mode: .crayon + supportsOpacity: false ) + .labelsHidden() case .gradient: - CustomGradientPicker( + IceGradientPicker( + configuration.tintKind.localized, gradient: $configuration.tintGradient, - supportsOpacity: false, - allowsEmptySelections: false, - mode: .crayon + supportsOpacity: false ) + .labelsHidden() } } .frame(height: 24) @@ -185,13 +188,11 @@ private struct UnlabeledPartialEditor: View { @ViewBuilder private var borderColor: some View { if configuration.hasBorder { - IceLabeledContent("Border Color") { - CustomColorPicker( - selection: $configuration.borderColor, - supportsOpacity: true, - mode: .crayon - ) - } + IceColorPicker( + "Border Color", + selection: $configuration.borderColor, + supportsOpacity: true + ) } } @@ -211,7 +212,7 @@ private struct UnlabeledPartialEditor: View { } private struct LabeledPartialEditor: View { - @EnvironmentObject var appearanceManager: MenuBarAppearanceManager + @Binding var configuration: MenuBarAppearanceConfigurationV2 @State private var currentAppearance = SystemAppearance.current @State private var textFrame = CGRect.zero @@ -246,9 +247,9 @@ private struct LabeledPartialEditor: View { private var previewButton: some View { switch appearance { case .light: - PreviewButton(configuration: appearanceManager.configuration.lightModeConfiguration) + PreviewButton(configuration: configuration.lightModeConfiguration) case .dark: - PreviewButton(configuration: appearanceManager.configuration.darkModeConfiguration) + PreviewButton(configuration: configuration.darkModeConfiguration) } } @@ -256,18 +257,18 @@ private struct LabeledPartialEditor: View { private var partialEditor: some View { switch appearance { case .light: - UnlabeledPartialEditor(configuration: appearanceManager.bindings.configuration.lightModeConfiguration) + UnlabeledPartialEditor(configuration: $configuration.lightModeConfiguration) case .dark: - UnlabeledPartialEditor(configuration: appearanceManager.bindings.configuration.darkModeConfiguration) + UnlabeledPartialEditor(configuration: $configuration.darkModeConfiguration) } } } private struct StaticPartialEditor: View { - @EnvironmentObject var appearanceManager: MenuBarAppearanceManager + @Binding var configuration: MenuBarAppearanceConfigurationV2 var body: some View { - UnlabeledPartialEditor(configuration: appearanceManager.bindings.configuration.staticConfiguration) + UnlabeledPartialEditor(configuration: $configuration.staticConfiguration) } } diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift index 832a5e577..7d10b6b7c 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift @@ -6,110 +6,141 @@ import Combine import SwiftUI -// MARK: - MenuBarAppearanceEditorPanel - -/// A panel that manages the appearance editor popover. +/// A panel that contains a portable version of the menu bar +/// appearance editor interface. final class MenuBarAppearanceEditorPanel: NSPanel { + /// The default screen to show the panel on. + static var defaultScreen: NSScreen? { + NSScreen.screenWithMouse ?? NSScreen.main + } + /// The shared app state. private weak var appState: AppState? /// Storage for internal observers. private var cancellables = Set() - init(appState: AppState) { + /// Overridden to always be `true`. + override var canBecomeKey: Bool { true } + + /// Creates a menu bar appearance editor panel. + init() { super.init( - contentRect: CGRect(x: 0, y: 0, width: 1, height: 1), - styleMask: [.borderless, .nonactivatingPanel], + contentRect: .zero, + styleMask: [.titled, .closable, .fullSizeContentView, .nonactivatingPanel, .utilityWindow, .hudWindow], backing: .buffered, defer: false ) + self.titlebarAppearsTransparent = true + self.isExcludedFromWindowsMenu = false + self.becomesKeyOnlyIfNeeded = true + self.isMovableByWindowBackground = false + self.isMovable = false + self.hidesOnDeactivate = false + self.level = .floating + self.collectionBehavior = [.fullScreenAuxiliary, .ignoresCycle, .moveToActiveSpace] + standardWindowButton(.closeButton)?.isHidden = true + } + + /// Sets up the panel. + func performSetup(with appState: AppState) { self.appState = appState - self.isFloatingPanel = true - self.backgroundColor = .clear + configureContentView(with: appState) configureCancellables() } + /// Configures the panel's content view. + private func configureContentView(with appState: AppState) { + let hostingView = MenuBarAppearanceEditorHostingView(appState: appState) + setFrame(hostingView.frame, display: true) + contentView = hostingView + } + + /// Configures the internal observers for the panel. private func configureCancellables() { var c = Set() - NSWorkspace.shared.notificationCenter - .publisher(for: NSWorkspace.activeSpaceDidChangeNotification) - .sink { [weak self] _ in - self?.orderOut(self) - NSColorPanel.shared.close() - NSColorPanel.shared.hidesOnDeactivate = true + // Make sure the panel takes on the app's appearance. + NSApp.publisher(for: \.effectiveAppearance) + .sink { [weak self] effectiveAppearance in + self?.appearance = effectiveAppearance } .store(in: &c) + // Close the panel when certain app or system events occur. + Publishers.Merge3( + NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.activeSpaceDidChangeNotification), + NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification), + NotificationCenter.default.publisher(for: NSApplication.didResignActiveNotification) + ) + .sink { [weak self] _ in + self?.close() + } + .store(in: &c) + cancellables = c } - /// Shows the appearance editor popover. - func showAppearanceEditorPopover() { - guard - let appState, - let contentView, - let screen = NSScreen.screens.first(where: { $0.frame.contains(NSEvent.mouseLocation) }), - let menuBarHeight = NSApp.mainMenu?.menuBarHeight - else { - return - } - setFrameOrigin(CGPoint(x: screen.frame.midX - frame.width / 2, y: screen.frame.maxY - menuBarHeight)) - let popover = MenuBarAppearanceEditorPopover(appState: appState) - popover.delegate = self - popover.show(relativeTo: .zero, of: contentView, preferredEdge: .minY) - popover.contentViewController?.view.window?.makeKey() - NSColorPanel.shared.hidesOnDeactivate = false + /// Updates the origin of the panel's frame for display + /// on the given screen. + private func updateOrigin(for screen: NSScreen) { + let originX = screen.frame.midX - frame.width / 2 + let originY = screen.visibleFrame.maxY - frame.height + setFrameOrigin(CGPoint(x: originX, y: originY)) } -} -// MARK: MenuBarAppearanceEditorPanel: NSPopoverDelegate -extension MenuBarAppearanceEditorPanel: NSPopoverDelegate { - func popoverDidClose(_ notification: Notification) { - if let popover = notification.object as? MenuBarAppearanceEditorPopover { - popover.mouseDownMonitor.stop() - orderOut(popover) - NSColorPanel.shared.close() - NSColorPanel.shared.hidesOnDeactivate = true - } + /// Shows the panel on the given screen. + func show(on screen: NSScreen) { + updateOrigin(for: screen) + makeKeyAndOrderFront(nil) } -} -// MARK: - MenuBarAppearanceEditorPopover + override func cancelOperation(_ sender: Any?) { + super.cancelOperation(sender) + close() + } +} -/// A popover that displays the menu bar appearance editor -/// at a centered location under the menu bar. -private final class MenuBarAppearanceEditorPopover: NSPopover { - private weak var appState: AppState? +// MARK: - MenuBarAppearanceEditorHostingView - private(set) lazy var mouseDownMonitor = GlobalEventMonitor(mask: .leftMouseDown) { [weak self] _ in - self?.performClose(self) - } +private final class MenuBarAppearanceEditorHostingView: NSHostingView { + override var acceptsFirstResponder: Bool { true } + override var needsPanelToBecomeKey: Bool { true } - @ViewBuilder - private var contentView: some View { - if let appState { - MenuBarAppearanceEditor( - location: .popover(closePopover: { [weak self] in - self?.performClose(self) - }) - ) - .environmentObject(appState) - .environmentObject(appState.appearanceManager) - } - } + override var safeAreaInsets: NSEdgeInsets { NSEdgeInsets() } + override var intrinsicContentSize: CGSize { CGSize(width: 550, height: 600) } init(appState: AppState) { - super.init() - self.appState = appState - self.contentViewController = NSHostingController(rootView: contentView) - self.contentSize = CGSize(width: 550, height: 600) - self.behavior = .applicationDefined - self.mouseDownMonitor.start() + super.init(rootView: MenuBarAppearanceEditorContentView(appState: appState)) + setFrameSize(intrinsicContentSize) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + @available(*, unavailable) + required init(rootView: MenuBarAppearanceEditorContentView) { + fatalError("init(rootView:) has not been implemented") + } +} + +// MARK: - MenuBarAppearanceEditorContentView + +private struct MenuBarAppearanceEditorContentView: View { + @ObservedObject var appState: AppState + + var body: some View { + MenuBarAppearanceEditor(location: .panel) + .background { + Rectangle() + .fill(.regularMaterial) + Rectangle() + .fill(.windowBackground) + .opacity(0.25) + } + .environmentObject(appState) + .environmentObject(appState.appearanceManager) + } } diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarShapePicker.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarShapePicker.swift index 4e935530a..ba4ccc6b3 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarShapePicker.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarShapePicker.swift @@ -6,21 +6,29 @@ import SwiftUI struct MenuBarShapePicker: View { - @EnvironmentObject var appearanceManager: MenuBarAppearanceManager @Environment(\.colorScheme) private var colorScheme + @Binding var configuration: MenuBarAppearanceConfigurationV2 var body: some View { - shapeKindPicker - exampleView + VStack { + shapeKindPicker + shapePicker + .foregroundStyle(colorScheme == .dark ? .primary : .secondary) + } + if configuration.shapeKind == .noShape { + Text("No shape kind selected") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } } @ViewBuilder private var shapeKindPicker: some View { - IcePicker("Shape Kind", selection: appearanceManager.bindings.configuration.shapeKind) { + IcePicker("Shape Kind", selection: $configuration.shapeKind) { ForEach(MenuBarShapeKind.allCases, id: \.self) { shape in switch shape { - case .none: - Text("None").tag(shape) + case .noShape: + Text("No Shape").tag(shape) case .full: Text("Full").tag(shape) case .split: @@ -31,25 +39,19 @@ struct MenuBarShapePicker: View { } @ViewBuilder - private var exampleView: some View { - switch appearanceManager.configuration.shapeKind { - case .none: - Text("No shape kind selected") - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) + private var shapePicker: some View { + switch configuration.shapeKind { + case .noShape: + EmptyView() case .full: - MenuBarFullShapeExampleView(info: appearanceManager.bindings.configuration.fullShapeInfo) - .equatable() - .foregroundStyle(colorScheme == .dark ? .primary : .secondary) + MenuBarFullShapePicker(info: $configuration.fullShapeInfo).equatable() case .split: - MenuBarSplitShapeExampleView(info: appearanceManager.bindings.configuration.splitShapeInfo) - .equatable() - .foregroundStyle(colorScheme == .dark ? .primary : .secondary) + MenuBarSplitShapePicker(info: $configuration.splitShapeInfo).equatable() } } } -private struct MenuBarFullShapeExampleView: View, Equatable { +private struct MenuBarFullShapePicker: View, Equatable { @Binding var info: MenuBarFullShapeInfo var body: some View { @@ -153,6 +155,22 @@ private struct MenuBarFullShapeExampleView: View, Equatable { } } +private struct MenuBarSplitShapePicker: View, Equatable { + @Binding var info: MenuBarSplitShapeInfo + + var body: some View { + HStack { + MenuBarFullShapePicker(info: $info.leading).equatable() + Divider() + MenuBarFullShapePicker(info: $info.trailing).equatable() + } + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.info == rhs.info + } +} + private struct MenuBarEndCapExampleView: View { @State private var radius: CGFloat = 0 @@ -187,22 +205,3 @@ private struct MenuBarEndCapExampleView: View { } } } - -private struct MenuBarSplitShapeExampleView: View, Equatable { - @Binding var info: MenuBarSplitShapeInfo - - var body: some View { - HStack { - MenuBarFullShapeExampleView(info: $info.leading) - .equatable() - Divider() - .padding(.horizontal) - MenuBarFullShapeExampleView(info: $info.trailing) - .equatable() - } - } - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.info == rhs.info - } -} diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift index 5e5ca2182..e68482932 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift @@ -111,10 +111,10 @@ final class MenuBarAppearanceManager: ObservableObject { if current.hasBorder { return true } - if configuration.shapeKind != .none { + if configuration.shapeKind != .noShape { return true } - if current.tintKind != .none { + if current.tintKind != .noTint { return true } return false @@ -142,6 +142,3 @@ final class MenuBarAppearanceManager: ObservableObject { self.overlayPanels = overlayPanels } } - -// MARK: MenuBarAppearanceManager: BindingExposable -extension MenuBarAppearanceManager: BindingExposable { } diff --git a/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift b/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift index fe6927444..99d4cc1b1 100644 --- a/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift +++ b/Ice/MenuBar/Appearance/MenuBarOverlayPanel.swift @@ -93,7 +93,12 @@ final class MenuBarOverlayPanel: NSPanel { self.title = "Menu Bar Overlay" self.backgroundColor = .clear self.hasShadow = false + self.animationBehavior = .none + self.hidesOnDeactivate = false + self.canHide = false + self.isMovable = false self.ignoresMouseEvents = true + self.isExcludedFromWindowsMenu = true self.collectionBehavior = [.fullScreenNone, .ignoresCycle, .moveToActiveSpace] self.contentView = MenuBarOverlayPanelContentView() configureCancellables() @@ -140,19 +145,15 @@ final class MenuBarOverlayPanel: NSPanel { ) .removeDuplicates() .sink { [weak self] _ in - guard - let self, - let appState - else { + guard let self else { return } - let displayID = owningScreen.displayID updateTaskContext.setTask(for: .applicationMenuFrame, timeout: .seconds(10)) { var hasDoneInitialUpdate = false while true { try Task.checkCancellation() guard - let latestFrame = appState.menuBarManager.getApplicationMenuFrame(for: displayID), + let latestFrame = self.owningScreen.getApplicationMenuFrame(), latestFrame != self.applicationMenuFrame else { if hasDoneInitialUpdate { @@ -180,7 +181,7 @@ final class MenuBarOverlayPanel: NSPanel { publisher(for: \.isOnActiveSpace) .receive(on: DispatchQueue.main) .replace(with: ()), - UniversalEventMonitor.publisher(for: .leftMouseUp) + EventMonitor.publish(events: .leftMouseUp, scope: .universal) .filter { [weak self] _ in self?.isOnActiveSpace ?? false } .replace(with: ()) ) @@ -229,10 +230,9 @@ final class MenuBarOverlayPanel: NSPanel { self.updateFlags.removeAll() } let windows = WindowInfo.createWindows(option: .onScreen) - guard let owningDisplay = self.validate(for: .updates, with: windows) else { - return + if validate(for: .updates, with: windows) { + performUpdates(for: flags, windows: windows, screen: owningScreen) } - performUpdates(for: flags, windows: windows, display: owningDisplay) } .store(in: &c) @@ -254,40 +254,39 @@ final class MenuBarOverlayPanel: NSPanel { /// Performs validation for the given validation kind. Returns the panel's /// owning display if successful. Returns `nil` on failure. - private func validate(for kind: ValidationKind, with windows: [WindowInfo]) -> CGDirectDisplayID? { + private func validate(for kind: ValidationKind, with windows: [WindowInfo]) -> Bool { lazy var actionMessage = switch kind { case .showing: "Preventing overlay panel from showing." case .updates: "Preventing overlay panel from updating." } guard let appState else { MenuBarOverlayPanel.logger.debug("No app state. \(actionMessage, privacy: .public)") - return nil + return false } guard !appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults else { MenuBarOverlayPanel.logger.debug("Menu bar is hidden by system. \(actionMessage, privacy: .public)") - return nil + return false } - guard !appState.isActiveSpaceFullscreen else { + guard !appState.activeSpace.isFullscreen else { MenuBarOverlayPanel.logger.debug("Active space is fullscreen. \(actionMessage, privacy: .public)") - return nil + return false } - let owningDisplay = owningScreen.displayID - guard appState.menuBarManager.hasValidMenuBar(in: windows, for: owningDisplay) else { + guard appState.menuBarManager.hasValidMenuBar(in: windows, for: owningScreen.displayID) else { MenuBarOverlayPanel.logger.debug("No valid menu bar found. \(actionMessage, privacy: .public)") - return nil + return false } - return owningDisplay + return true } /// Stores the frame of the menu bar's application menu. - private func updateApplicationMenuFrame(for display: CGDirectDisplayID) { + private func updateApplicationMenuFrame(for screen: NSScreen) { guard let menuBarManager = appState?.menuBarManager, !menuBarManager.isMenuBarHiddenBySystem else { return } - applicationMenuFrame = menuBarManager.getApplicationMenuFrame(for: display) + applicationMenuFrame = screen.getApplicationMenuFrame() } /// Stores the area of the desktop wallpaper that is under the menu bar @@ -299,19 +298,19 @@ final class MenuBarOverlayPanel: NSPanel { else { return } - let wallpaper = ScreenCapture.captureWindow(wallpaperWindow.windowID, screenBounds: menuBarWindow.bounds) + let wallpaper = ScreenCapture.captureWindow(with: wallpaperWindow.windowID, screenBounds: menuBarWindow.bounds) if desktopWallpaper?.dataProvider?.data != wallpaper?.dataProvider?.data { desktopWallpaper = wallpaper } } /// Updates the panel to prepare for display. - private func performUpdates(for flags: Set, windows: [WindowInfo], display: CGDirectDisplayID) { + private func performUpdates(for flags: Set, windows: [WindowInfo], screen: NSScreen) { if flags.contains(.applicationMenuFrame) { - updateApplicationMenuFrame(for: display) + updateApplicationMenuFrame(for: screen) } if flags.contains(.desktopWallpaper) { - updateDesktopWallpaper(for: display, with: windows) + updateDesktopWallpaper(for: screen.displayID, with: windows) } } @@ -616,7 +615,7 @@ private final class MenuBarOverlayPanelContentView: NSView { /// Draws the tint defined by the given configuration in the given rectangle. private func drawTint(in rect: CGRect) { switch configuration.tintKind { - case .none: + case .noTint: break case .solid: if let tintColor = NSColor(cgColor: configuration.tintColor)?.withAlphaComponent(0.2) { @@ -624,7 +623,7 @@ private final class MenuBarOverlayPanelContentView: NSView { rect.fill() } case .gradient: - if let tintGradient = configuration.tintGradient.withAlphaComponent(0.2).nsGradient { + if let tintGradient = configuration.tintGradient.withAlpha(0.2).nsGradient(using: .displayP3) { tintGradient.draw(in: rect, angle: 0) } } @@ -641,7 +640,7 @@ private final class MenuBarOverlayPanelContentView: NSView { let drawableBounds = getDrawableBounds() let shapePath = switch fullConfiguration.shapeKind { - case .none: + case .noShape: NSBezierPath(rect: drawableBounds) case .full: pathForFullShape( @@ -662,7 +661,7 @@ private final class MenuBarOverlayPanelContentView: NSView { var hasBorder = false switch fullConfiguration.shapeKind { - case .none: + case .noShape: if configuration.hasShadow { let gradient = NSGradient( colors: [ @@ -743,7 +742,7 @@ private final class MenuBarOverlayPanelContentView: NSView { } let borderPath = switch fullConfiguration.shapeKind { - case .none: + case .noShape: NSBezierPath(rect: drawableBounds) case .full: pathForFullShape( diff --git a/Ice/MenuBar/Appearance/MenuBarShape.swift b/Ice/MenuBar/Appearance/MenuBarShape.swift index b80355149..41f9cf4a7 100644 --- a/Ice/MenuBar/Appearance/MenuBarShape.swift +++ b/Ice/MenuBar/Appearance/MenuBarShape.swift @@ -16,7 +16,7 @@ enum MenuBarEndCap: Int, Codable, Hashable, CaseIterable { /// A type that specifies a custom shape kind for the menu bar. enum MenuBarShapeKind: Int, Codable, Hashable, CaseIterable { /// The menu bar does not use a custom shape. - case none = 0 + case noShape = 0 /// A custom shape that takes up the full menu bar. case full = 1 /// A custom shape that splits the menu bar between diff --git a/Ice/MenuBar/Appearance/MenuBarTintKind.swift b/Ice/MenuBar/Appearance/MenuBarTintKind.swift index 9d665f63d..3a04c8c18 100644 --- a/Ice/MenuBar/Appearance/MenuBarTintKind.swift +++ b/Ice/MenuBar/Appearance/MenuBarTintKind.swift @@ -8,7 +8,7 @@ import SwiftUI /// A type that specifies how the menu bar is tinted. enum MenuBarTintKind: Int, CaseIterable, Codable, Identifiable { /// The menu bar is not tinted. - case none = 0 + case noTint = 0 /// The menu bar is tinted with a solid color. case solid = 1 /// The menu bar is tinted with a gradient. @@ -19,7 +19,7 @@ enum MenuBarTintKind: Int, CaseIterable, Codable, Identifiable { /// Localized string key representation. var localized: LocalizedStringKey { switch self { - case .none: "None" + case .noTint: "No Tint" case .solid: "Solid" case .gradient: "Gradient" } diff --git a/Ice/MenuBar/ControlItem/ControlItem.swift b/Ice/MenuBar/ControlItem/ControlItem.swift index 1a752f30d..c9be784d2 100644 --- a/Ice/MenuBar/ControlItem/ControlItem.swift +++ b/Ice/MenuBar/ControlItem/ControlItem.swift @@ -90,6 +90,7 @@ final class ControlItem { button.target = controlItem button.action = #selector(controlItem.performAction) + button.sendAction(on: [.leftMouseDown, .rightMouseUp]) } else { self.constraint = nil } @@ -277,23 +278,6 @@ final class ControlItem { } .store(in: &c) - appState.settings.general.$useIceBar - .receive(on: DispatchQueue.main) - .sink { [weak self] useIceBar in - guard - let self, - let button = statusItem.button - else { - return - } - if useIceBar { - button.sendAction(on: [.leftMouseDown, .rightMouseUp]) - } else { - button.sendAction(on: [.leftMouseUp, .rightMouseUp]) - } - } - .store(in: &c) - if identifier == .visible { appState.settings.general.$showIceIcon .combineLatest(statusItem.publisher(for: \.isVisible)) @@ -462,7 +446,7 @@ final class ControlItem { statusItem.length = shouldShow ? 3 : 0 if let window { - let size = with(window.frame.size) { $0.width = shouldShow ? 3 : 1 } + let size = withMutableCopy(of: window.frame.size) { $0.width = shouldShow ? 3 : 1 } window.setContentSize(size) } } @@ -505,7 +489,7 @@ final class ControlItem { /// Performs the control item's action. @objc private func performAction() { guard - let appState, + let menuBarManager = appState?.menuBarManager, let event = NSApp.currentEvent else { return @@ -513,29 +497,32 @@ final class ControlItem { switch event.type { case .leftMouseDown, .leftMouseUp: - if NSEvent.modifierFlags == .control { - showMenu() - return - } + let modifierFlags = NSEvent.modifierFlags - let targetSection: MenuBarSection + // Running this from a Task seems to improve the visual + // responsiveness of the status item's button. + Task { + if modifierFlags == .control { + showMenu() + return + } - if - NSEvent.modifierFlags == .option, - let alwaysHiddenSection = appState.menuBarManager.section(withName: .alwaysHidden), - alwaysHiddenSection.isEnabled - { - targetSection = alwaysHiddenSection - } else if - let section = appState.menuBarManager.section(withName: sectionName), - section.isEnabled - { - targetSection = section - } else { - return - } + if + modifierFlags == .option, + let section = menuBarManager.section(withName: .alwaysHidden), + section.isEnabled + { + section.toggle() + return + } - targetSection.toggle() + if + let section = menuBarManager.section(withName: sectionName), + section.isEnabled + { + section.toggle() + } + } case .rightMouseUp: showMenu() default: diff --git a/Ice/MenuBar/IceBar/IceBar.swift b/Ice/MenuBar/IceBar/IceBar.swift index b2d4bc0ab..07ff64151 100644 --- a/Ice/MenuBar/IceBar/IceBar.swift +++ b/Ice/MenuBar/IceBar/IceBar.swift @@ -141,7 +141,7 @@ final class IceBarPanel: NSPanel { guard lowerBound <= upperBound, - let controlItem = appState.itemManager.itemCache.allItems.first(matching: .visibleControlItem), + let controlItem = appState.itemManager.itemCache.managedItems.first(matching: .visibleControlItem), // Bridging API is more reliable than controlItem.frame in some // cases (like if the item is offscreen). let itemBounds = Bridging.getWindowBounds(for: controlItem.windowID) @@ -168,20 +168,18 @@ final class IceBarPanel: NSPanel { appState.navigationState.isIceBarPresented = true currentSection = section - let cacheTask = Task(timeout: .milliseconds(100)) { + let cacheTask = Task(timeout: .seconds(1)) { await appState.itemManager.cacheItemsIfNeeded() await appState.imageCache.updateCache() } do { try await cacheTask.value - } catch is TaskTimeoutError { - Logger.general.error("Cache task timed out during IceBarPanel.show") } catch { - Logger.general.error("Cache task failed during IceBarPanel.show - \(error)") + Logger.general.error("Cache update failed when showing IceBarPanel - \(error)") } - contentView = IceBarContentHostingView( + contentView = IceBarHostingView( appState: appState, colorManager: colorManager, screen: screen, @@ -220,12 +218,10 @@ final class IceBarPanel: NSPanel { } } -// MARK: - IceBarContentHostingView +// MARK: - IceBarHostingView -private final class IceBarContentHostingView: NSHostingView { - override var safeAreaInsets: NSEdgeInsets { - NSEdgeInsets() - } +private final class IceBarHostingView: NSHostingView { + override var safeAreaInsets: NSEdgeInsets { NSEdgeInsets() } init( appState: AppState, @@ -294,7 +290,7 @@ private struct IceBarContentView: View { guard let menuBarHeight = screen.getMenuBarHeight() else { return nil } - if configuration.shapeKind != .none && configuration.isInset && screen.hasNotch { + if configuration.shapeKind != .noShape && configuration.isInset && screen.hasNotch { return menuBarHeight - appState.appearanceManager.menuBarInsetAmount * 2 } return menuBarHeight @@ -320,7 +316,7 @@ private struct IceBarContentView: View { .frame(height: contentHeight) .padding(.horizontal, horizontalPadding) .padding(.vertical, verticalPadding) - .layoutBarStyle(appState: appState, averageColorInfo: colorManager.colorInfo) + .menuBarItemContainer(appState: appState, colorInfo: colorManager.colorInfo) .foregroundStyle(colorManager.colorInfo?.color.brightness ?? 0 > 0.67 ? .black : .white) .clipShape(clipShape) .shadow(color: .black.opacity(shadowOpacity), radius: 2.5) @@ -378,6 +374,7 @@ private struct IceBarContentView: View { itemManager: itemManager, menuBarManager: menuBarManager, item: item, + screen: screen, section: section ) } @@ -401,6 +398,7 @@ private struct IceBarItemView: View { @ObservedObject var menuBarManager: MenuBarManager let item: MenuBarItem + let screen: NSScreen let section: MenuBarSection.Name private var leftClickAction: () -> Void { @@ -411,7 +409,11 @@ private struct IceBarItemView: View { menuBarManager.section(withName: section)?.hide() Task { try await Task.sleep(for: .milliseconds(25)) - itemManager.tempShowItem(item, clickWhenFinished: true, mouseButton: .left) + if Bridging.isWindowOnDisplay(item.windowID, screen.displayID) { + try await itemManager.click(item: item, with: .left) + } else { + await itemManager.tempShow(item: item, clickingWith: .left) + } } } } @@ -424,7 +426,11 @@ private struct IceBarItemView: View { menuBarManager.section(withName: section)?.hide() Task { try await Task.sleep(for: .milliseconds(25)) - itemManager.tempShowItem(item, clickWhenFinished: true, mouseButton: .right) + if Bridging.isWindowOnDisplay(item.windowID, screen.displayID) { + try await itemManager.click(item: item, with: .right) + } else { + await itemManager.tempShow(item: item, clickingWith: .right) + } } } } diff --git a/Ice/MenuBar/IceBar/IceBarColorManager.swift b/Ice/MenuBar/IceBar/IceBarColorManager.swift index c13c1457f..317b36949 100644 --- a/Ice/MenuBar/IceBar/IceBarColorManager.swift +++ b/Ice/MenuBar/IceBar/IceBarColorManager.swift @@ -7,16 +7,11 @@ import Combine import SwiftUI final class IceBarColorManager: ObservableObject { - private struct WindowImageInfo { - let image: CGImage - let source: MenuBarAverageColorInfo.Source - } - @Published private(set) var colorInfo: MenuBarAverageColorInfo? private weak var iceBarPanel: IceBarPanel? - private var windowImageInfo: WindowImageInfo? + private var windowImage: CGImage? private var cancellables = Set() @@ -39,7 +34,7 @@ final class IceBarColorManager: ObservableObject { else { return } - updateWindowImageInfo(for: screen) + updateWindowImage(for: screen) } .store(in: &c) @@ -101,7 +96,7 @@ final class IceBarColorManager: ObservableObject { else { return } - updateWindowImageInfo(for: screen) + updateWindowImage(for: screen) if iceBarPanel.isVisible { withAnimation { self.updateColorInfo(with: iceBarPanel.frame, screen: screen) @@ -114,7 +109,7 @@ final class IceBarColorManager: ObservableObject { cancellables = c } - private func updateWindowImageInfo(for screen: NSScreen) { + private func updateWindowImage(for screen: NSScreen) { let windows = WindowInfo.createWindows(option: .onScreen) let displayID = screen.displayID @@ -125,25 +120,22 @@ final class IceBarColorManager: ObservableObject { return } - let windowIDs = [menuBarWindow.windowID, wallpaperWindow.windowID] - let bounds = with(wallpaperWindow.bounds) { $0.size.height = 1 } - let option: CGWindowImageOption = .nominalResolution - - guard let image = ScreenCapture.captureWindows(windowIDs, screenBounds: bounds, option: option) else { + guard let image = ScreenCapture.captureWindows( + with: [menuBarWindow.windowID, wallpaperWindow.windowID], + screenBounds: withMutableCopy(of: wallpaperWindow.bounds) { $0.size.height = 1 }, + option: .nominalResolution + ) else { return } - // Just use `menuBarWindow` as the source for now, regardless - // of whether it contributes to the capture. - windowImageInfo = WindowImageInfo(image: image, source: .menuBarWindow) + windowImage = image } private func updateColorInfo(with frame: CGRect, screen: NSScreen) { - guard let windowImageInfo else { + guard let image = windowImage else { return } - let image = windowImageInfo.image let imageBounds = CGRect(x: 0, y: 0, width: image.width, height: image.height) let insetScreenFrame = screen.frame.insetBy(dx: frame.width / 2, dy: 0) @@ -160,11 +152,13 @@ final class IceBarColorManager: ObservableObject { return } - colorInfo = MenuBarAverageColorInfo(color: averageColor, source: windowImageInfo.source) + // Just use `menuBarWindow` as the source for now, regardless + // of whether its image contributed to the average. + colorInfo = MenuBarAverageColorInfo(color: averageColor, source: .menuBarWindow) } func updateAllProperties(with frame: CGRect, screen: NSScreen) { - updateWindowImageInfo(for: screen) + updateWindowImage(for: screen) updateColorInfo(with: frame, screen: screen) } } diff --git a/Ice/MenuBar/LayoutBar/LayoutBar.swift b/Ice/MenuBar/LayoutBar/LayoutBar.swift index 01ac6219f..a89e67b2b 100644 --- a/Ice/MenuBar/LayoutBar/LayoutBar.swift +++ b/Ice/MenuBar/LayoutBar/LayoutBar.swift @@ -8,56 +8,47 @@ import SwiftUI struct LayoutBar: View { private struct Representable: NSViewRepresentable { let appState: AppState - let section: MenuBarSection - let spacing: CGFloat + let section: MenuBarSection.Name func makeNSView(context: Context) -> LayoutBarScrollView { - LayoutBarScrollView(appState: appState, section: section, spacing: spacing) + LayoutBarScrollView(appState: appState, section: section) } - func updateNSView(_ nsView: LayoutBarScrollView, context: Context) { - nsView.spacing = spacing - } + func updateNSView(_ nsView: LayoutBarScrollView, context: Context) { } } @EnvironmentObject var appState: AppState @EnvironmentObject var imageCache: MenuBarItemImageCache - let section: MenuBarSection - let spacing: CGFloat - - private var menuBarManager: MenuBarManager { - appState.menuBarManager - } + let section: MenuBarSection.Name private var backgroundShape: some InsettableShape { - RoundedRectangle(cornerRadius: 9, style: .circular) - } - - init(section: MenuBarSection, spacing: CGFloat = 0) { - self.section = section - self.spacing = spacing + if #available(macOS 26.0, *) { + RoundedRectangle(cornerRadius: 12, style: .continuous) + } else { + RoundedRectangle(cornerRadius: 9, style: .circular) + } } var body: some View { - conditionalBody - .frame(height: 50) + mainContent + .frame(height: 48) .frame(maxWidth: .infinity) - .layoutBarStyle(appState: appState, averageColorInfo: menuBarManager.averageColorInfo) + .menuBarItemContainer(appState: appState) .clipShape(backgroundShape) + .contentShape([.interaction, .focusEffect], backgroundShape) .overlay { backgroundShape - .stroke(.quaternary) + .strokeBorder(.quaternary) } } @ViewBuilder - private var conditionalBody: some View { - if imageCache.cacheFailed(for: section.name) { + private var mainContent: some View { + if imageCache.cacheFailed(for: section) { Text("Unable to display menu bar items") - .foregroundStyle(menuBarManager.averageColorInfo?.color.brightness ?? 0 > 0.67 ? .black : .white) } else { - Representable(appState: appState, section: section, spacing: spacing) + Representable(appState: appState, section: section) } } } diff --git a/Ice/MenuBar/LayoutBar/LayoutBarContainer.swift b/Ice/MenuBar/LayoutBar/LayoutBarContainer.swift index aa172beaf..e83eedd85 100644 --- a/Ice/MenuBar/LayoutBar/LayoutBarContainer.swift +++ b/Ice/MenuBar/LayoutBar/LayoutBarContainer.swift @@ -31,7 +31,7 @@ final class LayoutBarContainer: NSView { private(set) weak var appState: AppState? /// The section whose items are represented. - let section: MenuBarSection + let section: MenuBarSection.Name /// A Boolean value that indicates whether the container should /// animate its next layout pass. @@ -43,13 +43,6 @@ final class LayoutBarContainer: NSView { /// set its arranged views. var canSetArrangedViews = true - /// The amount of space between each arranged view. - var spacing: CGFloat { - didSet { - layoutArrangedViews() - } - } - /// The contaner's arranged views. /// /// The views are laid out from left to right in the order that they @@ -68,11 +61,9 @@ final class LayoutBarContainer: NSView { /// - Parameters: /// - appState: The shared app state instance. /// - section: The section whose items are represented. - /// - spacing: The amount of space between each arranged view. - init(appState: AppState, section: MenuBarSection, spacing: CGFloat) { + init(appState: AppState, section: MenuBarSection.Name) { self.appState = appState self.section = section - self.spacing = spacing super.init(frame: .zero) self.translatesAutoresizingMaskIntoConstraints = false unregisterDraggedTypes() @@ -94,7 +85,7 @@ final class LayoutBarContainer: NSView { guard let self else { return } - setArrangedViews(items: cache.managedItems(for: section.name)) + setArrangedViews(items: cache.managedItems(for: section)) } .store(in: &c) @@ -164,7 +155,7 @@ final class LayoutBarContainer: NSView { // be a newly added view view.setFrameOrigin( CGPoint( - x: previous.map { $0.frame.maxX + spacing } ?? 0, + x: previous.map { $0.frame.maxX } ?? 0, y: (maxHeight / 2) - view.bounds.midY ) ) diff --git a/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift b/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift index ea1ee0961..2cbba602a 100644 --- a/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift +++ b/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift @@ -11,17 +11,6 @@ import OSLog final class LayoutBarPaddingView: NSView { private let container: LayoutBarContainer - /// The section whose items are represented. - var section: MenuBarSection { - container.section - } - - /// The amount of space between each arranged view. - var spacing: CGFloat { - get { container.spacing } - set { container.spacing = newValue } - } - /// The layout view's arranged views. /// /// The views are laid out from left to right in the order that they @@ -37,9 +26,8 @@ final class LayoutBarPaddingView: NSView { /// - Parameters: /// - appState: The shared app state instance. /// - section: The section whose items are represented. - /// - spacing: The amount of space between each arranged view. - init(appState: AppState, section: MenuBarSection, spacing: CGFloat) { - self.container = LayoutBarContainer(appState: appState, section: section, spacing: spacing) + init(appState: AppState, section: MenuBarSection.Name) { + self.container = LayoutBarContainer(appState: appState, section: section) super.init(frame: .zero) addSubview(self.container) @@ -102,7 +90,7 @@ final class LayoutBarPaddingView: NSView { // dragging source is the only view in the layout bar, so we // need to find a target item let items = await MenuBarItem.getMenuBarItems(option: .activeSpace) - let targetItem: MenuBarItem? = switch section.name { + let targetItem: MenuBarItem? = switch container.section { case .visible: nil // visible section always has more than 1 item case .hidden: items.first(matching: .hiddenControlItem) case .alwaysHidden: items.first(matching: .alwaysHiddenControlItem) diff --git a/Ice/MenuBar/LayoutBar/LayoutBarScrollView.swift b/Ice/MenuBar/LayoutBar/LayoutBarScrollView.swift index 1e8b53489..5a2afe70c 100644 --- a/Ice/MenuBar/LayoutBar/LayoutBarScrollView.swift +++ b/Ice/MenuBar/LayoutBar/LayoutBarScrollView.swift @@ -8,12 +8,6 @@ import Cocoa final class LayoutBarScrollView: NSScrollView { private let paddingView: LayoutBarPaddingView - /// The amount of space between each arranged view. - var spacing: CGFloat { - get { paddingView.spacing } - set { paddingView.spacing = newValue } - } - /// The layout view's arranged views. /// /// The views are laid out from left to right in the order that they appear in @@ -29,9 +23,8 @@ final class LayoutBarScrollView: NSScrollView { /// - Parameters: /// - appState: The shared app state instance. /// - section: The section whose items are represented. - /// - spacing: The amount of space between each arranged view. - init(appState: AppState, section: MenuBarSection, spacing: CGFloat) { - self.paddingView = LayoutBarPaddingView(appState: appState, section: section, spacing: spacing) + init(appState: AppState, section: MenuBarSection.Name) { + self.paddingView = LayoutBarPaddingView(appState: appState, section: section) super.init(frame: .zero) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift index da86a36c5..cfd696a27 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift @@ -3,7 +3,6 @@ // Ice // -import AXSwift import Cocoa import Combine @@ -168,6 +167,13 @@ struct MenuBarItem: CustomStringConvertible { self.ownerName = itemWindow.ownerName self.isOnScreen = itemWindow.isOnScreen } + + /// Returns the current bounds for the given menu bar item. + /// + /// - Parameter item: A menu bar item. + static func currentBounds(for item: MenuBarItem) -> CGRect? { + Bridging.getWindowBounds(for: item.windowID) + } } // MARK: - MenuBarItem List @@ -342,6 +348,8 @@ private extension MenuBarItemTag { // MARK: - MenuBarItemTag.Namespace Helper private extension MenuBarItemTag.Namespace { + private static var uuidCache = [CGWindowID: UUID]() + /// Creates a namespace without checks. /// /// This initializer does not perform validity checks on its parameters. @@ -355,9 +363,9 @@ private extension MenuBarItemTag.Namespace { // name seems less likely to change, so let's prefer it as a (somewhat) // stable identifier. if let app = itemWindow.owningApplication { - self.init(app.bundleIdentifier ?? itemWindow.ownerName ?? app.localizedName) + self = .optional(app.bundleIdentifier ?? itemWindow.ownerName ?? app.localizedName) } else { - self.init(itemWindow.ownerName) + self = .optional(itemWindow.ownerName) } } @@ -372,11 +380,13 @@ private extension MenuBarItemTag.Namespace { // that don't. We should also be able to handle daemons and helpers, // which are more likely not to have a bundle ID. if let sourcePID, let app = NSRunningApplication(processIdentifier: sourcePID) { - self.init(app.bundleIdentifier ?? app.localizedName) - } else if let app = itemWindow.owningApplication { - self.init(app.bundleIdentifier ?? itemWindow.ownerName ?? app.localizedName) + self = .optional(app.bundleIdentifier ?? app.localizedName) + } else if let uuid = Self.uuidCache[itemWindow.windowID] { + self = .uuid(uuid) } else { - self.init(itemWindow.ownerName) + let uuid = UUID() + Self.uuidCache[itemWindow.windowID] = uuid + self = .uuid(uuid) } } } diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift index 50049522f..f2f8df151 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift @@ -119,7 +119,7 @@ final class MenuBarItemImageCache: ObservableObject { for item in items { let windowID = item.windowID - // Don't use item.bounds, it could be out of date. + // Don't use `item.bounds`, it could be out of date. guard let bounds = Bridging.getWindowBounds(for: windowID) else { result.excluded.append(item) continue @@ -131,7 +131,7 @@ final class MenuBarItemImageCache: ObservableObject { } guard - let compositeImage = ScreenCapture.captureWindows(windowIDs, option: captureOption), + let compositeImage = ScreenCapture.captureWindows(with: windowIDs, option: captureOption), CGFloat(compositeImage.width) == boundsUnion.width * scale, // Safety check. !compositeImage.isTransparent() else { @@ -170,7 +170,7 @@ final class MenuBarItemImageCache: ObservableObject { for item in items { guard - let image = ScreenCapture.captureWindow(item.windowID, option: captureOption), + let image = ScreenCapture.captureWindow(with: item.windowID, option: captureOption), !image.isTransparent() else { result.excluded.append(item) @@ -182,15 +182,21 @@ final class MenuBarItemImageCache: ObservableObject { return result } - /// Captures the images of the given menu bar items and returns a dictionary - /// containing the images, keyed by their menu bar item tags. - private nonisolated func captureImages(for items: [MenuBarItem], screen: NSScreen) -> [MenuBarItemTag: CapturedImage] { - let scale = screen.backingScaleFactor + /// Captures the images of the given menu bar items and returns the result. + private nonisolated func captureImages(of items: [MenuBarItem], scale: CGFloat, appState: AppState) async -> CaptureResult { + // This check may have already happened at a higher level, but let's check + // again with a more lenient duration. We want to use individual capture if + // there is any chance that items are still moving, since composite capture + // doesn't account for overlapping items. + if await appState.itemManager.latestMoveOperationStarted(within: .seconds(3)) { + logger.debug("Capturing individually due to recent item movement") + return individualCapture(items, scale: scale) + } let compositeResult = compositeCapture(items, scale: scale) if compositeResult.excluded.isEmpty { - return compositeResult.images // All items were captured successfully. + return compositeResult // All items captured successfully. } logger.notice( @@ -200,23 +206,24 @@ final class MenuBarItemImageCache: ObservableObject { """ ) - let individualResult = individualCapture(compositeResult.excluded, scale: scale) + var individualResult = individualCapture(compositeResult.excluded, scale: scale) - if !individualResult.excluded.isEmpty { - logger.error("Some items failed capture: \(individualResult.excluded, privacy: .public)") - } + // Merge the successfully captured images from each result. Keep excluded + // items as part of the result, so they can be logged elsewhere. + individualResult.images.merge(compositeResult.images) { (_, new) in new } - return compositeResult.images.merging(individualResult.images) { (_, new) in new } + return individualResult } /// Captures the images of the menu bar items in the given section and returns /// a dictionary containing the images, keyed by their menu bar item tags. - private func captureImages(for section: MenuBarSection.Name, screen: NSScreen) async -> [MenuBarItemTag: CapturedImage] { - guard let appState else { - return [:] - } + private func captureImages(for section: MenuBarSection.Name, scale: CGFloat, appState: AppState) async -> [MenuBarItemTag: CapturedImage] { let items = await appState.itemManager.itemCache.managedItems(for: section) - return captureImages(for: items, screen: screen) + let captureResult = await captureImages(of: items, scale: scale, appState: appState) + if !captureResult.excluded.isEmpty { + logger.error("Some items failed capture: \(captureResult.excluded, privacy: .public)") + } + return captureResult.images } // MARK: Update Cache @@ -226,12 +233,19 @@ final class MenuBarItemImageCache: ObservableObject { func updateCacheWithoutChecks(sections: [MenuBarSection.Name]) async { guard let appState, - await appState.hasPermission(.screenRecording), - let screen = NSScreen.main + await appState.hasPermission(.screenRecording) + else { + return + } + + guard + let displayID = await appState.itemManager.itemCache.displayID, + let screen = NSScreen.screens.first(where: { $0.displayID == displayID }) else { return } + let scale = screen.backingScaleFactor var newImages = [MenuBarItemTag: CapturedImage]() for section in sections { @@ -239,15 +253,10 @@ final class MenuBarItemImageCache: ObservableObject { continue } - let sectionImages = await captureImages(for: section, screen: screen) + let sectionImages = await captureImages(for: section, scale: scale, appState: appState) guard !sectionImages.isEmpty else { - logger.warning( - """ - Failed to update cached menu bar item images for \ - \(section.logString, privacy: .public) - """ - ) + logger.warning("Failed item image cache for \(section.logString, privacy: .public)") continue } @@ -261,10 +270,6 @@ final class MenuBarItemImageCache: ObservableObject { /// Updates the cache for the given sections, if necessary. func updateCache(sections: [MenuBarSection.Name]) async { - func skippingCache(reason: @escaping @autoclosure () -> String) { - logger.debug("Skipping menu bar item image cache as \(reason(), privacy: .public)") - } - guard let appState else { return } @@ -273,22 +278,21 @@ final class MenuBarItemImageCache: ObservableObject { let isSearchPresented = await appState.navigationState.isSearchPresented if !isIceBarPresented && !isSearchPresented { - guard await appState.navigationState.isAppFrontmost else { - skippingCache(reason: "Ice Bar not visible, app not frontmost") - return - } - guard await appState.navigationState.isSettingsPresented else { - skippingCache(reason: "Ice Bar not visible, Settings not visible") + guard + await appState.navigationState.isSettingsPresented, + case .menuBarLayout = await appState.navigationState.settingsNavigationIdentifier + else { + logger.debug("Skipping item image cache as interface not presented") return } - guard case .menuBarLayout = await appState.navigationState.settingsNavigationIdentifier else { - skippingCache(reason: "Ice Bar not visible, Settings visible but not on Menu Bar Layout") + guard await appState.navigationState.isAppFrontmost else { + logger.debug("Skipping item image cache as app not frontmost") return } } - guard await !appState.itemManager.itemHasRecentlyMoved else { - skippingCache(reason: "an item was recently moved") + guard await !appState.itemManager.latestMoveOperationStarted(within: .seconds(1)) else { + logger.debug("Skipping item image cache due to recent item movement") return } diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index 90c13d36c..1f0a47bea 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -10,123 +10,54 @@ import OSLog /// Manager for menu bar items. @MainActor final class MenuBarItemManager: ObservableObject { - /// Cache for menu bar items. - struct ItemCache: Hashable { - /// All cached menu bar items, keyed by section. - private var items = [MenuBarSection.Name: [MenuBarItem]]() - - /// All cached menu bar items. - var allItems: [MenuBarItem] { - MenuBarSection.Name.allCases.reduce(into: []) { result, section in - result.append(contentsOf: self[section]) - } - } - - /// The cached menu bar items managed by Ice. - var managedItems: [MenuBarItem] { - MenuBarSection.Name.allCases.reduce(into: []) { result, section in - result.append(contentsOf: managedItems(for: section)) - } - } - - /// Clears the cache. - mutating func clear() { - items.removeAll() - } - - /// Returns the cached menu bar items managed by Ice for the given section. - func managedItems(for section: MenuBarSection.Name) -> [MenuBarItem] { - self[section].filter { item in - // Filter out items that can't be hidden. - if !item.canBeHidden { - return false - } - - // Filter out the two separator control items. - if item.isControlItem && item.tag != .visibleControlItem { - return false - } - - return true - } - } - - /// Returns the name of the section for the given menu bar item. - func section(for item: MenuBarItem) -> MenuBarSection.Name? { - for (section, items) in self.items where items.contains(where: { $0.tag == item.tag }) { - return section - } - return nil - } - - /// Accesses the items in the given section. - subscript(section: MenuBarSection.Name) -> [MenuBarItem] { - get { items[section, default: []] } - set { items[section] = newValue } - } - } - - /// Context for a temporarily shown menu bar item. - private struct TempShownItemContext { - /// The tag associated with the item. - let tag: MenuBarItemTag - - /// The destination to return the item to. - let returnDestination: MoveDestination - - /// The window of the item's shown interface. - let shownInterfaceWindow: WindowInfo? + /// An actor that manages menu bar item cache operations. + private final actor CacheActor { + private var cacheTask: Task? - /// A Boolean value that indicates whether the menu bar item's interface is showing. - var isShowingInterface: Bool { - guard let currentWindow = shownInterfaceWindow.flatMap({ WindowInfo(windowID: $0.windowID) }) else { - return false - } - return if - currentWindow.layer != CGWindowLevelForKey(.popUpMenuWindow), - let owningApplication = currentWindow.owningApplication - { - owningApplication.isActive && currentWindow.isOnScreen - } else { - currentWindow.isOnScreen - } + /// Runs the given async closure as a task and waits for it + /// to complete before returning. + func runCacheTask(_ operation: @escaping () async -> Void) async { + cacheTask?.cancel() + cacheTask = Task(operation: operation) + await cacheTask?.value } } /// The manager's menu bar item cache. - @Published private(set) var itemCache = ItemCache() - - /// The shared app state. - private(set) weak var appState: AppState? + @Published private(set) var itemCache = ItemCache(displayID: nil) /// Logger for the menu bar item manager. private let logger = Logger(category: "MenuBarItemManager") - /// Storage for internal observers. - private var cancellables = Set() + /// Serial queue for posting events directly to menu bar items. + private let scrombleQueue = DispatchQueue.targetingGlobal( + label: "MenuBarItemManager.scrombleQueue", + qos: .userInteractive + ) - /// Cached window identifiers for the most recent items. + /// An actor that manages menu bar item cache operations. + private let cacheActor = CacheActor() + + /// Cached window identifiers for the most recent menu + /// bar items. private var cachedItemWindowIDs = [CGWindowID]() - /// Context values for the current temporarily shown items. + /// Context values for the current temporarily shown menu + /// bar items. private var tempShownItemContexts = [TempShownItemContext]() - /// A timer that determines when to rehide the temporarily shown items. - private var tempShownItemsTimer: Timer? + /// A timer for rehiding temporarily shown menu bar items. + private var rehideTimer: Timer? - /// The last time a menu bar item was moved. - private var lastItemMoveStartDate: Date? + /// A timestamp taken at the start of the latest menu bar + /// item movement operation. + private var latestMoveOperationTimestamp: ContinuousClock.Instant? - private var cacheTask: Task? + /// Storage for internal observers. + private var cancellables = Set() - /// A Boolean value that indicates whether a menu bar item has - /// recently moved. - var itemHasRecentlyMoved: Bool { - guard let lastItemMoveStartDate else { - return false - } - return Date.now.timeIntervalSince(lastItemMoveStartDate) <= 1 - } + /// The shared app state. + private(set) weak var appState: AppState? /// Sets up the manager. func performSetup(with appState: AppState) async { @@ -139,23 +70,19 @@ final class MenuBarItemManager: ObservableObject { private func configureCancellables(with appState: AppState) { var c = Set() - Publishers.CombineLatest( - Timer.publish(every: 5, on: .main, in: .default) - .autoconnect() - .merge(with: Just(.now)), - NSWorkspace.shared.publisher(for: \.runningApplications) - .delay(for: 0.25, scheduler: DispatchQueue.main) - ) - .throttle(for: 1, scheduler: DispatchQueue.main, latest: true) - .sink { [weak self] _ in - guard let self else { - return - } - Task { - await self.cacheItemsIfNeeded() + NSWorkspace.shared.publisher(for: \.runningApplications) + .delay(for: 0.25, scheduler: DispatchQueue.main) + .discardMerge(Timer.publish(every: 5, on: .main, in: .default).autoconnect()) + .debounce(for: 1, scheduler: DispatchQueue.main) + .sink { [weak self] in + guard let self else { + return + } + Task { + await self.cacheItemsIfNeeded() + } } - } - .store(in: &c) + .store(in: &c) appState.navigationState.$settingsNavigationIdentifier .sink { [weak self] identifier in @@ -170,12 +97,106 @@ final class MenuBarItemManager: ObservableObject { cancellables = c } + + /// Returns a Boolean value that indicates whether the latest menu bar + /// item movement operation was started within the given time duration. + func latestMoveOperationStarted(within duration: Duration) -> Bool { + guard let timestamp = latestMoveOperationTimestamp else { + return false + } + return timestamp.duration(to: .now) <= duration + } } -// MARK: - Cache Items +// MARK: - Item Cache extension MenuBarItemManager { - private struct ControlItemSet { + /// Cache for menu bar items. + struct ItemCache: Hashable { + /// All cached menu bar items, keyed by section. + private var storage = [MenuBarSection.Name: [MenuBarItem]]() + + /// The identifier of the display with the active menu bar at + /// the time this cache was created. + let displayID: CGDirectDisplayID? + + /// The cached menu bar items as an array. + var managedItems: [MenuBarItem] { + MenuBarSection.Name.allCases.reduce(into: []) { result, section in + result.append(contentsOf: managedItems(for: section)) + } + } + + /// Creates a cache with the given display identifier. + init(displayID: CGDirectDisplayID?) { + self.displayID = displayID + } + + // TODO: This is redundant now, so remove it. + /// Returns the managed menu bar items for the given section. + func managedItems(for section: MenuBarSection.Name) -> [MenuBarItem] { + self[section] + } + + /// Returns the address for the menu bar item with the given tag, + /// if it exists in the cache. + func address(for tag: MenuBarItemTag) -> (section: MenuBarSection.Name, index: Int)? { + for (section, items) in storage { + guard let index = items.firstIndex(matching: tag) else { + continue + } + return (section, index) + } + return nil + } + + /// Inserts the given menu bar item into the cache at the specified + /// destination. + mutating func insert(_ item: MenuBarItem, at destination: MoveDestination) { + let targetTag = destination.targetItem.tag + + if targetTag == .hiddenControlItem { + switch destination { + case .leftOfItem: + self[.hidden].append(item) + case .rightOfItem: + self[.visible].insert(item, at: 0) + } + return + } + + if targetTag == .alwaysHiddenControlItem { + switch destination { + case .leftOfItem: + self[.alwaysHidden].append(item) + case .rightOfItem: + self[.hidden].insert(item, at: 0) + } + return + } + + guard case (let section, var index)? = address(for: targetTag) else { + return + } + + if case .rightOfItem = destination { + let range = self[section].startIndex...self[section].endIndex + index = (index - 1).clamped(to: range) + } + + self[section].insert(item, at: index) + } + + /// Accesses the items in the given section. + subscript(section: MenuBarSection.Name) -> [MenuBarItem] { + get { storage[section, default: []] } + set { storage[section] = newValue } + } + } + + /// A pair of control items, taken from a list of menu bar items + /// during a menu bar item cache operation. + private struct ControlItemPair { let hidden: MenuBarItem let alwaysHidden: MenuBarItem? @@ -188,106 +209,113 @@ extension MenuBarItemManager { } } - /// Caches the given menu bar items, without ensuring that the - /// control items are in the correct order. - private func uncheckedCacheItems(controlItems: ControlItemSet, otherItems: [MenuBarItem]) { - logger.debug("Caching menu bar items") - - let predicates = Predicates.sectionPredicates( - hiddenControlItem: controlItems.hidden, - alwaysHiddenControlItem: controlItems.alwaysHidden - ) + /// Context maintained during a menu bar item cache operation. + private struct CacheContext { + let controlItems: ControlItemPair - var cache = ItemCache() + var cache: ItemCache var tempShownItems = [(MenuBarItem, MoveDestination)]() - for item in otherItems { - if let context = tempShownItemContexts.first(where: { $0.tag == item.tag }) { - // Keep track of temporarily shown items and their return destinations separately. - // We want to cache them as if they were in their original locations. Once all other - // items are cached, use the return destinations to insert the items into the cache - // at the correct position. - tempShownItems.append((item, context.returnDestination)) - } else if predicates.isInVisibleSection(item) { - cache[.visible].append(item) - } else if predicates.isInHiddenSection(item) { - cache[.hidden].append(item) - } else if predicates.isInAlwaysHiddenSection(item) { - cache[.alwaysHidden].append(item) - } else { - logger.warning("\(item.logString, privacy: .public) was not cached") - cachedItemWindowIDs.removeAll() // Make sure we don't skip the next cache attempt. - } + private(set) lazy var hiddenControlItemBounds = bestBounds(for: controlItems.hidden) + private(set) lazy var alwaysHiddenControlItemBounds = controlItems.alwaysHidden.map(bestBounds) + + init(controlItems: ControlItemPair, displayID: CGDirectDisplayID?) { + self.controlItems = controlItems + self.cache = ItemCache(displayID: displayID) } - for (item, destination) in tempShownItems { - switch destination { - case .leftOfItem(let targetItem): - switch targetItem.tag { - case .hiddenControlItem: - cache[.hidden].append(item) - case .alwaysHiddenControlItem: - cache[.alwaysHidden].append(item) - default: - guard - let section = cache.section(for: targetItem), - let index = cache[section].firstIndex(matching: targetItem.tag) - else { - continue - } - let range = cache[section].startIndex...cache[section].endIndex - cache[section].insert(item, at: index.clamped(to: range)) + func bestBounds(for item: MenuBarItem) -> CGRect { + MenuBarItem.currentBounds(for: item) ?? item.bounds + } + + func isValidForCaching(_ item: MenuBarItem) -> Bool { + // Filter out non-hideable items and the two separator control items. + item.canBeHidden && (!item.isControlItem || item.tag == .visibleControlItem) + } + + mutating func isItemInSection(_ item: MenuBarItem, _ section: MenuBarSection.Name) -> Bool { + lazy var itemBounds = bestBounds(for: item) + switch section { + case .visible: + return itemBounds.minX >= hiddenControlItemBounds.maxX + case .hidden: + if let alwaysHiddenControlItemBounds { + return itemBounds.maxX <= hiddenControlItemBounds.minX && + itemBounds.minX >= alwaysHiddenControlItemBounds.maxX + } else { + return itemBounds.maxX <= hiddenControlItemBounds.minX } - case .rightOfItem(let targetItem): - switch targetItem.tag { - case .hiddenControlItem: - cache[.visible].insert(item, at: 0) - case .alwaysHiddenControlItem: - cache[.hidden].insert(item, at: 0) - default: - guard - let section = cache.section(for: targetItem), - let index = cache[section].firstIndex(matching: targetItem.tag) - else { - continue - } - let range = cache[section].startIndex...cache[section].endIndex - cache[section].insert(item, at: (index - 1).clamped(to: range)) + case .alwaysHidden: + if let alwaysHiddenControlItemBounds { + return itemBounds.maxX <= alwaysHiddenControlItemBounds.minX + } else { + return false } } } + } + + /// Caches the given menu bar items, without ensuring that the control + /// items are in the correct order. + private func uncheckedCacheItems(items: [MenuBarItem], context: CacheContext) { + var context = context + + outer: for item in items where context.isValidForCaching(item) { + if let temp = tempShownItemContexts.first(where: { $0.tag == item.tag }) { + // Cache temporarily shown items as if they were in their original locations. + // Keep track of them separately and use their return destinations to insert + // them into the cache once all other items have been handled. + context.tempShownItems.append((item, temp.returnDestination)) + continue + } + + for section in MenuBarSection.Name.allCases where context.isItemInSection(item, section) { + context.cache[section].append(item) + continue outer + } + + logger.warning("\(item.logString, privacy: .public) was not cached") + cachedItemWindowIDs.removeAll() // Make sure we don't skip the next cache attempt. + } + + for (item, destination) in context.tempShownItems { + context.cache.insert(item, at: destination) + } - itemCache = cache + itemCache = context.cache + logger.debug("Updated menu bar item cache") } /// Caches the current menu bar items, regardless of the current item /// state, ensuring that the control items are in the correct order. func cacheItemsRegardless(_ currentItemWindowIDs: [CGWindowID]? = nil) async { - cacheTask?.cancel() - cacheTask = Task { - logger.debug("Preparing to cache menu bar items") + await cacheActor.runCacheTask { [weak self] in + guard let self else { + return + } + let displayID = Bridging.getActiveMenuBarDisplayID() var items = await MenuBarItem.getMenuBarItems(option: .activeSpace) + cachedItemWindowIDs = currentItemWindowIDs ?? items.reversed().map { $0.windowID } - guard let controlItems = ControlItemSet(items: &items) else { - logger.warning("Missing control item for hidden section") - logger.debug("Clearing menu bar item cache") - itemCache.clear() + guard let controlItems = ControlItemPair(items: &items) else { + // ???: Is clearing the cache the best thing to do here? + logger.warning("Missing control item for hidden section - clearing menu bar item cache") + itemCache = ItemCache(displayID: nil) return } await enforceControlItemOrder(controlItems: controlItems) - uncheckedCacheItems(controlItems: controlItems, otherItems: items) + uncheckedCacheItems(items: items, context: CacheContext(controlItems: controlItems, displayID: displayID)) } - await cacheTask?.value } /// Caches the current menu bar items if needed, ensuring that the /// control items are in the correct order. func cacheItemsIfNeeded() async { - guard !itemHasRecentlyMoved else { - logger.debug("Skipping menu bar item cache as an item was recently moved") + guard !latestMoveOperationStarted(within: .seconds(1)) else { + logger.debug("Skipping menu bar item cache due to recent item movement") return } @@ -297,7 +325,6 @@ extension MenuBarItemManager { cachedItemWindowIDs == itemWindowIDs, itemCache.managedItems.allSatisfy({ $0.sourcePID != nil }) { - logger.debug("Skipping menu bar item cache as item windows have not changed") return } @@ -305,118 +332,9 @@ extension MenuBarItemManager { } } -// MARK: - Menu Bar Item Events - - -extension MenuBarItemManager { - /// An error that can occur during menu bar item event operations. - struct EventError: Error, CustomStringConvertible, LocalizedError { - /// Error codes within the domain of menu bar item event errors. - enum ErrorCode: Int, CustomStringConvertible { - /// An operation could not be completed. - case couldNotComplete - /// The creation of a menu bar item event failed. - case eventCreationFailure - /// The shared app state is invalid or could not be found. - case invalidAppState - /// An event source could not be created or is otherwise invalid. - case invalidEventSource - /// The location of the mouse cursor is invalid or could not be found. - case invalidCursorLocation - /// A menu bar item is invalid. - case invalidItem - /// A menu bar item cannot be moved. - case notMovable - /// A menu bar item event operation timed out. - case eventOperationTimeout - /// A menu bar item bounds check timed out. - case boundsCheckTimeout - /// An operation timed out. - case otherTimeout - - /// Description of the code for debugging purposes. - var description: String { - switch self { - case .couldNotComplete: "couldNotComplete" - case .eventCreationFailure: "eventCreationFailure" - case .invalidAppState: "invalidAppState" - case .invalidEventSource: "invalidEventSource" - case .invalidCursorLocation: "invalidCursorLocation" - case .invalidItem: "invalidItem" - case .notMovable: "notMovable" - case .eventOperationTimeout: "eventOperationTimeout" - case .boundsCheckTimeout: "boundsCheckTimeout" - case .otherTimeout: "otherTimeout" - } - } - - /// A string to use for logging purposes. - var logString: String { - "\(self) (rawValue: \(rawValue))" - } - } - - /// The error code of this error. - let code: ErrorCode - - /// The error's menu bar item. - let item: MenuBarItem - - /// The message associated with this error. - var message: String { - switch code { - case .couldNotComplete: - "Could not complete event operation for \"\(item.displayName)\"" - case .eventCreationFailure: - "Failed to create event for \"\(item.displayName)\"" - case .invalidAppState: - "Invalid app state for \"\(item.displayName)\"" - case .invalidEventSource: - "Invalid event source for \"\(item.displayName)\"" - case .invalidCursorLocation: - "Invalid cursor location for \"\(item.displayName)\"" - case .invalidItem: - "\"\(item.displayName)\" is invalid" - case .notMovable: - "\"\(item.displayName)\" is not movable" - case .eventOperationTimeout: - "Event operation timed out for \"\(item.displayName)\"" - case .boundsCheckTimeout: - "Bounds check timed out for \"\(item.displayName)\"" - case .otherTimeout: - "Operation timed out for \"\(item.displayName)\"" - } - } - - /// Description of the error for debugging purposes. - var description: String { - var parameters = [String]() - parameters.append("code: \(code.logString)") - parameters.append("item: \(item.logString)") - return "\(Self.self)(\(parameters.joined(separator: ", ")))" - } - - /// Description of the error for display purposes. - var errorDescription: String? { - message - } - - /// Suggestion for recovery from the error. - var recoverySuggestion: String? { - "Please try again. If the error persists, please file a bug report." - } - } -} - // MARK: - Async Waiters extension MenuBarItemManager { - /// Use this to pad out event operations, if needed. - /// - /// - Parameter duration: The duration to wait. Defaults to 20ms. - private func eventSleep(for duration: Duration = .milliseconds(20)) async { - try? await Task.sleep(for: duration) - } - /// Waits asynchronously for the given operation to complete. /// /// - Parameters: @@ -467,7 +385,7 @@ extension MenuBarItemManager { await withCheckedContinuation { continuation in let mask: NSEvent.EventTypeMask = [.leftMouseUp, .rightMouseUp, .otherMouseUp] cancellable = RunLoopLocalEventMonitor.publisher(for: mask, mode: .eventTracking) - .merge(with: UniversalEventMonitor.publisher(for: mask)) + .merge(with: EventMonitor.publish(events: mask, scope: .universal)) .removeDuplicates() .combineLatest(Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()) .sink { _ in @@ -494,7 +412,7 @@ extension MenuBarItemManager { await withCheckedContinuation { continuation in let mask: NSEvent.EventTypeMask = .flagsChanged cancellable = RunLoopLocalEventMonitor.publisher(for: mask, mode: .eventTracking) - .merge(with: UniversalEventMonitor.publisher(for: mask)) + .merge(with: EventMonitor.publish(events: mask, scope: .universal)) .removeDuplicates() .combineLatest(Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()) .sink { _ in @@ -509,96 +427,166 @@ extension MenuBarItemManager { } } -// MARK: - Move Items +// MARK: - Event Helpers extension MenuBarItemManager { - /// Destinations for menu bar item move operations. - enum MoveDestination { - /// Specifies a destination left of the given target item. - case leftOfItem(MenuBarItem) - /// Specifies a destination right of the given target item. - case rightOfItem(MenuBarItem) - - /// A string to use for logging purposes. - var logString: String { - switch self { - case .leftOfItem(let item): "left of \(item.logString)" - case .rightOfItem(let item): "right of \(item.logString)" - } + /// An error that can occur during menu bar item event operations. + struct EventError: Error, CustomStringConvertible, LocalizedError { + /// Error codes within the domain of menu bar item event errors. + enum ErrorCode: Int, CustomStringConvertible { + /// A menu bar item bounds check timed out. + case boundsCheckTimeout + /// An operation could not be completed. + case couldNotComplete + /// The creation of a menu bar item event failed. + case eventCreationFailure + /// A menu bar item event operation timed out. + case eventOperationTimeout + /// The shared app state is invalid or could not be found. + case invalidAppState + /// An event source could not be created or is otherwise invalid. + case invalidEventSource + /// A menu bar item is invalid. + case invalidItem + /// A menu bar item's current bounds could not be found. + case missingCurrentBounds + /// The location of the mouse could not be found. + case missingMouseLocation + /// A menu bar item cannot be moved. + case notMovable + /// An operation timed out. + case otherTimeout + + /// Description of the code for debugging purposes. + var description: String { + switch self { + case .boundsCheckTimeout: "boundsCheckTimeout" + case .couldNotComplete: "couldNotComplete" + case .eventCreationFailure: "eventCreationFailure" + case .eventOperationTimeout: "eventOperationTimeout" + case .invalidAppState: "invalidAppState" + case .invalidEventSource: "invalidEventSource" + case .invalidItem: "invalidItem" + case .missingCurrentBounds: "missingCurrentBounds" + case .missingMouseLocation: "missingMouseLocation" + case .notMovable: "notMovable" + case .otherTimeout: "otherTimeout" + } + } + + /// A string to use for logging purposes. + var logString: String { + "\(self) (rawValue: \(rawValue))" + } } - } - /// Returns the current bounds for the given item. - /// - /// - Parameter item: The item to return the current bounds for. - private func getCurrentBounds(for item: MenuBarItem) -> CGRect? { - guard let bounds = Bridging.getWindowBounds(for: item.windowID) else { - logger.error("Couldn't get current bounds for \(item.logString, privacy: .public)") - return nil + /// The error code of this error. + let code: ErrorCode + + /// The error's menu bar item. + let item: MenuBarItem + + /// The message associated with this error. + var message: String { + switch code { + case .boundsCheckTimeout: + #"Bounds check timed out for "\#(item.displayName)""# + case .couldNotComplete: + #"Could not complete event operation for "\#(item.displayName)""# + case .eventCreationFailure: + #"Failed to create event for "\#(item.displayName)""# + case .eventOperationTimeout: + #"Event operation timed out for "\#(item.displayName)""# + case .invalidAppState: + #"Invalid app state for "\#(item.displayName)""# + case .invalidEventSource: + #"Invalid event source for "\#(item.displayName)""# + case .invalidItem: + #""\#(item.displayName)" is invalid"# + case .missingCurrentBounds: + #"Missing current bounds for "\#(item.displayName)""# + case .missingMouseLocation: + #"Missing mouse location for "\#(item.displayName)""# + case .notMovable: + #""\#(item.displayName)" is not movable"# + case .otherTimeout: + #"Operation timed out for "\#(item.displayName)""# + } + } + + /// Description of the error for debugging purposes. + var description: String { + var parameters = [String]() + parameters.append("code: \(code.logString)") + parameters.append("item: \(item.logString)") + return "\(Self.self)(\(parameters.joined(separator: ", ")))" + } + + /// Description of the error for display purposes. + var errorDescription: String? { + message + } + + /// Suggestion for recovery from the error. + var recoverySuggestion: String? { + "Please try again. If the error persists, please file a bug report." } - return bounds } - /// Returns the end point for moving an item to the given destination. + /// Waits for the given duration. Use this to pad out event + /// operations when needed. /// - /// - Parameter destination: The destination to return the end point for. - private func getEndPoint(for destination: MoveDestination) throws -> CGPoint { - switch destination { - case .leftOfItem(let targetItem): - guard let currentBounds = getCurrentBounds(for: targetItem) else { - throw EventError(code: .invalidItem, item: targetItem) - } - return CGPoint(x: currentBounds.minX, y: currentBounds.midY) - case .rightOfItem(let targetItem): - guard let currentBounds = getCurrentBounds(for: targetItem) else { - throw EventError(code: .invalidItem, item: targetItem) - } - return CGPoint(x: currentBounds.maxX, y: currentBounds.midY) + /// - Parameter duration: The duration to wait. + private func eventSleep(for duration: Duration = .milliseconds(20)) async { + try? await Task.sleep(for: duration) + } + + /// Returns the current bounds for the given item. + private func getCurrentBounds(for item: MenuBarItem) throws -> CGRect { + guard let bounds = MenuBarItem.currentBounds(for: item) else { + throw EventError(code: .missingCurrentBounds, item: item) } + return bounds } - /// Returns the fallback point for returning the given item to its original - /// position if a move fails. - /// - /// - Parameter item: The item to return the fallback point for. - private func getFallbackPoint(for item: MenuBarItem) throws -> CGPoint { - guard let currentBounds = getCurrentBounds(for: item) else { - throw EventError(code: .invalidItem, item: item) + /// Returns the event source for moving a menu bar item. + private func getEventSource(item: MenuBarItem) throws -> CGEventSource { + enum Context { + static var source: CGEventSource? + } + if let source = Context.source { + return source + } + guard let source = CGEventSource(stateID: .hidSystemState) else { + throw EventError(code: .invalidEventSource, item: item) } - return CGPoint(x: currentBounds.midX, y: currentBounds.midY) + Context.source = source + return source } - /// Returns the target item for the given destination. - /// - /// - Parameter destination: The destination to get the target item from. - private func getTargetItem(for destination: MoveDestination) -> MenuBarItem { - switch destination { - case .leftOfItem(let targetItem), .rightOfItem(let targetItem): targetItem + /// Returns the current mouse location. + private func getMouseLocation(item: MenuBarItem) throws -> CGPoint { + guard let location = MouseCursor.locationCoreGraphics else { + throw EventError(code: .missingMouseLocation, item: item) } + return location } - /// Returns a Boolean value that indicates whether the given item is in the - /// correct position for the given destination. - /// - /// - Parameters: - /// - item: The item to check the position of. - /// - destination: The destination to compare the item's position against. - private func itemHasCorrectPosition(item: MenuBarItem, for destination: MoveDestination) throws -> Bool { - guard let currentBounds = getCurrentBounds(for: item) else { - throw EventError(code: .invalidItem, item: item) + /// Permits all events for an event source during the given suppression + /// states, suppressing local events for the given interval. + private func permitAllEvents( + for stateID: CGEventSourceStateID, + during states: [CGEventSuppressionState], + suppressionInterval: TimeInterval, + item: MenuBarItem + ) throws { + guard let source = CGEventSource(stateID: stateID) else { + throw EventError(code: .invalidEventSource, item: item) } - switch destination { - case .leftOfItem(let targetItem): - guard let currentTargetBounds = getCurrentBounds(for: targetItem) else { - throw EventError(code: .invalidItem, item: targetItem) - } - return currentBounds.maxX == currentTargetBounds.minX - case .rightOfItem(let targetItem): - guard let currentTargetBounds = getCurrentBounds(for: targetItem) else { - throw EventError(code: .invalidItem, item: targetItem) - } - return currentBounds.minX == currentTargetBounds.maxX + for state in states { + source.setLocalEventsFilterDuringSuppressionState(.permitAllEvents, state: state) } + source.localEventsSuppressionInterval = suppressionInterval } /// Returns a Boolean value that indicates whether the given events have the @@ -625,12 +613,7 @@ extension MenuBarItemManager { /// - event: The event to post. /// - location: The event tap location to post the event to. private nonisolated func postEvent(_ event: CGEvent, to location: EventTap.Location) { - logger.debug( - """ - Posting \(event.type.logString, privacy: .public) \ - to \(location.logString, privacy: .public) - """ - ) + logger.debug("Posting \(event.type.logString, privacy: .public) to \(location.logString, privacy: .public)") switch location { case .hidEventTap: event.post(tap: .cghidEventTap) case .sessionEventTap: event.post(tap: .cgSessionEventTap) @@ -639,441 +622,388 @@ extension MenuBarItemManager { } } - /// Posts an event to the given event tap location and waits until it is - /// received before returning. + /// Posts an event to the given event tap location and waits + /// until it is received before returning. /// /// - Parameters: /// - event: The event to post. /// - location: The event tap location to post the event to. - /// - item: The menu bar item that the event affects. - private func postEventAndWaitToReceive( + /// - item: The menu bar item that the event targets. + /// - timeout: The duration to wait before throwing an error. + private func postEventRoundtrip( _ event: CGEvent, to location: EventTap.Location, - item: MenuBarItem + item: MenuBarItem, + timeout: Duration ) async throws { - return try await withCheckedThrowingContinuation { continuation in - let eventTap = EventTap( - options: .listenOnly, - location: location, - place: .tailAppendEventTap, - types: [event.type] - ) { [weak self] proxy, type, rEvent in - guard let self else { - proxy.disable() - return nil - } + var eventTap: EventTap? + + let timeoutTask = Task(timeout: timeout) { + try await withCheckedThrowingContinuation { continuation in + eventTap = EventTap( + options: .listenOnly, + location: location, + placement: .tailAppendEventTap, + type: event.type, + callbackQueue: scrombleQueue + ) { [weak self] tap, rEvent in + guard let self else { + tap.disable() + return rEvent + } - // Reenable the tap if disabled by the system. - if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { - proxy.enable() - return nil - } + guard eventsMatch([rEvent, event], by: CGEventField.menuBarItemEventFields) else { + return rEvent + } - // Verify that the received event was the sent event. - guard eventsMatch([rEvent, event], by: CGEventField.menuBarItemEventFields) else { - return nil - } + tap.disable() + continuation.resume() - // Ensure the tap is enabled, preventing multiple calls to resume(). - guard proxy.isEnabled else { - logger.debug( - """ - Event tap \"\(proxy.label, privacy: .public)\" is disabled \ - (item: \(item.logString, privacy: .public)) - """ - ) - return nil + return rEvent } - logger.debug( - """ - Received \(type.logString, privacy: .public) \ - at \(location.logString, privacy: .public) \ - (item: \(item.logString, privacy: .public)) - """ - ) - - // Disable the tap and resume the continuation. - proxy.disable() - continuation.resume() - - return nil - } + eventTap?.enable() - eventTap.enable(timeout: .milliseconds(100)) { [logger] in - logger.error( - """ - Event tap \"\(eventTap.label, privacy: .public)\" timed out \ - (item: \(item.logString, privacy: .public)) - """ - ) - eventTap.disable() - continuation.resume(throwing: EventError(code: .eventOperationTimeout, item: item)) + postEvent(event, to: location) } + } - // Post the event to the location. - postEvent(event, to: location) + do { + try await timeoutTask.value + } catch is TaskTimeoutError { + throw EventError(code: .eventOperationTimeout, item: item) + } catch { + throw EventError(code: .couldNotComplete, item: item) } } - /// Does a lot of weird magic to make a menu bar item receive an event. + /// Does a lot of weird magic to make a menu bar item receive + /// an event. /// /// - Parameters: - /// - event: The event to send. - /// - firstLocation: The first location to send the event to. - /// - secondLocation: The second location to send the event to. - /// - item: The menu bar item that the event affects. + /// - event: The event to post. + /// - firstLocation: The first location to post the event. + /// - secondLocation: The second location to post the event. + /// - item: The menu bar item that the event targets. + /// - timeout: The duration to wait before throwing an error. private func scrombleEvent( _ event: CGEvent, from firstLocation: EventTap.Location, to secondLocation: EventTap.Location, - item: MenuBarItem + item: MenuBarItem, + timeout: Duration ) async throws { - // Create a null event and assign it unique user data. - guard let nullEvent = CGEvent(source: nil) else { + guard let nullEvent = CGEvent.uniqueNullEvent() else { throw EventError(code: .eventCreationFailure, item: item) } - let nullUserData = Int64(truncatingIfNeeded: Int(bitPattern: ObjectIdentifier(nullEvent))) - nullEvent.setIntegerValueField(.eventSourceUserData, value: nullUserData) - - return try await withCheckedThrowingContinuation { continuation in - // Create an event tap for the null event at the first location. - // This tap throws away all events it receives. - let eventTap1 = EventTap( - label: "EventTap 1", - options: .defaultTap, - location: firstLocation, - place: .tailAppendEventTap, - types: [nullEvent.type] - ) { [weak self] proxy, type, rEvent in - guard let self else { - proxy.disable() - return nil - } - // Reenable the tap if disabled by the system. - if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { - proxy.enable() - return nil - } + var eventTap1: EventTap? + var eventTap2: EventTap? - // Verify that this is the null event. - guard rEvent.getIntegerValueField(.eventSourceUserData) == nullUserData else { - return nil - } + let timeoutTask = Task(timeout: timeout) { + await withCheckedContinuation { continuation in + // Create an event tap for the null event at the first location. + // Once this tap receives the event, it posts the real event to + // the second location and discards the null event. + eventTap1 = EventTap( + label: "EventTap 1", + options: .defaultTap, + location: firstLocation, + placement: .headInsertEventTap, + type: nullEvent.type, + callbackQueue: scrombleQueue + ) { [weak self] tap, rEvent in + guard let self else { + tap.disable() + return rEvent + } - // Disable the tap and post the real event to the second location. - proxy.disable() - postEvent(event, to: secondLocation) + guard eventsMatch([rEvent, nullEvent], by: [.eventSourceUserData]) else { + return rEvent + } - return nil - } + tap.disable() + postEvent(event, to: secondLocation) - // Create an event tap for the real event at the second location. - // This tap can listen for events, but cannot alter or discard them. - let eventTap2 = EventTap( - label: "EventTap 2", - options: .listenOnly, - location: secondLocation, - place: .tailAppendEventTap, - types: [event.type] - ) { [weak self] proxy, type, rEvent in - guard let self else { - proxy.disable() return nil } - // Reenable the tap if disabled by the system. - if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { - proxy.enable() - return nil - } + // Create an event tap for the real event at the second location. + // Once this tap receives the event, it resumes the continuation. + eventTap2 = EventTap( + label: "EventTap 2", + options: .listenOnly, + location: secondLocation, + placement: .tailAppendEventTap, + type: event.type, + callbackQueue: scrombleQueue + ) { [weak self] tap, rEvent in + guard let self else { + tap.disable() + return rEvent + } - // Verify that the received event was the sent event. - guard eventsMatch([rEvent, event], by: CGEventField.menuBarItemEventFields) else { - return nil - } + guard eventsMatch([rEvent, event], by: CGEventField.menuBarItemEventFields) else { + return rEvent + } - // Ensure the tap is enabled, preventing multiple calls to resume(). - guard proxy.isEnabled else { - logger.debug( - """ - Event tap \"\(proxy.label, privacy: .public)\" is disabled \ - (item: \(item.logString, privacy: .public)) - """ - ) - return nil + tap.disable() + continuation.resume() + + return rEvent } - // Disable the tap, post the event to the first location, and resume - // the continuation. - proxy.disable() - postEvent(event, to: firstLocation) - continuation.resume() + eventTap1?.enable() + eventTap2?.enable() - return nil + // Post the null event to the first location. + postEvent(nullEvent, to: firstLocation) } + } - // Enable both taps, with a timeout on the second tap. - eventTap1.enable() - eventTap2.enable(timeout: .milliseconds(100)) { [logger] in - logger.error( - """ - Event tap \"\(eventTap2.label, privacy: .public)\" timed out \ - (item: \(item.logString, privacy: .public)) - """ - ) - eventTap1.disable() - eventTap2.disable() - continuation.resume(throwing: EventError(code: .eventOperationTimeout, item: item)) + do { + try await timeoutTask.value + } catch is TaskTimeoutError { + throw EventError(code: .eventOperationTimeout, item: item) + } catch { + throw EventError(code: .couldNotComplete, item: item) + } + } +} + +// MARK: - Move Operations + +extension MenuBarItemManager { + /// Destinations for menu bar item move operations. + enum MoveDestination { + /// The destination to the left of the given target item. + case leftOfItem(MenuBarItem) + /// The destination to the right of the given target item. + case rightOfItem(MenuBarItem) + + /// The destination's target item. + var targetItem: MenuBarItem { + switch self { + case .leftOfItem(let item), .rightOfItem(let item): item } + } - // Post the null event to the first location. - postEvent(nullEvent, to: firstLocation) + /// A string to use for logging purposes. + var logString: String { + switch self { + case .leftOfItem(let item): "left of \(item.logString)" + case .rightOfItem(let item): "right of \(item.logString)" + } } } - /// Does a lot of weird magic to make a menu bar item receive an event, then - /// waits for the bounds of the given menu bar item to change before returning. - /// + /// Returns the end location for moving an item to the given destination. + private func getEndLocation(for destination: MoveDestination) throws -> CGPoint { + let bounds = try getCurrentBounds(for: destination.targetItem) + return switch destination { + case .leftOfItem: CGPoint(x: bounds.minX, y: bounds.midY) + case .rightOfItem: CGPoint(x: bounds.maxX, y: bounds.midY) + } + } + + /// Returns a Boolean value that indicates whether the given item is + /// in the correct position for the given destination. + private func itemHasCorrectPosition(item: MenuBarItem, for destination: MoveDestination) throws -> Bool { + let itemBounds = try getCurrentBounds(for: item) + let targetBounds = try getCurrentBounds(for: destination.targetItem) + return switch destination { + case .leftOfItem: itemBounds.maxX == targetBounds.minX + case .rightOfItem: itemBounds.minX == targetBounds.maxX + } + } + + /// Actions to perform after an event is received. + enum ScrombleEventDeferredAction { + struct BoundsChangeOptions: OptionSet { + let rawValue: Int + + static let ignoreErrors = BoundsChangeOptions(rawValue: 1 << 0) + static let sleepOnError = BoundsChangeOptions(rawValue: 1 << 1) + } + + case waitForBoundsChange(options: BoundsChangeOptions = []) + + func createTask( + with item: MenuBarItem, + timeout: Duration, + manager: MenuBarItemManager + ) async -> () async throws -> Void { + switch self { + case .waitForBoundsChange(let options): + let boundsResult = await Task { + try await manager.getCurrentBounds(for: item) + }.result + return { + do { + let bounds = try boundsResult.get() + try await manager.waitForBoundsChange( + of: item, + initialBounds: bounds, + timeout: timeout + ) + } catch { + manager.logger.warning("Bounds check failed with error: \(error, privacy: .public)") + if options.contains(.sleepOnError) { + await manager.eventSleep(for: .milliseconds(100)) + } + if options.contains(.ignoreErrors) { + return + } + throw error + } + } + } + } + } + + /// Does a lot of weird magic to make a menu bar item receive + /// an event, then performs the given action. + /// /// - Parameters: - /// - event: The event to send. - /// - firstLocation: The first location to send the event to. - /// - secondLocation: The second location to send the event to. - /// - item: The item whose bounds should be observed. + /// - event: The event to post. + /// - firstLocation: The first location to post the event. + /// - secondLocation: The second location to post the event. + /// - item: The menu bar item that the event targets. + /// - timeout: The duration to wait before throwing an error. + /// - deferredAction: An action to perform after the event is + /// received. private func scrombleEvent( _ event: CGEvent, from firstLocation: EventTap.Location, to secondLocation: EventTap.Location, - waitingForBoundsChangeOf item: MenuBarItem + item: MenuBarItem, + timeout: Duration, + deferredAction: ScrombleEventDeferredAction ) async throws { - guard let currentBounds = getCurrentBounds(for: item) else { - try await scrombleEvent(event, from: firstLocation, to: secondLocation, item: item) - logger.warning( - """ - Couldn't get bounds for \(item.logString, privacy: .public), \ - so using fixed delay - """ - ) - // This will be slow, but subsequent events will have a better chance of succeeding. - try await Task.sleep(for: .milliseconds(100)) - return - } - try await scrombleEvent(event, from: firstLocation, to: secondLocation, item: item) - try await waitForBoundsChange(of: item, initialBounds: currentBounds, timeout: .milliseconds(100)) + let deferredTask = await deferredAction.createTask(with: item, timeout: timeout, manager: self) + try await scrombleEvent(event, from: firstLocation, to: secondLocation, item: item, timeout: timeout) + try await deferredTask() } /// Waits for a menu bar item's bounds to change from an initial value. /// /// - Parameters: - /// - item: The item whose bounds should be observed. - /// - initialBounds: An initial value to compare the item's bounds against. - /// - timeout: The amount of time to wait before throwing a timeout error. - private func waitForBoundsChange(of item: MenuBarItem, initialBounds: CGRect, timeout: Duration) async throws { - struct BoundsCheckCancellationError: Error { } - + /// - item: The menu bar item to check for bounds changes. + /// - initialBounds: An initial value to determine whether the item's + /// bounds have changed. + /// - timeout: The duration to wait before throwing an error. + private func waitForBoundsChange( + of item: MenuBarItem, + initialBounds: CGRect, + timeout: Duration + ) async throws { let boundsCheckTask = Task(timeout: timeout) { while true { try Task.checkCancellation() - guard let currentBounds = getCurrentBounds(for: item) else { - throw BoundsCheckCancellationError() - } - if currentBounds != initialBounds { - logger.debug( - """ - Bounds for \(item.logString, privacy: .public) changed \ - to \(NSStringFromRect(currentBounds), privacy: .public) - """ - ) - return + let currentBounds = try getCurrentBounds(for: item) + guard currentBounds != initialBounds else { + continue } + logger.debug( + """ + Bounds for \(item.logString, privacy: .public) changed \ + to \(NSStringFromRect(currentBounds), privacy: .public) + """ + ) + return } } do { try await boundsCheckTask.value - } catch is BoundsCheckCancellationError { - logger.warning( - """ - Bounds check for \(item.logString, privacy: .public) \ - was cancelled, so using fixed delay - """ - ) - // This will be slow, but subsequent events will have a better chance of succeeding. - try await Task.sleep(for: .milliseconds(100)) + } catch let error as EventError { + throw error } catch is TaskTimeoutError { throw EventError(code: .boundsCheckTimeout, item: item) + } catch { + throw EventError(code: .couldNotComplete, item: item) } - } - - /// Permits all events for an event source during the given suppression states, - /// suppressing local events for the given interval. - private func permitAllEvents( - for stateID: CGEventSourceStateID, - during states: [CGEventSuppressionState], - suppressionInterval: TimeInterval, - item: MenuBarItem - ) throws { - guard let source = CGEventSource(stateID: stateID) else { - throw EventError(code: .invalidEventSource, item: item) - } - for state in states { - source.setLocalEventsFilterDuringSuppressionState(.permitAllEvents, state: state) - } - source.localEventsSuppressionInterval = suppressionInterval - } - - /// Tries to wake up the given item if it is not responding to events. - private func wakeUpItem(_ item: MenuBarItem) async throws { - logger.debug("Attempting to wake up \(item.logString, privacy: .public)") - - guard let source = CGEventSource(stateID: .hidSystemState) else { - throw EventError(code: .invalidEventSource, item: item) - } - guard let currentBounds = getCurrentBounds(for: item) else { - throw EventError(code: .invalidItem, item: item) - } - - let wakePoint = CGPoint(x: currentBounds.midX, y: currentBounds.midY) - - guard - let mouseDownEvent = CGEvent.menuBarItemEvent( - type: .move(.leftMouseDown), - location: wakePoint, - item: item, - pid: item.ownerPID, - source: source - ), - let mouseUpEvent = CGEvent.menuBarItemEvent( - type: .move(.leftMouseUp), - location: wakePoint, - item: item, - pid: item.ownerPID, - source: source - ) - else { - throw EventError(code: .eventCreationFailure, item: item) - } - - let eventTask = Task { - try await scrombleEvent( - mouseDownEvent, - from: .pid(item.ownerPID), - to: .sessionEventTap, - item: item - ) - try await scrombleEvent( - mouseUpEvent, - from: .pid(item.ownerPID), - to: .sessionEventTap, - item: item - ) - } - let result = await eventTask.result - await eventSleep() - try result.get() - } - - /// Moves a menu bar item to the given destination, without restoring the mouse - /// pointer to its initial location. - /// - /// - Parameters: - /// - item: A menu bar item to move. - /// - destination: A destination to move the menu bar item. - private func moveItemWithoutRestoringMouseLocation(_ item: MenuBarItem, to destination: MoveDestination) async throws { - guard item.isMovable else { - throw EventError(code: .notMovable, item: item) - } - guard let source = CGEventSource(stateID: .hidSystemState) else { - throw EventError(code: .invalidEventSource, item: item) - } + } - let startPoint = CGPoint(x: 20_000, y: 20_000) - let endPoint = try getEndPoint(for: destination) - let fallbackPoint = try getFallbackPoint(for: item) - let targetItem = getTargetItem(for: destination) + /// Attempts to move a menu bar item to the given destination. + /// + /// - Parameters: + /// - item: The menu bar item to move. + /// - destination: The destination to move the menu bar item. + /// - source: The event source used to create the events that + /// move the item. + /// - timeout: The duration to wait before throwing an error. + private func performMoveOperation( + item: MenuBarItem, + destination: MoveDestination, + source: CGEventSource, + timeout: Duration + ) async throws { + let pid = item.sourcePID ?? item.ownerPID guard - let mouseDownEvent = CGEvent.menuBarItemEvent( + let moveEvent1 = CGEvent.menuBarItemEvent( + source: source, type: .move(.leftMouseDown), - location: startPoint, + location: CGPoint(x: 20_000, y: 20_000), item: item, - pid: item.ownerPID, - source: source + pid: pid ), - let mouseUpEvent = CGEvent.menuBarItemEvent( + let moveEvent2 = CGEvent.menuBarItemEvent( + source: source, type: .move(.leftMouseUp), - location: endPoint, - item: targetItem, - pid: item.ownerPID, - source: source + location: try getEndLocation(for: destination), + item: destination.targetItem, + pid: pid ), let fallbackEvent = CGEvent.menuBarItemEvent( + source: source, type: .move(.leftMouseUp), - location: fallbackPoint, + location: try getCurrentBounds(for: item).center, item: item, - pid: item.ownerPID, - source: source + pid: pid ) else { throw EventError(code: .eventCreationFailure, item: item) } - try permitAllEvents( - for: .combinedSessionState, - during: [ - .eventSuppressionStateRemoteMouseDrag, - .eventSuppressionStateSuppressionInterval, - ], - suppressionInterval: 0, - item: item - ) - - lastItemMoveStartDate = .now + latestMoveOperationTimestamp = .now do { try await scrombleEvent( - mouseDownEvent, - from: .pid(item.ownerPID), + moveEvent1, + from: .pid(pid), to: .sessionEventTap, - waitingForBoundsChangeOf: item + item: item, + timeout: timeout, + deferredAction: .waitForBoundsChange(options: [.ignoreErrors, .sleepOnError]) ) try await scrombleEvent( - mouseUpEvent, - from: .pid(item.ownerPID), + moveEvent2, + from: .pid(pid), to: .sessionEventTap, - waitingForBoundsChangeOf: item + item: item, + timeout: timeout, + deferredAction: .waitForBoundsChange(options: .sleepOnError) ) } catch { - do { - let eventTask = Task { - logger.debug( - """ - Posting fallback event for moving \ - \(item.logString, privacy: .public) - """ - ) - try await postEventAndWaitToReceive( - fallbackEvent, - to: .sessionEventTap, - item: item - ) - } - - let result = await eventTask.result - await eventSleep() + logger.warning("Move events failed. Posting fallback.") - // Catch this for logging purposes only -- we still want - // to throw the existing error if the fallback fails. - try result.get() - } catch { - logger.error( - """ - Failed to post fallback event for moving \ - \(item.logString, privacy: .public) - """ + // Pad with eventSleep calls to reduce the chance that + // events are still being processed somewhere. + await eventSleep() + do { + // Catch this for logging purposes only. We want to + // propagate the original error. + try await postEventRoundtrip( + fallbackEvent, + to: .sessionEventTap, + item: item, + timeout: timeout ) + } catch { + logger.error("Fallback event failed with error: \(error, privacy: .public)") } - + await eventSleep() throw error } } @@ -1081,16 +1011,23 @@ extension MenuBarItemManager { /// Moves a menu bar item to the given destination. /// /// - Parameters: - /// - item: A menu bar item to move. - /// - destination: A destination to move the menu bar item. - func move(item: MenuBarItem, to destination: MoveDestination) async throws { - if try itemHasCorrectPosition(item: item, for: destination) { - logger.debug( - """ - \(item.logString, privacy: .public) is already in \ - the correct position - """ - ) + /// - item: The menu bar item to move. + /// - destination: The destination to move the menu bar item. + /// - timeout: The duration to wait before throwing an error. + func move( + item: MenuBarItem, + to destination: MoveDestination, + timeout: Duration = .milliseconds(100) + ) async throws { + guard item.isMovable else { + throw EventError(code: .notMovable, item: item) + } + guard let appState else { + throw EventError(code: .invalidAppState, item: item) + } + + guard try !itemHasCorrectPosition(item: item, for: destination) else { + logger.debug("\(item.logString, privacy: .public) already has correct position") return } @@ -1111,22 +1048,18 @@ extension MenuBarItemManager { throw EventError(code: .couldNotComplete, item: item) } - logger.info( - """ - Moving \(item.logString, privacy: .public) to \ - \(destination.logString, privacy: .public) - """ - ) + let source = try getEventSource(item: item) + let mouseLocation = try getMouseLocation(item: item) - guard let appState else { - throw EventError(code: .invalidAppState, item: item) - } - guard let cursorLocation = MouseCursor.locationCoreGraphics else { - throw EventError(code: .invalidCursorLocation, item: item) - } - guard let initialBounds = getCurrentBounds(for: item) else { - throw EventError(code: .invalidItem, item: item) - } + try permitAllEvents( + for: .combinedSessionState, + during: [ + .eventSuppressionStateRemoteMouseDrag, + .eventSuppressionStateSuppressionInterval, + ], + suppressionInterval: 0, + item: item + ) appState.eventManager.stopAll() defer { @@ -1136,59 +1069,67 @@ extension MenuBarItemManager { MouseCursor.hide() defer { - MouseCursor.warp(to: cursorLocation) + MouseCursor.warp(to: mouseLocation) MouseCursor.show() } - // Item movement can occasionally fail. Retry up to a total of 5 attempts, - // throwing the last attempt's error if it fails. - for n in 1...5 { - do { - try await moveItemWithoutRestoringMouseLocation(item, to: destination) - guard let newBounds = getCurrentBounds(for: item) else { - throw EventError(code: .invalidItem, item: item) - } - if newBounds != initialBounds { - logger.info("Successfully moved item") - break - } else { - throw EventError(code: .couldNotComplete, item: item) + logger.debug( + """ + Moving \(item.logString, privacy: .public) to \ + \(destination.logString, privacy: .public) + """ + ) + + let moveTask = Task { + // Move operations can occasionally fail. Retry up to a total + // of 5 attempts, throwing the last attempt's error if it fails. + for n in 1...5 { + try Task.checkCancellation() + do { + return try await performMoveOperation( + item: item, + destination: destination, + source: source, + timeout: timeout + ) + } catch where n < 5 { + logger.warning( + """ + Move attempt \(n, privacy: .public) failed with error: \ + \(error, privacy: .public) + """ + ) } - } catch where n < 5 { - logger.warning( - """ - Item movement attempt \(n, privacy: .public) \ - failed with error: \(error, privacy: .public) - """ - ) - try await wakeUpItem(item) - logger.info("Retrying move of item") - continue } } - } - /// Moves a menu bar item to the given destination and waits until the move - /// completes before returning. - /// - /// - Parameters: - /// - item: A menu bar item to move. - /// - destination: A destination to move the menu bar item. - /// - timeout: Amount of time to wait before throwing an error. - func slowMove(item: MenuBarItem, to destination: MoveDestination, timeout: Duration = .seconds(1)) async throws { do { - try await move(item: item, to: destination) - } catch { - await eventSleep() + try await moveTask.value + logger.debug("Successfully moved item") + } catch let error as EventError { throw error + } catch { + throw EventError(code: .couldNotComplete, item: item) } + } + + /// Moves a menu bar item to the given destination and waits until + /// the move is finished before returning. + /// + /// - Parameters: + /// - item: The menu bar item to move. + /// - destination: The destination to move the menu bar item. + /// - timeout: The duration to wait before throwing an error. + func slowMove( + item: MenuBarItem, + to destination: MoveDestination, + timeout: Duration = .seconds(1) + ) async throws { + try await move(item: item, to: destination, timeout: .milliseconds(100)) let waitTask = Task(timeout: timeout) { - while true { + while try !itemHasCorrectPosition(item: item, for: destination) { try Task.checkCancellation() - if try itemHasCorrectPosition(item: item, for: destination) { - return - } } } @@ -1200,45 +1141,53 @@ extension MenuBarItemManager { } } -// MARK: - Click Items +// MARK: - Click Operations extension MenuBarItemManager { - /// Clicks the given menu bar item with the given mouse button. - func click(item: MenuBarItem, with mouseButton: CGMouseButton) async throws { - guard let source = CGEventSource(stateID: .hidSystemState) else { - throw EventError(code: .invalidEventSource, item: item) - } - guard let cursorLocation = MouseCursor.locationCoreGraphics else { - throw EventError(code: .invalidCursorLocation, item: item) - } - guard let currentBounds = getCurrentBounds(for: item) else { - throw EventError(code: .invalidItem, item: item) + /// Clicks the given menu bar item. + /// + /// - Parameters: + /// - item: The menu bar item to click. + /// - mouseButton: The mouse button to click the item with. + /// - timeout: The duration to wait before throwing an error. + func click( + item: MenuBarItem, + with mouseButton: CGMouseButton, + timeout: Duration = .milliseconds(100) + ) async throws { + guard let appState else { + throw EventError(code: .invalidAppState, item: item) } + let source = try getEventSource(item: item) + let mouseLocation = try getMouseLocation(item: item) + let currentBounds = try getCurrentBounds(for: item) + let buttonStates = mouseButton.buttonStates - let clickPoint = currentBounds.center + let clickLocation = currentBounds.center + let pid = item.sourcePID ?? item.ownerPID guard - let mouseDownEvent = CGEvent.menuBarItemEvent( + let clickEvent1 = CGEvent.menuBarItemEvent( + source: source, type: .click(buttonStates.down), - location: clickPoint, + location: clickLocation, item: item, - pid: item.ownerPID, - source: source + pid: pid ), - let mouseUpEvent = CGEvent.menuBarItemEvent( + let clickEvent2 = CGEvent.menuBarItemEvent( + source: source, type: .click(buttonStates.up), - location: clickPoint, + location: clickLocation, item: item, - pid: item.ownerPID, - source: source + pid: pid ), let fallbackEvent = CGEvent.menuBarItemEvent( + source: source, type: .click(buttonStates.up), - location: clickPoint, + location: clickLocation, item: item, - pid: item.ownerPID, - source: source + pid: pid ) else { throw EventError(code: .eventCreationFailure, item: item) @@ -1254,64 +1203,104 @@ extension MenuBarItemManager { item: item ) + appState.eventManager.stopAll() + defer { + appState.eventManager.startAll() + } + MouseCursor.hide() defer { - MouseCursor.warp(to: cursorLocation) + MouseCursor.warp(to: mouseLocation) MouseCursor.show() } + logger.debug( + """ + Clicking \(item.logString, privacy: .public) with \ + \(mouseButton.logString, privacy: .public) + """ + ) + do { - logger.info( - """ - Clicking \(item.logString, privacy: .public) with \ - \(mouseButton.logString, privacy: .public) - """ + try await scrombleEvent( + clickEvent1, + from: .pid(pid), + to: .sessionEventTap, + item: item, + timeout: timeout ) - await eventSleep() - try await scrombleEvent(mouseDownEvent, from: .pid(item.ownerPID), to: .sessionEventTap, item: item) - await eventSleep() - try await scrombleEvent(mouseUpEvent, from: .pid(item.ownerPID), to: .sessionEventTap, item: item) - await eventSleep() + try await scrombleEvent( + clickEvent2, + from: .pid(pid), + to: .sessionEventTap, + item: item, + timeout: timeout + ) + logger.debug("Successfully clicked item") } catch { - do { - let eventTask = Task { - logger.debug( - """ - Posting fallback event for clicking \ - \(item.logString, privacy: .public) - """ - ) - try await postEventAndWaitToReceive( - fallbackEvent, - to: .sessionEventTap, - item: item - ) - } + logger.warning("Click events failed. Posting fallback.") - let result = await eventTask.result - await eventSleep() - - // Catch this for logging purposes only -- we still want - // to throw the existing error if the fallback fails. - try result.get() - } catch { - logger.error( - """ - Failed to post fallback event for clicking \ - \(item.logString, privacy: .public) - """ + // Pad with eventSleep calls to reduce the chance that + // events are still being processed somewhere. + await eventSleep() + do { + // Catch this for logging purposes only. We want to + // propagate the original error. + try await postEventRoundtrip( + fallbackEvent, + to: .sessionEventTap, + item: item, + timeout: timeout ) + } catch { + logger.error("Fallback event failed with error: \(error, privacy: .public)") } + await eventSleep() throw error } } } -// MARK: - Temporarily Show Items +// MARK: - Temporarily Show extension MenuBarItemManager { - /// Gets the destination to return the given item to after it is temporarily shown. + /// Context for a temporarily shown menu bar item. + private struct TempShownItemContext { + /// The tag associated with the item. + let tag: MenuBarItemTag + + /// The destination to return the item to. + let returnDestination: MoveDestination + + /// The window of the item's shown interface. + let shownInterfaceWindow: WindowInfo? + + /// The number of attempts that have been made to rehide the item. + var rehideAttempts = 0 + + /// A Boolean value that indicates whether the menu bar item's + /// interface is showing. + var isShowingInterface: Bool { + guard + let shownInterfaceWindow, + let currentWindow = WindowInfo(windowID: shownInterfaceWindow.windowID) + else { + return false + } + if + currentWindow.layer != CGWindowLevelForKey(.popUpMenuWindow), + let owningApplication = currentWindow.owningApplication + { + return owningApplication.isActive && currentWindow.isOnScreen + } else { + return currentWindow.isOnScreen + } + } + } + + /// Gets the destination to return the given item to after it is + /// temporarily shown. private func getReturnDestination(for item: MenuBarItem, in items: [MenuBarItem]) -> MoveDestination? { if let index = items.firstIndex(matching: item.tag) { if items.indices.contains(index + 1) { @@ -1323,17 +1312,16 @@ extension MenuBarItemManager { return nil } - /// Schedules a timer for the given interval, attempting to rehide the current - /// temporarily shown items when the timer fires. - private func runTempShownItemTimer(for interval: TimeInterval) { - logger.debug( - """ - Running rehide timer for temporarily shown items \ - with interval: \(interval, privacy: .public) - """ - ) - tempShownItemsTimer?.invalidate() - tempShownItemsTimer = .scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] timer in + /// Schedules a timer for the given interval that rehides the + /// temporarily shown items when fired. + private func runRehideTimer(for interval: TimeInterval? = nil) { + guard let appState else { + return + } + let interval = interval ?? appState.settings.advanced.tempShowInterval + logger.debug("Running rehide timer for interval: \(interval, format: .fixed, privacy: .public)") + rehideTimer?.invalidate() + rehideTimer = .scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] timer in guard let self else { timer.invalidate() return @@ -1347,203 +1335,194 @@ extension MenuBarItemManager { /// Temporarily shows the given item. /// - /// The item is cached alongside a destination that it will be automatically returned - /// to. If `true` is passed to the `clickWhenFinished` parameter, the item is clicked - /// once movement is finished. + /// The item is cached and returned to its original destination after the + /// time interval specified by ``AdvancedSettings/tempShowInterval``. /// /// - Parameters: - /// - item: An item to show. - /// - clickWhenFinished: A Boolean value that indicates whether the item should be - /// clicked once movement is finished. - /// - mouseButton: The mouse button of the click. - func tempShowItem(_ item: MenuBarItem, clickWhenFinished: Bool, mouseButton: CGMouseButton) { - guard let screen = NSScreen.main else { + /// - item: The item to temporarily show. + /// - mouseButton: The mouse button to click the item with. + func tempShow(item: MenuBarItem, clickingWith mouseButton: CGMouseButton) async { + guard + let displayID = Bridging.getActiveMenuBarDisplayID(), + let screen = NSScreen.screens.first(where: { $0.displayID == displayID }) + else { + logger.error("No active menu bar display, so not showing \(item.logString, privacy: .public)") return } - let displayID = screen.displayID - - if Bridging.isWindowOnDisplay(item.windowID, displayID) { - if clickWhenFinished { - Task { - do { - try await click(item: item, with: mouseButton) - } catch { - logger.error("ERROR: \(error, privacy: .public)") - } - } - } + guard let applicationMenuFrame = screen.getApplicationMenuFrame() else { + logger.error("No application menu frame, so not showing \(item.logString, privacy: .public)") return } - guard - let appState, - let applicationMenuFrame = appState.menuBarManager.getApplicationMenuFrame(for: displayID) - else { - logger.warning( - """ - No application menu frame, so not showing \ - \(item.logString, privacy: .public) - """ - ) + var items = await MenuBarItem.getMenuBarItems(option: .activeSpace) + + guard let destination = getReturnDestination(for: item, in: items) else { + logger.error("No return destination for \(item.logString, privacy: .public)") return } - logger.info("Temporarily showing \(item.logString, privacy: .public)") - - Task { - var items = await MenuBarItem.getMenuBarItems(option: .activeSpace) - - guard let destination = getReturnDestination(for: item, in: items) else { - logger.warning("No return destination for \(item.logString, privacy: .public)") - return - } + // Remove all items up to the hidden control item. + items.trimPrefix { $0.tag != .hiddenControlItem } - // Remove all items up to the hidden control item. - items.trimPrefix { $0.tag != .hiddenControlItem } + if !items.isEmpty { // Remove the hidden control item. items.removeFirst() + } - // Remove all offscreen items. - if #available(macOS 26.0, *) { - // TODO: isOnScreen doesn't work properly as of macOS 26 Developer Beta 1. Remove this if/when it works again. - items.trimPrefix { !Bridging.isWindowOnDisplay($0.windowID, displayID) } - } else { - items.trimPrefix { !$0.isOnScreen } - } + // Remove all offscreen items. + if #available(macOS 26.0, *) { + // MenuBarItem.isOnScreen doesn't work properly as of macOS 26. + // TODO: Revert this if and when it works again. + items.trimPrefix { !Bridging.isWindowOnDisplay($0.windowID, displayID) } + } else { + items.trimPrefix { !$0.isOnScreen } + } - let maxX = if let rightArea = screen.auxiliaryTopRightArea { - max(rightArea.minX + 20, applicationMenuFrame.maxX) - } else { - applicationMenuFrame.maxX - } + let maxX = if let rightArea = screen.auxiliaryTopRightArea { + max(rightArea.minX + 20, applicationMenuFrame.maxX) + } else { + applicationMenuFrame.maxX + } - // Remove items until we have enough room to show this item. - items.trimPrefix { $0.bounds.minX - item.bounds.width <= maxX } + // Remove items until we have enough room to show this item. + items.trimPrefix { $0.bounds.minX - item.bounds.width <= maxX } - guard let targetItem = items.first else { - let alert = NSAlert() - alert.messageText = "Not enough room to show \"\(item.displayName)\"" - alert.runModal() - return - } + guard let targetItem = items.first else { + logger.warning("Not enough room to show \(item.logString, privacy: .public)") + let alert = NSAlert() + alert.messageText = "Not enough room to show \"\(item.displayName)\"" + alert.runModal() + return + } - let contextTask = Task { - try await slowMove(item: item, to: .leftOfItem(targetItem)) - await eventSleep() + logger.debug("Temporarily showing \(item.logString, privacy: .public)") - let context: TempShownItemContext + do { + try await slowMove(item: item, to: .leftOfItem(targetItem)) + } catch { + logger.error("Error showing item: \(error, privacy: .public)") + return + } - if clickWhenFinished { - let beforeWindows = WindowInfo.createWindows(option: .onScreen) + rehideTimer?.invalidate() + defer { + runRehideTimer() + } - await eventSleep() - try await click(item: item, with: mouseButton) - await eventSleep(for: .seconds(0.25)) + await eventSleep() - let afterWindows = WindowInfo.createWindows(option: .onScreen) + let idsBeforeClick = Set(Bridging.getWindowList(option: .onScreen)) - let shownInterfaceWindow = afterWindows.first { afterWindow in - afterWindow.ownerPID == item.sourcePID && - !beforeWindows.contains { beforeWindow in - afterWindow.windowID == beforeWindow.windowID - } - } + do { + try await click(item: item, with: mouseButton) + } catch { + logger.error("Error clicking item: \(error, privacy: .public)") + let context = TempShownItemContext( + tag: item.tag, + returnDestination: destination, + shownInterfaceWindow: nil + ) + tempShownItemContexts.append(context) + return + } - context = TempShownItemContext( - tag: item.tag, - returnDestination: destination, - shownInterfaceWindow: shownInterfaceWindow - ) - } else { - context = TempShownItemContext( - tag: item.tag, - returnDestination: destination, - shownInterfaceWindow: nil - ) - } + await eventSleep(for: .seconds(0.5)) - return context - } + let windowsAfterClick = WindowInfo.createWindows(option: .onScreen) - do { - let context = try await contextTask.value - tempShownItemContexts.append(context) - runTempShownItemTimer(for: appState.settings.advanced.tempShowInterval) - } catch { - logger.error("ERROR: \(error, privacy: .public)") - } + let window = windowsAfterClick.first { window in + window.ownerPID == item.sourcePID && !idsBeforeClick.contains(window.windowID) } + + let context = TempShownItemContext( + tag: item.tag, + returnDestination: destination, + shownInterfaceWindow: window + ) + tempShownItemContexts.append(context) } /// Rehides all temporarily shown items. /// - /// If an item is currently showing its interface, this method waits for the - /// interface to close before hiding the items. + /// If an item is currently showing its interface, this method waits + /// for the interface to close before hiding the items. func rehideTempShownItems() async { guard !tempShownItemContexts.isEmpty else { return } - guard !MouseEvents.isButtonPressed() else { - logger.debug("Mouse button is down, so waiting to rehide") - runTempShownItemTimer(for: 3) - return - } guard !tempShownItemContexts.contains(where: { $0.isShowingInterface }) else { logger.debug("Menu bar item interface is shown, so waiting to rehide") - runTempShownItemTimer(for: 3) + runRehideTimer(for: 3) return } - logger.info("Rehiding temporarily shown items") - + let items = await MenuBarItem.getMenuBarItems(option: .activeSpace) var failedContexts = [TempShownItemContext]() - let items = await MenuBarItem.getMenuBarItems(option: .activeSpace) + logger.debug("Rehiding temporarily shown items") - while let context = tempShownItemContexts.popLast() { + while var context = tempShownItemContexts.popLast() { guard let item = items.first(where: { $0.tag == context.tag }) else { continue } do { try await slowMove(item: item, to: context.returnDestination) } catch { - logger.error( + context.rehideAttempts += 1 + logger.warning( """ - Failed to rehide \(item.logString, privacy: .public) \ - (error: \(error, privacy: .public)) + Attempt \(context.rehideAttempts, privacy: .public) to rehide \ + \(item.logString, privacy: .public) failed with error: \ + \(error, privacy: .public) """ ) - failedContexts.append(context) + if context.rehideAttempts < 3 { + tempShownItemContexts.append(context) // Try again. + } else { + // Failed contexts are ultimately added back into the array + // of temp shown contexts and rehidden after a longer delay, + // so reset the attempt count. + context.rehideAttempts = 0 + failedContexts.append(context) + } } await eventSleep() } if failedContexts.isEmpty { - tempShownItemsTimer?.invalidate() - tempShownItemsTimer = nil + rehideTimer?.invalidate() + rehideTimer = nil } else { + failedContexts.reverse() // Reverse for correct order. tempShownItemContexts = failedContexts - logger.warning("Some items failed to rehide") - runTempShownItemTimer(for: 3) + logger.error( + """ + Some items failed to rehide: \ + \(failedContexts.map { $0.tag }, privacy: .public) + """ + ) + runRehideTimer(for: 3) } } /// Removes a temporarily shown item from the cache. /// - /// This ensures that the item will _not_ be returned to its previous location. + /// This ensures that the item will _not_ be returned to its + /// previous location. func removeTempShownItemFromCache(with tag: MenuBarItemTag) { tempShownItemContexts.removeAll { $0.tag == tag } } } -// MARK: - Control Item Order +// MARK: - Enforce Control Item Order extension MenuBarItemManager { - /// Enforces the order of the given control items, ensuring that the always-hidden - /// control item stays to the left of the hidden control item. - private func enforceControlItemOrder(controlItems: ControlItemSet) async { + /// Enforces the order of the given control items, ensuring that the + /// control item for the always-hidden section is positioned to the + /// left of control item for the hidden section. + private func enforceControlItemOrder(controlItems: ControlItemPair) async { let hidden = controlItems.hidden guard @@ -1553,9 +1532,8 @@ extension MenuBarItemManager { return } - logger.info("Control items incorrectly ordered, enforcing correct order") - do { + logger.debug("Control items have incorrect order") try await slowMove(item: alwaysHidden, to: .leftOfItem(hidden)) } catch { logger.error("Error enforcing control item order: \(error, privacy: .public)") @@ -1563,7 +1541,7 @@ extension MenuBarItemManager { } } -// MARK: - Menu Bar Item Event Helper Types +// MARK: - Helper Types /// Button states for menu bar item events. private enum MenuBarItemEventButtonState { @@ -1579,7 +1557,6 @@ private enum MenuBarItemEventButtonState { private enum MenuBarItemEventType { /// The event type for moving a menu bar item. case move(MenuBarItemEventButtonState) - /// The event type for clicking a menu bar item. case click(MenuBarItemEventButtonState) @@ -1706,25 +1683,25 @@ private extension CGMouseButton { } } -// MARK: - CGEvent Constructor +// MARK: - CGEvent Helpers private extension CGEvent { /// Returns an event that can be sent to a menu bar item. /// /// - Parameters: + /// - source: The source of the event. /// - type: The type of the event. /// - location: The location of the event. Does not need to be /// within the bounds of the item. /// - item: The target item of the event. /// - pid: The target process identifier of the event. Does not /// need to be the item's `ownerPID`. - /// - source: The source of the event. - class func menuBarItemEvent( + static func menuBarItemEvent( + source: CGEventSource, type: MenuBarItemEventType, location: CGPoint, item: MenuBarItem, - pid: pid_t, - source: CGEventSource + pid: pid_t ) -> CGEvent? { guard let event = CGEvent( mouseEventSource: source, @@ -1742,6 +1719,15 @@ private extension CGEvent { return event } + /// Returns a null event with unique user data. + static func uniqueNullEvent() -> CGEvent? { + guard let event = CGEvent(source: nil) else { + return nil + } + event.setUserData(ObjectIdentifier(event)) + return event + } + private func setFlags(for type: MenuBarItemEventType) { flags = type.cgEventFlags } diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift index 41f0c027e..f930233d4 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift @@ -4,6 +4,7 @@ // import CoreGraphics +import Foundation // MARK: - MenuBarItemTag @@ -35,7 +36,7 @@ struct MenuBarItemTag: Hashable, CustomStringConvertible { /// A string representation of the tag. var stringValue: String { - var result = namespace.rawValue + var result = namespace.stringValue if !title.isEmpty { result.append(":\(title)") } @@ -176,101 +177,40 @@ extension MenuBarItemTag { static let screenCaptureUI = MenuBarItemTag(namespace: .screenCaptureUI, title: "Item-0") } -// MARK: MenuBarItemTag: Codable -extension MenuBarItemTag: Codable { - init(from decoder: any Decoder) throws { - let container = try decoder.singleValueContainer() - let string = try container.decode(String.self) - let components = string.components(separatedBy: ":") - let count = components.count - if count > 2 { - self.namespace = Namespace(components[0]) - self.title = components[1...].joined(separator: ":") - } else if count == 2 { - self.namespace = Namespace(components[0]) - self.title = components[1] - } else if count == 1 { - self.namespace = Namespace(components[0]) - self.title = "" - } else { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: container.codingPath, - debugDescription: "Missing namespace component" - ) - ) - } - } - - func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(stringValue) - } -} - // MARK: - MenuBarItemTag.Namespace extension MenuBarItemTag { /// A type that represents a menu bar item namespace. - struct Namespace: Codable, Hashable, RawRepresentable, CustomStringConvertible { - /// Private representation of a namespace. - private enum Kind { - case null - case rawValue(String) - } - - /// The private representation of the namespace. - private let kind: Kind - - /// The namespace's raw value. - var rawValue: String { - switch kind { - case .null: "" - case .rawValue(let rawValue): rawValue + enum Namespace: Hashable, CustomStringConvertible { + /// The null namespace. + case null + /// A namespace represented by string. + case string(String) + /// A namespace represented by uuid. + case uuid(UUID) + + /// The namespace's string value. + var stringValue: String { + switch self { + case .null: "null" + case .string(let string): string + case .uuid(let uuid): uuid.uuidString } } /// A textual representation of the namespace. var description: String { - rawValue - } - - /// An Optional representation of the namespace that converts - /// the ``null`` namespace to `nil`. - var optional: Namespace? { - switch kind { - case .null: nil - case .rawValue: self - } - } - - /// Creates a namespace with the given private representation. - private init(kind: Kind) { - self.kind = kind - } - - /// Creates a namespace with the given raw value. - /// - /// - Parameter rawValue: The raw value of the namespace. - init(rawValue: String) { - self.init(kind: .rawValue(rawValue)) - } - - /// Creates a namespace with the given raw value. - /// - /// - Parameter rawValue: The raw value of the namespace. - init(_ rawValue: String) { - self.init(rawValue: rawValue) + stringValue } /// Creates a namespace with the given optional value. - /// - /// If the provided value is `nil`, the namespace is initialized - /// to the ``null`` namespace. - /// + /// /// - Parameter value: An optional value for the namespace. - init(_ value: String?) { - self = value.map { Namespace($0) } ?? .null + /// + /// - Returns: The ``string(_:)`` namespace when `value` is not `nil`. + /// Otherwise, the ``null`` namespace. + static func optional(_ value: String?) -> Namespace { + value.map { .string($0) } ?? .null } } } @@ -278,29 +218,26 @@ extension MenuBarItemTag { // MARK: MenuBarItemTag.Namespace Constants extension MenuBarItemTag.Namespace { /// The namespace for the "Ice" process. - static let ice = Self(Constants.bundleIdentifier) + static let ice = string(Constants.bundleIdentifier) /// The namespace for the "Control Center" process. - static let controlCenter = Self("com.apple.controlcenter") + static let controlCenter = string("com.apple.controlcenter") /// The namespace for the "PasswordsMenuBarExtra" process. - static let passwords = Self("com.apple.Passwords.MenuBarExtra") + static let passwords = string("com.apple.Passwords.MenuBarExtra") /// The namespace for the "screencaptureui" process. - static let screenCaptureUI = Self("com.apple.screencaptureui") + static let screenCaptureUI = string("com.apple.screencaptureui") /// The namespace for the "Spotlight" process. - static let spotlight = Self("com.apple.Spotlight") + static let spotlight = string("com.apple.Spotlight") /// The namespace for the "SystemUIServer" process. - static let systemUIServer = Self("com.apple.systemuiserver") + static let systemUIServer = string("com.apple.systemuiserver") /// The namespace for the "TextInputMenuAgent" process. - static let textInputMenuAgent = Self("com.apple.TextInputMenuAgent") + static let textInputMenuAgent = string("com.apple.TextInputMenuAgent") /// The namespace for the "WeatherMenu" process. - static let weather = Self("com.apple.weather.menu") - - /// The null namespace. - static let null = Self(kind: .null) + static let weather = string("com.apple.weather.menu") } diff --git a/Ice/MenuBar/MenuBarManager.swift b/Ice/MenuBar/MenuBarManager.swift index 457563e89..b85d5387c 100644 --- a/Ice/MenuBar/MenuBarManager.swift +++ b/Ice/MenuBar/MenuBarManager.swift @@ -47,6 +47,10 @@ final class MenuBarManager: ObservableObject { /// The panel that contains the menu bar search interface. let searchPanel = MenuBarSearchPanel() + /// The panel that contains a portable version of the menu bar + /// appearance editor interface + let appearanceEditorPanel = MenuBarAppearanceEditorPanel() + /// The managed sections in the menu bar. let sections = [ MenuBarSection(name: .visible), @@ -72,6 +76,7 @@ final class MenuBarManager: ObservableObject { configureCancellables() iceBarPanel.performSetup(with: appState) searchPanel.performSetup(with: appState) + appearanceEditorPanel.performSetup(with: appState) for section in sections { section.performSetup(with: appState) } @@ -172,7 +177,7 @@ final class MenuBarManager: ObservableObject { appState.settings.advanced.hideApplicationMenus, !appState.settings.general.useIceBar, !isMenuBarHiddenBySystem, - !appState.isActiveSpaceFullscreen, + !appState.activeSpace.isFullscreen, !appState.navigationState.isSettingsPresented else { return @@ -183,16 +188,14 @@ final class MenuBarManager: ObservableObject { return } - let displayID = screen.displayID - // Get the application menu frame for the display. - guard let applicationMenuFrame = getApplicationMenuFrame(for: displayID) else { + guard let applicationMenuFrame = screen.getApplicationMenuFrame() else { return } Task { // Get all items. - var items = await MenuBarItem.getMenuBarItems(on: displayID, option: .activeSpace) + var items = await MenuBarItem.getMenuBarItems(on: screen.displayID, option: .activeSpace) // Filter the items down according to the currently enabled/shown sections. if @@ -240,54 +243,28 @@ final class MenuBarManager: ObservableObject { return } - let image: CGImage? - let source: MenuBarAverageColorInfo.Source - let windows = WindowInfo.createWindows(option: .onScreen) let displayID = screen.displayID - if #available(macOS 26.0, *) { - if let window = WindowInfo.wallpaperWindow(from: windows, for: displayID) { - var bounds = window.bounds - bounds.size.height = 1 - bounds.origin.x = bounds.midX - bounds.size.width /= 2 - - image = ScreenCapture.captureWindow(window.windowID, screenBounds: bounds, option: .nominalResolution) - source = .desktopWallpaper - } else { - return - } - } else { - if let window = WindowInfo.menuBarWindow(from: windows, for: displayID) { - var bounds = window.bounds - bounds.size.height = 1 - bounds.origin.x = bounds.maxX - (bounds.width / 4) - bounds.size.width /= 4 - - image = ScreenCapture.captureWindow(window.windowID, screenBounds: bounds, option: .nominalResolution) - source = .menuBarWindow - } else if let window = WindowInfo.wallpaperWindow(from: windows, for: displayID) { - var bounds = window.bounds - bounds.size.height = 1 - bounds.origin.x = bounds.midX - bounds.size.width /= 2 - - image = ScreenCapture.captureWindow(window.windowID, screenBounds: bounds, option: .nominalResolution) - source = .desktopWallpaper - } else { - return - } + guard + let menuBarWindow = WindowInfo.menuBarWindow(from: windows, for: displayID), + let wallpaperWindow = WindowInfo.wallpaperWindow(from: windows, for: displayID) + else { + return } guard - let image, - let color = image.averageColor(makeOpaque: true) + let image = ScreenCapture.captureWindows( + with: [menuBarWindow.windowID, wallpaperWindow.windowID], + screenBounds: withMutableCopy(of: wallpaperWindow.bounds) { $0.size.height = 1 }, + option: .nominalResolution + ), + let color = image.averageColor(option: .ignoreAlpha) else { return } - let info = MenuBarAverageColorInfo(color: color, source: source) + let info = MenuBarAverageColorInfo(color: color, source: .menuBarWindow) if averageColorInfo != info { averageColorInfo = info @@ -308,57 +285,17 @@ final class MenuBarManager: ObservableObject { } } - /// Returns the frame of the application menu for the given display. - func getApplicationMenuFrame(for displayID: CGDirectDisplayID) -> CGRect? { - let displayBounds = CGDisplayBounds(displayID) - - guard - let menuBar = try? systemWideElement.elementAtPosition(displayBounds.origin), - let role = try? menuBar.role(), - role == .menuBar - else { - return nil - } - - let applicationMenuFrame = menuBar.children.reduce(CGRect.null) { result, item in - guard item.isEnabled, let frame = item.frame else { - return result - } - return result.union(frame) - } - - if applicationMenuFrame.width <= 0 { - return nil - } - - // The Accessibility API returns the menu bar for the active screen, regardless of the - // display origin used. This workaround prevents an incorrect frame from being returned - // for inactive displays in multi-display setups where one display has a notch. - if - let mainScreen = NSScreen.main, - let thisScreen = NSScreen.screens.first(where: { $0.displayID == displayID }), - thisScreen != mainScreen, - let notchedScreen = NSScreen.screens.first(where: { $0.hasNotch }), - let leftArea = notchedScreen.auxiliaryTopLeftArea, - applicationMenuFrame.width >= leftArea.maxX - { - return nil - } - - return applicationMenuFrame - } - /// Shows the secondary context menu. func showSecondaryContextMenu(at point: CGPoint) { let menu = NSMenu(title: "Ice") - let editItem = NSMenuItem( + let editAppearanceItem = NSMenuItem( title: "Edit Menu Bar Appearance…", - action: #selector(showAppearanceEditorPopover), + action: #selector(showAppearanceEditorPanel), keyEquivalent: "" ) - editItem.target = self - menu.addItem(editItem) + editAppearanceItem.target = self + menu.addItem(editAppearanceItem) menu.addItem(.separator()) @@ -403,15 +340,12 @@ final class MenuBarManager: ObservableObject { } } - /// Shows the appearance editor popover, centered under the menu bar. - @objc private func showAppearanceEditorPopover() { - guard let appState else { - logger.error("Error showing appearance editor popover: Missing app state") + /// Shows the appearance editor panel. + @objc private func showAppearanceEditorPanel() { + guard let screen = MenuBarAppearanceEditorPanel.defaultScreen else { return } - let panel = MenuBarAppearanceEditorPanel(appState: appState) - panel.orderFrontRegardless() - panel.showAppearanceEditorPopover() + appearanceEditorPanel.show(on: screen) } /// Returns the menu bar section with the given name. @@ -430,13 +364,28 @@ extension MenuBarManager: BindingExposable { } // MARK: - MenuBarAverageColorInfo -/// Information for the menu bar's average color. +/// Information for the average color of the menu bar. struct MenuBarAverageColorInfo: Hashable { + /// Sources used to compute the average color of the menu bar. enum Source: Hashable { case menuBarWindow case desktopWallpaper } + /// The average color of the menu bar var color: CGColor + + /// The source used to compute the color. var source: Source + + /// The brightness of the menu bar's color. + var brightness: CGFloat { color.brightness ?? 0 } + + /// A Boolean value that indicates whether the menu bar has a + /// bright color. + /// + /// This value is `true` if ``brightness`` is above `0.67`. At + /// the time of writing, if this value is `true`, the menu bar + /// draws its items with a darker appearance. + var isBright: Bool { brightness > 0.67 } } diff --git a/Ice/MenuBar/MenuBarSection.swift b/Ice/MenuBar/MenuBarSection.swift index 6cf98bcd2..142382e57 100644 --- a/Ice/MenuBar/MenuBarSection.swift +++ b/Ice/MenuBar/MenuBarSection.swift @@ -47,7 +47,7 @@ final class MenuBarSection { /// An event monitor that handles starting the rehide timer when the mouse /// is outside of the menu bar. - private var rehideMonitor: UniversalEventMonitor? + private var rehideMonitor: EventMonitor? /// A Boolean value that indicates whether the Ice Bar should be used. private var useIceBar: Bool { @@ -64,7 +64,7 @@ final class MenuBarSection { guard let appState else { return nil } - if appState.isActiveSpaceFullscreen { + if appState.activeSpace.isFullscreen { return NSScreen.screenWithMouse ?? NSScreen.main } else { return NSScreen.main @@ -158,14 +158,13 @@ final class MenuBarSection { } if let screen = screenForIceBar { - Task(timeout: .seconds(3)) { + Task { switch name { case .visible, .hidden: await menuBarManager.iceBarPanel.show(section: .hidden, on: screen) case .alwaysHidden: await menuBarManager.iceBarPanel.show(section: .alwaysHidden, on: screen) } - try Task.checkCancellation() startRehideChecks() } } @@ -230,7 +229,7 @@ final class MenuBarSection { return } - rehideMonitor = UniversalEventMonitor(mask: .mouseMoved) { [weak self] event in + rehideMonitor = EventMonitor.universal(for: .mouseMoved) { [weak self] event in guard let self, let screen = NSScreen.main diff --git a/Ice/MenuBar/Search/MenuBarSearchModel.swift b/Ice/MenuBar/Search/MenuBarSearchModel.swift index 4fcfa19ea..922415c13 100644 --- a/Ice/MenuBar/Search/MenuBarSearchModel.swift +++ b/Ice/MenuBar/Search/MenuBarSearchModel.swift @@ -56,17 +56,13 @@ final class MenuBarSearchModel: ObservableObject { return } - let windowIDs = [menuBarWindow.windowID, wallpaperWindow.windowID] - let option: CGWindowImageOption = .nominalResolution - let bounds = with(wallpaperWindow.bounds) { bounds in - bounds.size.height = 1 - bounds.origin.x = bounds.midX - bounds.size.width /= 2 - } - guard - let image = ScreenCapture.captureWindows(windowIDs, screenBounds: bounds, option: option), - let color = image.averageColor(makeOpaque: true) + let image = ScreenCapture.captureWindows( + with: [menuBarWindow.windowID, wallpaperWindow.windowID], + screenBounds: withMutableCopy(of: wallpaperWindow.bounds) { $0.size.height = 1 }, + option: .nominalResolution + ), + let color = image.averageColor(option: .ignoreAlpha) else { return } diff --git a/Ice/MenuBar/Search/MenuBarSearchPanel.swift b/Ice/MenuBar/Search/MenuBarSearchPanel.swift index a1db03e56..bb51bdea6 100644 --- a/Ice/MenuBar/Search/MenuBarSearchPanel.swift +++ b/Ice/MenuBar/Search/MenuBarSearchPanel.swift @@ -24,8 +24,8 @@ final class MenuBarSearchPanel: NSPanel { private let model = MenuBarSearchModel() /// Monitor for mouse down events. - private lazy var mouseDownMonitor = UniversalEventMonitor( - mask: [.leftMouseDown, .rightMouseDown, .otherMouseDown] + private lazy var mouseDownMonitor = EventMonitor.universal( + for: [.leftMouseDown, .rightMouseDown, .otherMouseDown] ) { [weak self, weak appState] event in guard let self, @@ -34,15 +34,15 @@ final class MenuBarSearchPanel: NSPanel { else { return event } - if !appState.itemManager.itemHasRecentlyMoved { + if !appState.itemManager.latestMoveOperationStarted(within: .seconds(1)) { close() } return event } /// Monitor for key down events. - private lazy var keyDownMonitor = UniversalEventMonitor( - mask: [.keyDown] + private lazy var keyDownMonitor = EventMonitor.universal( + for: [.keyDown] ) { [weak self] event in if KeyCode(rawValue: Int(event.keyCode)) == .escape { self?.close() @@ -358,7 +358,11 @@ private struct MenuBarSearchContentView: View { closePanel() Task { try await Task.sleep(for: .milliseconds(25)) - itemManager.tempShowItem(item, clickWhenFinished: true, mouseButton: .left) + if Bridging.isWindowOnDisplay(item.windowID, displayID) { + try await itemManager.click(item: item, with: .left) + } else { + await itemManager.tempShow(item: item, clickingWith: .left) + } } } } @@ -577,7 +581,7 @@ private struct MenuBarSearchItemView: View { @ViewBuilder private var imageViewWithBackground: some View { imageView - .layoutBarStyle(appState: appState, averageColorInfo: model.averageColorInfo) + .menuBarItemContainer(appState: appState, colorInfo: model.averageColorInfo) .clipShape(backgroundShape) .overlay { backgroundShape diff --git a/Ice/Permissions/PermissionsWindow.swift b/Ice/Permissions/PermissionsWindow.swift index 5112a6b31..aed8029f1 100644 --- a/Ice/Permissions/PermissionsWindow.swift +++ b/Ice/Permissions/PermissionsWindow.swift @@ -19,7 +19,7 @@ struct PermissionsWindow: Scene { window.standardWindowButton(.miniaturizeButton)?.isHidden = true window.standardWindowButton(.zoomButton)?.isHidden = true if let contentView = window.contentView { - with(contentView.safeAreaInsets) { insets in + withMutableCopy(of: contentView.safeAreaInsets) { insets in insets.bottom = -insets.bottom insets.left = -insets.left insets.right = -insets.right diff --git a/Ice/Assets.xcassets/AccentColor.colorset/Contents.json b/Ice/Resources/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Ice/Assets.xcassets/AccentColor.colorset/Contents.json rename to Ice/Resources/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Ice/Assets.xcassets/AppIcon.appiconset/Contents.json b/Ice/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from Ice/Assets.xcassets/AppIcon.appiconset/Contents.json rename to Ice/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/Ice/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png similarity index 100% rename from Ice/Assets.xcassets/AppIcon.appiconset/icon_128x128.png rename to Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png diff --git a/Ice/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png similarity index 100% rename from Ice/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png rename to Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png diff --git a/Ice/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png similarity index 100% rename from Ice/Assets.xcassets/AppIcon.appiconset/icon_16x16.png rename to Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png diff --git a/Ice/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png similarity index 100% rename from Ice/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png rename to Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png diff --git a/Ice/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png similarity index 100% rename from Ice/Assets.xcassets/AppIcon.appiconset/icon_256x256.png rename to Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png diff --git a/Ice/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png similarity index 100% rename from Ice/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png rename to Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png diff --git a/Ice/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png similarity index 100% rename from Ice/Assets.xcassets/AppIcon.appiconset/icon_32x32.png rename to Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png diff --git a/Ice/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png similarity index 100% rename from Ice/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png rename to Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png diff --git a/Ice/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png similarity index 100% rename from Ice/Assets.xcassets/AppIcon.appiconset/icon_512x512.png rename to Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png diff --git a/Ice/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png similarity index 100% rename from Ice/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png rename to Ice/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png diff --git a/Ice/Assets.xcassets/Contents.json b/Ice/Resources/Assets.xcassets/Contents.json similarity index 100% rename from Ice/Assets.xcassets/Contents.json rename to Ice/Resources/Assets.xcassets/Contents.json diff --git a/Ice/Assets.xcassets/ControlItemImages/Contents.json b/Ice/Resources/Assets.xcassets/ControlItemImages/Contents.json similarity index 100% rename from Ice/Assets.xcassets/ControlItemImages/Contents.json rename to Ice/Resources/Assets.xcassets/ControlItemImages/Contents.json diff --git a/Ice/Assets.xcassets/ControlItemImages/Dot/Contents.json b/Ice/Resources/Assets.xcassets/ControlItemImages/Dot/Contents.json similarity index 100% rename from Ice/Assets.xcassets/ControlItemImages/Dot/Contents.json rename to Ice/Resources/Assets.xcassets/ControlItemImages/Dot/Contents.json diff --git a/Ice/Assets.xcassets/ControlItemImages/Dot/DotFill.imageset/Contents.json b/Ice/Resources/Assets.xcassets/ControlItemImages/Dot/DotFill.imageset/Contents.json similarity index 100% rename from Ice/Assets.xcassets/ControlItemImages/Dot/DotFill.imageset/Contents.json rename to Ice/Resources/Assets.xcassets/ControlItemImages/Dot/DotFill.imageset/Contents.json diff --git a/Ice/Assets.xcassets/ControlItemImages/Dot/DotFill.imageset/DotFill.png b/Ice/Resources/Assets.xcassets/ControlItemImages/Dot/DotFill.imageset/DotFill.png similarity index 100% rename from Ice/Assets.xcassets/ControlItemImages/Dot/DotFill.imageset/DotFill.png rename to Ice/Resources/Assets.xcassets/ControlItemImages/Dot/DotFill.imageset/DotFill.png diff --git a/Ice/Assets.xcassets/ControlItemImages/Dot/DotStroke.imageset/Contents.json b/Ice/Resources/Assets.xcassets/ControlItemImages/Dot/DotStroke.imageset/Contents.json similarity index 100% rename from Ice/Assets.xcassets/ControlItemImages/Dot/DotStroke.imageset/Contents.json rename to Ice/Resources/Assets.xcassets/ControlItemImages/Dot/DotStroke.imageset/Contents.json diff --git a/Ice/Assets.xcassets/ControlItemImages/Dot/DotStroke.imageset/DotStroke.png b/Ice/Resources/Assets.xcassets/ControlItemImages/Dot/DotStroke.imageset/DotStroke.png similarity index 100% rename from Ice/Assets.xcassets/ControlItemImages/Dot/DotStroke.imageset/DotStroke.png rename to Ice/Resources/Assets.xcassets/ControlItemImages/Dot/DotStroke.imageset/DotStroke.png diff --git a/Ice/Assets.xcassets/ControlItemImages/Ellipsis/Contents.json b/Ice/Resources/Assets.xcassets/ControlItemImages/Ellipsis/Contents.json similarity index 100% rename from Ice/Assets.xcassets/ControlItemImages/Ellipsis/Contents.json rename to Ice/Resources/Assets.xcassets/ControlItemImages/Ellipsis/Contents.json diff --git a/Ice/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisFill.imageset/Contents.json b/Ice/Resources/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisFill.imageset/Contents.json similarity index 100% rename from Ice/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisFill.imageset/Contents.json rename to Ice/Resources/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisFill.imageset/Contents.json diff --git a/Ice/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisFill.imageset/EllipsisFill.png b/Ice/Resources/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisFill.imageset/EllipsisFill.png similarity index 100% rename from Ice/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisFill.imageset/EllipsisFill.png rename to Ice/Resources/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisFill.imageset/EllipsisFill.png diff --git a/Ice/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisStroke.imageset/Contents.json b/Ice/Resources/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisStroke.imageset/Contents.json similarity index 100% rename from Ice/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisStroke.imageset/Contents.json rename to Ice/Resources/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisStroke.imageset/Contents.json diff --git a/Ice/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisStroke.imageset/EllipsisStroke.png b/Ice/Resources/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisStroke.imageset/EllipsisStroke.png similarity index 100% rename from Ice/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisStroke.imageset/EllipsisStroke.png rename to Ice/Resources/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisStroke.imageset/EllipsisStroke.png diff --git a/Ice/Assets.xcassets/ControlItemImages/IceCube/Contents.json b/Ice/Resources/Assets.xcassets/ControlItemImages/IceCube/Contents.json similarity index 100% rename from Ice/Assets.xcassets/ControlItemImages/IceCube/Contents.json rename to Ice/Resources/Assets.xcassets/ControlItemImages/IceCube/Contents.json diff --git a/Ice/Assets.xcassets/ControlItemImages/IceCube/IceCubeFill.imageset/Contents.json b/Ice/Resources/Assets.xcassets/ControlItemImages/IceCube/IceCubeFill.imageset/Contents.json similarity index 100% rename from Ice/Assets.xcassets/ControlItemImages/IceCube/IceCubeFill.imageset/Contents.json rename to Ice/Resources/Assets.xcassets/ControlItemImages/IceCube/IceCubeFill.imageset/Contents.json diff --git a/Ice/Assets.xcassets/ControlItemImages/IceCube/IceCubeFill.imageset/IceCubeFill.png b/Ice/Resources/Assets.xcassets/ControlItemImages/IceCube/IceCubeFill.imageset/IceCubeFill.png similarity index 100% rename from Ice/Assets.xcassets/ControlItemImages/IceCube/IceCubeFill.imageset/IceCubeFill.png rename to Ice/Resources/Assets.xcassets/ControlItemImages/IceCube/IceCubeFill.imageset/IceCubeFill.png diff --git a/Ice/Assets.xcassets/ControlItemImages/IceCube/IceCubeStroke.imageset/Contents.json b/Ice/Resources/Assets.xcassets/ControlItemImages/IceCube/IceCubeStroke.imageset/Contents.json similarity index 100% rename from Ice/Assets.xcassets/ControlItemImages/IceCube/IceCubeStroke.imageset/Contents.json rename to Ice/Resources/Assets.xcassets/ControlItemImages/IceCube/IceCubeStroke.imageset/Contents.json diff --git a/Ice/Assets.xcassets/ControlItemImages/IceCube/IceCubeStroke.imageset/IceCubeStroke.png b/Ice/Resources/Assets.xcassets/ControlItemImages/IceCube/IceCubeStroke.imageset/IceCubeStroke.png similarity index 100% rename from Ice/Assets.xcassets/ControlItemImages/IceCube/IceCubeStroke.imageset/IceCubeStroke.png rename to Ice/Resources/Assets.xcassets/ControlItemImages/IceCube/IceCubeStroke.imageset/IceCubeStroke.png diff --git a/Ice/Assets.xcassets/DefaultLayoutBarColor.colorset/Contents.json b/Ice/Resources/Assets.xcassets/DefaultLayoutBarColor.colorset/Contents.json similarity index 100% rename from Ice/Assets.xcassets/DefaultLayoutBarColor.colorset/Contents.json rename to Ice/Resources/Assets.xcassets/DefaultLayoutBarColor.colorset/Contents.json diff --git a/Ice/Assets.xcassets/Warning.imageset/Contents.json b/Ice/Resources/Assets.xcassets/Warning.imageset/Contents.json similarity index 100% rename from Ice/Assets.xcassets/Warning.imageset/Contents.json rename to Ice/Resources/Assets.xcassets/Warning.imageset/Contents.json diff --git a/Ice/Assets.xcassets/Warning.imageset/Warning.png b/Ice/Resources/Assets.xcassets/Warning.imageset/Warning.png similarity index 100% rename from Ice/Assets.xcassets/Warning.imageset/Warning.png rename to Ice/Resources/Assets.xcassets/Warning.imageset/Warning.png diff --git a/Ice/Info.plist b/Ice/Resources/Info.plist similarity index 100% rename from Ice/Info.plist rename to Ice/Resources/Info.plist diff --git a/Ice/Settings/Models/HotkeysSettings.swift b/Ice/Settings/Models/HotkeysSettings.swift index a77eaa01a..c590af5a3 100644 --- a/Ice/Settings/Models/HotkeysSettings.swift +++ b/Ice/Settings/Models/HotkeysSettings.swift @@ -75,7 +75,7 @@ final class HotkeysSettings: ObservableObject { Logger.serialization.error("Error encoding hotkey: \(error, privacy: .public)") } } receiveValue: { data in - with(Defaults.dictionary(forKey: .hotkeys) ?? [:]) { dictionary in + withMutableCopy(of: Defaults.dictionary(forKey: .hotkeys) ?? [:]) { dictionary in dictionary[hotkey.action.rawValue] = data Defaults.set(dictionary, forKey: .hotkeys) } diff --git a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift index 11528b0b9..5a3debb91 100644 --- a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift @@ -81,17 +81,17 @@ struct MenuBarLayoutSettingsPane: View { } @ViewBuilder - private func layoutBar(for section: MenuBarSection.Name) -> some View { + private func layoutBar(for name: MenuBarSection.Name) -> some View { if - let section = appState.menuBarManager.section(withName: section), + let section = appState.menuBarManager.section(withName: name), section.isEnabled { VStack(alignment: .leading, spacing: 4) { - Text("\(section.name.displayString) Section") + Text("\(name.displayString) Section") .font(.system(size: 14)) .padding(.leading, 2) - LayoutBar(section: section) + LayoutBar(section: name) .environmentObject(appState.imageCache) } } diff --git a/Ice/UI/IceUI/IceColorPicker.swift b/Ice/UI/IceUI/IceColorPicker.swift new file mode 100644 index 000000000..2092bb045 --- /dev/null +++ b/Ice/UI/IceUI/IceColorPicker.swift @@ -0,0 +1,171 @@ +// +// IceColorPicker.swift +// Ice +// + +import Combine +import SwiftUI + +struct IceColorPicker: View { + @Binding private var selection: CGColor + @State private var isActive: Bool = false + + private let supportsOpacity: Bool + private let label: Label + + init( + selection: Binding, + supportsOpacity: Bool = true, + @ViewBuilder label: () -> Label + ) { + self._selection = selection + self.supportsOpacity = supportsOpacity + self.label = label() + } + + init( + _ labelKey: LocalizedStringKey, + selection: Binding, + supportsOpacity: Bool = true + ) where Label == Text { + self._selection = selection + self.supportsOpacity = supportsOpacity + self.label = Text(labelKey) + } + + /// Creates a new color picker. + /// + /// - Parameters: + /// - gradient: A binding to a color. + /// - supportsOpacity: A Boolean value indicating whether the + /// picker should support opacity. + init( + selection: Binding, + supportsOpacity: Bool = true + ) where Label == EmptyView { + self._selection = selection + self.supportsOpacity = supportsOpacity + self.label = EmptyView() + } + + var body: some View { + IceLabeledContent { + IceColorPickerRoot( + selection: $selection, + isActive: $isActive, + supportsOpacity: supportsOpacity + ) + .onKeyDown(key: .escape, isEnabled: isActive) { + isActive = false + NSColorPanel.shared.close() + return .handled + } + } label: { + label + } + } +} + +private struct IceColorPickerRoot: NSViewRepresentable { + @Binding var selection: CGColor + @Binding var isActive: Bool + + let supportsOpacity: Bool + + func makeNSView(context: Context) -> NSColorWell { + let colorWell = NSColorWell() + updateNSView(colorWell, context: context) + context.coordinator.configure(with: colorWell) + return colorWell + } + + func updateNSView(_ colorWell: NSColorWell, context: Context) { + if colorWell.supportsAlpha != supportsOpacity { + colorWell.supportsAlpha = supportsOpacity + } + + if + let color = NSColor(cgColor: selection), + colorWell.color != color + { + colorWell.color = color + } + + if isActive != colorWell.isActive { + if isActive, let window = colorWell.window, window.isVisible { + colorWell.activate(true) + } else { + colorWell.deactivate() + } + } + } + + func makeCoordinator() -> IceColorPickerCoordinator { + IceColorPickerCoordinator(selection: $selection, isActive: $isActive) + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + nsView colorWell: NSColorWell, + context: Context + ) -> CGSize? { + colorWell.intrinsicContentSize + } +} + +@MainActor +private final class IceColorPickerCoordinator { + @Binding var selection: CGColor + @Binding var isActive: Bool + + private var cancellables = Set() + + init(selection: Binding, isActive: Binding) { + self._selection = selection + self._isActive = isActive + } + + func configure(with colorWell: NSColorWell) { + var c = Set() + + colorWell.publisher(for: \.color).removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] color in + guard let self else { + return + } + let selection = color.cgColor + if self.selection != selection { + self.selection = selection + } + } + .store(in: &c) + + colorWell.publisher(for: \.isActive).removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] isActive in + guard let self else { + return + } + if self.isActive != isActive { + self.isActive = isActive + } + } + .store(in: &c) + + colorWell.publisher(for: \.window).publisher(for: \.isVisible) + .replaceNil(with: false).removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] isVisible in + guard let self else { + return + } + if !isVisible, isActive { + isActive = false + } + } + .store(in: &c) + + cancellables = c + } +} diff --git a/Ice/UI/IceUI/IceGradientPicker.swift b/Ice/UI/IceUI/IceGradientPicker.swift new file mode 100644 index 000000000..ed9940411 --- /dev/null +++ b/Ice/UI/IceUI/IceGradientPicker.swift @@ -0,0 +1,397 @@ +// +// IceGradientPicker.swift +// Ice +// + +import Combine +import SwiftUI + +struct IceGradientPicker: View { + @Binding private var gradient: IceGradient + @State private var selection: Int? + @State private var cancellable: AnyCancellable? + + private let supportsOpacity: Bool + private let label: Label + + init( + gradient: Binding, + supportsOpacity: Bool = true, + @ViewBuilder label: () -> Label + ) { + self._gradient = gradient + self.supportsOpacity = supportsOpacity + self.label = label() + } + + init( + _ labelKey: LocalizedStringKey, + gradient: Binding, + supportsOpacity: Bool = true + ) where Label == Text { + self._gradient = gradient + self.supportsOpacity = supportsOpacity + self.label = Text(labelKey) + } + + /// Creates a new gradient picker. + /// + /// - Parameters: + /// - gradient: A binding to a gradient. + /// - supportsOpacity: A Boolean value indicating whether the + /// picker should support opacity. + init( + gradient: Binding, + supportsOpacity: Bool = true + ) where Label == EmptyView { + self._gradient = gradient + self.supportsOpacity = supportsOpacity + self.label = EmptyView() + } + + var body: some View { + IceLabeledContent { + IceGradientPickerRoot( + gradient: $gradient, + selection: $selection, + supportsOpacity: supportsOpacity + ) + .onWindowChange { window in + cancellable = window?.publisher(for: \.isVisible) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { isVisible in + if !isVisible { + selection = nil + } + } + } + } label: { + label + } + } +} + +private struct IceGradientPickerRoot: View { + @Environment(\.isEnabled) private var isEnabled + + @Binding var gradient: IceGradient + @Binding var selection: Int? + @State private var lastUpdated: Int? + @State private var cancellables = Set() + + let supportsOpacity: Bool + + private let handleWidth: CGFloat = 10 + + private var borderShape: some InsettableShape { + if #available(macOS 26.0, *) { + RoundedRectangle(cornerRadius: 6, style: .continuous) + } else { + RoundedRectangle(cornerRadius: 5, style: .circular) + } + } + + var body: some View { + gradient.swiftUIView(using: .displayP3) + .clipShape(borderShape) + .overlay { + borderView + } + .padding(.vertical, 2) + .overlay { + GeometryReader { geometry in + insertionReader(geometry: geometry) + handles(geometry: geometry) + } + .padding(.horizontal, handleWidth / 2) + } + .frame(width: 200, height: 24) + .shadow(radius: 2) + .onTapGesture(count: 2) { + distributeStops() + } + .onKeyDown(key: .delete, isEnabled: selection != nil) { + deleteSelectedStop() + return .handled + } + .onKeyDown(key: .escape, isEnabled: selection != nil) { + selection = nil + dismissColorPanel() + return .handled + } + .onChange(of: gradient) { oldValue, newValue in + gradientChanged(from: oldValue, to: newValue) + } + .onChange(of: selection) { oldValue, newValue in + selectionChanged(from: oldValue, to: newValue) + } + .compositingGroup() + .allowsHitTesting(isEnabled) + .opacity(isEnabled ? 1 : 0.5) + } + + @ViewBuilder + private var borderView: some View { + borderShape + .strokeBorder(.tertiary) + .overlay { + centerTickMark + } + } + + @ViewBuilder + private var centerTickMark: some View { + Rectangle() + .fill(.tertiary) + .frame(width: 1, height: 6) + } + + @ViewBuilder + private func insertionReader(geometry: GeometryProxy) -> some View { + Color.clear + .contentShape(borderShape) + .onTapGesture { location in + insertStop(at: (location.x / geometry.size.width), select: true) + } + } + + @ViewBuilder + private func handles(geometry: GeometryProxy) -> some View { + ForEach(gradient.stops.indices, id: \.self) { index in + IceGradientPickerHandle( + gradient: $gradient, + selection: $selection, + lastUpdated: $lastUpdated, + index: index, + geometry: geometry, + width: handleWidth + ) + } + } + + private func insertStop(at location: CGFloat, select: Bool) { + var location = location.clamped(to: 0...1) + if abs(location - 0.5) <= 0.025 { + location = 0.5 + } + if let color = gradient.color(at: location) { + gradient.stops.append(.stop(color, location: location)) + } else { + gradient.stops.append(.black(location: location)) + } + if select, let index = gradient.stops.indices.last { + DispatchQueue.main.async { + self.selection = index + } + } + } + + private func gradientChanged(from oldValue: IceGradient, to newValue: IceGradient) { + guard oldValue != newValue else { + return + } + if newValue.stops.isEmpty { + gradient = oldValue + } + } + + private func selectionChanged(from oldValue: Int?, to newValue: Int?) { + guard oldValue != newValue else { + return + } + + stopColorPanelObservers() + + if newValue != nil { + dismissColorPanel() + openColorPanel() + startColorPanelObservers() + } + } + + private func startColorPanelObservers() { + if + let selection, + gradient.stops.indices.contains(selection), + let color = NSColor(cgColor: gradient.stops[selection].color), + NSColorPanel.shared.color != color + { + NSColorPanel.shared.color = color + } + + var c = Set() + + NSColorPanel.shared.publisher(for: \.color) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { color in + guard + let selection, + NSColorPanel.shared.isVisible, + gradient.stops.indices.contains(selection), + gradient.stops[selection].color != color.cgColor + else { + return + } + gradient.stops[selection].color = color.cgColor + } + .store(in: &c) + + NSColorPanel.shared.publisher(for: \.isVisible) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { isVisible in + guard selection != nil else { + return + } + guard isVisible else { + selection = nil + return + } + if NSColorPanel.shared.showsAlpha != supportsOpacity { + NSColorPanel.shared.showsAlpha = supportsOpacity + } + } + .store(in: &c) + + cancellables = c + } + + private func stopColorPanelObservers() { + for cancellable in cancellables { + cancellable.cancel() + } + cancellables.removeAll() + } + + private func openColorPanel() { + if !NSColorPanel.shared.isVisible { + NSColorPanel.shared.orderFrontRegardless() + } + } + + private func dismissColorPanel() { + if NSColorPanel.shared.isVisible { + NSColorPanel.shared.close() + } + } + + private func deleteSelectedStop() { + guard + let index = selection.take(), + gradient.stops.indices.contains(index) + else { + return + } + gradient.stops.remove(at: index) + } + + private func distributeStops() { + guard !gradient.stops.isEmpty else { + return + } + if gradient.stops.count == 1 { + gradient.stops[0].location = 0.5 + } else { + let last = CGFloat(gradient.stops.count - 1) + let newStops = gradient.stops.lazy + .sorted { $0.location < $1.location } + .enumerated() + .map { n, stop in + stop.withLocation(CGFloat(n) / last) + } + gradient.stops = newStops + } + } +} + +private struct IceGradientPickerHandle: View { + @Binding var gradient: IceGradient + @Binding var selection: Int? + @Binding var lastUpdated: Int? + + let index: Int + let geometry: GeometryProxy + let width: CGFloat + + private var isSelected: Bool { + index == selection + } + + private var isLastUpdated: Bool { + index == lastUpdated + } + + private var stop: IceGradient.ColorStop? { + guard gradient.stops.indices.contains(index) else { + return nil + } + return gradient.stops[index] + } + + private var borderShape: some InsettableShape { + if #available(macOS 26.0, *) { + Capsule(style: .continuous) + } else { + Capsule(style: .circular) + } + } + + var body: some View { + handleView + .gesture( + DragGesture(minimumDistance: 2).onChanged { value in + update(with: value) + } + ) + .onTapGesture { + selection = isSelected ? nil : index + } + .onKeyPress(.space) { + selection = isSelected ? nil : index + return .handled + } + .onChange(of: isSelected) { _, newValue in + if newValue { + lastUpdated = index + } + } + } + + @ViewBuilder + private var handleView: some View { + if let stop { + borderShape + .fill(Color(cgColor: stop.color)) + .strokeBorder(isSelected ? AnyShapeStyle(.clear) : AnyShapeStyle(.tertiary)) + .background( + isSelected ? AnyShapeStyle(.tint) : AnyShapeStyle(.clear), + in: borderShape.inset(by: -2) + ) + .contentShape([.interaction, .focusEffect], borderShape) + .frame(width: width) + .position(x: geometry.size.width * stop.location, y: geometry.size.height / 2) + .zIndex(isLastUpdated ? 2 : stop.location) + .compositingGroup() + } + } + + private func update(with value: DragGesture.Value) { + guard gradient.stops.indices.contains(index) else { + return + } + + var location = (value.location.x / geometry.size.width).clamped(to: 0...1) + + if + !NSEvent.modifierFlags.contains(.command), + abs(value.velocity.width) <= 75 && abs(location - 0.5) <= 0.025 + { + location = 0.5 + } + + gradient.stops[index].location = location + lastUpdated = index + } +} diff --git a/Ice/UI/Pickers/CustomColorPicker/CustomColorPicker.swift b/Ice/UI/Pickers/CustomColorPicker/CustomColorPicker.swift deleted file mode 100644 index 641a4f795..000000000 --- a/Ice/UI/Pickers/CustomColorPicker/CustomColorPicker.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// CustomColorPicker.swift -// Ice -// - -import Combine -import SwiftUI - -struct CustomColorPicker: NSViewRepresentable { - final class Coordinator { - @Binding var selection: CGColor - - let supportsOpacity: Bool - let mode: NSColorPanel.Mode - - private var cancellables = Set() - - init( - selection: Binding, - supportsOpacity: Bool, - mode: NSColorPanel.Mode - ) { - self._selection = selection - self.supportsOpacity = supportsOpacity - self.mode = mode - } - - func configure(with nsView: NSColorWell) { - var c = Set() - - nsView - .publisher(for: \.color) - .removeDuplicates() - .sink { [weak self] color in - DispatchQueue.main.async { - if self?.selection != color.cgColor { - self?.selection = color.cgColor - } - } - } - .store(in: &c) - - NSColorPanel.shared - .publisher(for: \.isVisible) - .sink { [weak self, weak nsView] isVisible in - guard - let self, - let nsView, - isVisible, - nsView.isActive - else { - return - } - NSColorPanel.shared.showsAlpha = supportsOpacity - NSColorPanel.shared.mode = mode - if let window = nsView.window { - NSColorPanel.shared.level = window.level + 1 - } - if NSColorPanel.shared.frame.origin == .zero { - NSColorPanel.shared.center() - } - } - .store(in: &c) - - NSColorPanel.shared - .publisher(for: \.level) - .sink { [weak nsView] level in - guard - let nsView, - nsView.isActive, - let window = nsView.window, - level != window.level + 1 - else { - return - } - NSColorPanel.shared.level = window.level + 1 - } - .store(in: &c) - - cancellables = c - } - } - - @Binding var selection: CGColor - - let supportsOpacity: Bool - let mode: NSColorPanel.Mode - - func makeNSView(context: Context) -> NSColorWell { - let nsView = NSColorWell() - context.coordinator.configure(with: nsView) - return nsView - } - - func updateNSView(_ nsView: NSColorWell, context: Context) { - if let color = NSColor(cgColor: selection) { - nsView.color = color - } - nsView.supportsAlpha = supportsOpacity - } - - func makeCoordinator() -> Coordinator { - Coordinator( - selection: $selection, - supportsOpacity: supportsOpacity, - mode: mode - ) - } - - func sizeThatFits( - _ proposal: ProposedViewSize, - nsView: NSColorWell, - context: Context - ) -> CGSize? { - switch nsView.controlSize { - case .extraLarge: - CGSize(width: 64, height: 34) - case .large: - CGSize(width: 55, height: 30) - case .regular: - CGSize(width: 44, height: 24) - case .small: - CGSize(width: 33, height: 18) - case .mini: - CGSize(width: 29, height: 16) - @unknown default: - nsView.intrinsicContentSize - } - } -} diff --git a/Ice/UI/Pickers/CustomGradientPicker/ColorStop.swift b/Ice/UI/Pickers/CustomGradientPicker/ColorStop.swift deleted file mode 100644 index 709fe89d6..000000000 --- a/Ice/UI/Pickers/CustomGradientPicker/ColorStop.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// ColorStop.swift -// Ice -// - -import CoreGraphics - -/// A color stop in a gradient. -struct ColorStop: Hashable { - /// The color of the stop. - var color: CGColor - /// The location of the stop relative to its gradient. - var location: CGFloat - - /// Returns a copy of the color stop with the given alpha value. - func withAlphaComponent(_ alpha: CGFloat) -> ColorStop? { - guard let newColor = color.copy(alpha: alpha) else { - return nil - } - return ColorStop(color: newColor, location: location) - } -} - -extension ColorStop: Codable { - private enum CodingKeys: CodingKey { - case color - case location - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.color = try container.decode(CodableColor.self, forKey: .color).cgColor - self.location = try container.decode(CGFloat.self, forKey: .location) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(CodableColor(cgColor: color), forKey: .color) - try container.encode(location, forKey: .location) - } -} diff --git a/Ice/UI/Pickers/CustomGradientPicker/CustomGradient.swift b/Ice/UI/Pickers/CustomGradientPicker/CustomGradient.swift deleted file mode 100644 index aecf28e4a..000000000 --- a/Ice/UI/Pickers/CustomGradientPicker/CustomGradient.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// CustomGradient.swift -// Ice -// - -import SwiftUI - -/// A custom gradient for use with a ``GradientPicker``. -struct CustomGradient: View { - /// The color stops in the gradient. - var stops: [ColorStop] - - /// The color stops in the gradient, sorted by location. - var sortedStops: [ColorStop] { - stops.sorted { lhs, rhs in - lhs.location < rhs.location - } - } - - /// A Cocoa representation of this gradient. - var nsGradient: NSGradient? { - let sortedStops = sortedStops - let colors = sortedStops.compactMap { stop in - NSColor(cgColor: stop.color) - } - var locations = sortedStops.map { stop in - stop.location - } - guard colors.count == locations.count else { - return nil - } - return NSGradient( - colors: colors, - atLocations: &locations, - colorSpace: .sRGB - ) - } - - var body: some View { - GeometryReader { geometry in - if stops.isEmpty { - Color.clear - } else { - Image( - nsImage: NSImage( - size: geometry.size, - flipped: false - ) { bounds in - guard let nsGradient else { - return false - } - nsGradient.draw(in: bounds, angle: 0) - return true - } - ) - } - } - } - - /// Creates a gradient with the given unsorted stops. - /// - /// - Parameter stops: An array of color stops to sort and - /// assign as the gradient's color stops. - init(unsortedStops stops: [ColorStop]) { - self.stops = stops.sorted { $0.location < $1.location } - } - - init() { - self.init(unsortedStops: []) - } - - /// Returns the color at the given location in the gradient. - /// - /// - Parameter location: A value between 0 and 1 representing - /// the location of the color that should be returned. - func color(at location: CGFloat) -> CGColor? { - guard - let nsColor = nsGradient?.interpolatedColor(atLocation: location), - let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) - else { - return nil - } - return nsColor.cgColor.converted( - to: colorSpace, - intent: .defaultIntent, - options: nil - ) - } - - /// Returns a copy of the gradient with the given alpha value. - func withAlphaComponent(_ alpha: CGFloat) -> CustomGradient { - var copy = self - copy.stops = copy.stops.map { stop in - stop.withAlphaComponent(alpha) ?? stop - } - return copy - } -} - -extension CustomGradient { - /// The default menu bar tint gradient. - static let defaultMenuBarTint = CustomGradient( - unsortedStops: [ - ColorStop( - color: CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1), - location: 0 - ), - ColorStop( - color: CGColor(srgbRed: 0, green: 0, blue: 0, alpha: 1), - location: 1 - ), - ] - ) -} - -extension CustomGradient: Codable { } - -extension CustomGradient: Hashable { } diff --git a/Ice/UI/Pickers/CustomGradientPicker/CustomGradientPicker.swift b/Ice/UI/Pickers/CustomGradientPicker/CustomGradientPicker.swift deleted file mode 100644 index 28b569e2e..000000000 --- a/Ice/UI/Pickers/CustomGradientPicker/CustomGradientPicker.swift +++ /dev/null @@ -1,446 +0,0 @@ -// -// CustomGradientPicker.swift -// Ice -// - -import Combine -import SwiftUI - -struct CustomGradientPicker: View { - @Binding var gradient: CustomGradient - @State private var selectedStop: ColorStop? - @State private var zOrderedStops: [ColorStop] - @State private var window: NSWindow? - @State private var cancellables = Set() - - let supportsOpacity: Bool - let allowsEmptySelections: Bool - let mode: NSColorPanel.Mode - - /// Creates a new gradient picker. - /// - /// - Parameters: - /// - gradient: A binding to a gradient. - /// - supportsOpacity: A Boolean value indicating whether the - /// picker should support opacity. - /// - allowsEmptySelections: A Boolean value indicating whether - /// the picker should allow empty gradient selections. - /// - mode: The mode that the color panel should take on when - /// picking a color for the gradient. - init( - gradient: Binding, - supportsOpacity: Bool, - allowsEmptySelections: Bool, - mode: NSColorPanel.Mode - ) { - self._gradient = gradient - self.zOrderedStops = gradient.wrappedValue.stops - self.supportsOpacity = supportsOpacity - self.allowsEmptySelections = allowsEmptySelections - self.mode = mode - } - - var body: some View { - gradientView - .clipShape(borderShape) - .overlay { - borderView - } - .shadow(radius: 1) - .frame(width: 200, height: 18) - .overlay { - GeometryReader { geometry in - selectionReader(geometry: geometry) - insertionReader(geometry: geometry) - handles(geometry: geometry) - } - } - .foregroundStyle(Color(white: 0.9)) - .frame(height: 24) - .onChange(of: gradient) { _, newValue in - gradientChanged(to: newValue) - } - .onWindowChange(update: $window) - } - - @ViewBuilder - private var borderShape: some Shape { - RoundedRectangle(cornerRadius: 4, style: .circular) - } - - @ViewBuilder - private var gradientView: some View { - if gradient.stops.isEmpty { - Rectangle() - .fill(.white.gradient.opacity(0.1)) - .blendMode(.softLight) - } else { - gradient - } - } - - @ViewBuilder - private var borderView: some View { - borderShape - .stroke() - .overlay { - centerTickMark - } - .foregroundStyle(.secondary.opacity(0.75)) - .blendMode(.softLight) - } - - @ViewBuilder - private var centerTickMark: some View { - Rectangle() - .frame(width: 1, height: 6) - } - - @ViewBuilder - private func selectionReader(geometry: GeometryProxy) -> some View { - Color.clear - .localEventMonitor(mask: .leftMouseDown) { event in - guard - let window = event.window, - self.window === window - else { - return event - } - let locationInWindow = event.locationInWindow - guard window.contentLayoutRect.contains(locationInWindow) else { - return event - } - let globalFrame = geometry.frame(in: .global) - let flippedLocation = CGPoint(x: locationInWindow.x, y: window.frame.height - locationInWindow.y) - if !globalFrame.contains(flippedLocation) { - selectedStop = nil - } - return event - } - } - - @ViewBuilder - private func insertionReader(geometry: GeometryProxy) -> some View { - Color.clear - .contentShape(borderShape) - .gesture( - DragGesture(minimumDistance: 0, coordinateSpace: .local) - .onEnded { value in - guard abs(value.translation.width) <= 2 else { - return - } - let frame = geometry.frame(in: .local) - guard frame.contains(value.location) else { - return - } - let x = value.location.x - let width = frame.width - 10 - let location = (x / width) - (6 / width) - insertStop(at: location, select: true) - } - ) - } - - @ViewBuilder - private func handles(geometry: GeometryProxy) -> some View { - ForEach(gradient.stops.indices, id: \.self) { index in - CustomGradientPickerHandle( - gradient: $gradient, - selectedStop: $selectedStop, - zOrderedStops: $zOrderedStops, - cancellables: $cancellables, - index: index, - supportsOpacity: supportsOpacity, - mode: mode, - geometry: geometry - ) - } - } - - /// Inserts a new stop with the appropriate color at the given location - /// in the gradient. - private func insertStop(at location: CGFloat, select: Bool) { - var location = location.clamped(to: 0...1) - if (0.48...0.52).contains(location) { - location = 0.5 - } - let newStop: ColorStop = if - !gradient.stops.isEmpty, - let color = gradient.color(at: location) - { - ColorStop(color: color, location: location) - } else { - ColorStop(color: .black, location: location) - } - gradient.stops.append(newStop) - if select { - DispatchQueue.main.async { - self.selectedStop = newStop - } - } - } - - private func gradientChanged(to gradient: CustomGradient) { - if allowsEmptySelections { - return - } - if gradient.stops.isEmpty { - self.gradient = .defaultMenuBarTint - } else if gradient.stops.count == 1 { - var gradient = gradient - if gradient.stops[0].location >= 0.5 { - gradient.stops[0].location = 1 - let stop = ColorStop(color: .white, location: 0) - gradient.stops.append(stop) - } else { - gradient.stops[0].location = 0 - let stop = ColorStop(color: .black, location: 1) - gradient.stops.append(stop) - } - self.gradient = gradient - } - } -} - -private struct CustomGradientPickerHandle: View { - @Binding var gradient: CustomGradient - @Binding var selectedStop: ColorStop? - @Binding var zOrderedStops: [ColorStop] - @Binding var cancellables: Set - @State private var canActivate = true - - let index: Int - let supportsOpacity: Bool - let mode: NSColorPanel.Mode - let geometry: GeometryProxy - let width: CGFloat = 8 - let height: CGFloat = 22 - - private var stop: ColorStop? { - get { - guard gradient.stops.indices.contains(index) else { - return nil - } - return gradient.stops[index] - } - nonmutating set { - guard gradient.stops.indices.contains(index) else { - return - } - if let newValue { - gradient.stops[index] = newValue - } else { - gradient.stops.remove(at: index) - } - } - } - - var body: some View { - if let stop { - handleView(cgColor: stop.color) - .overlay { - borderView - } - .frame(width: width, height: height) - .overlay { - selectionIndicator(isSelected: selectedStop == stop) - } - .offset( - x: (geometry.size.width - width) * stop.location, - y: (geometry.size.height - height) / 2 - ) - .shadow(radius: 1) - .gesture( - DragGesture(minimumDistance: 5) - .onChanged { value in - update( - with: value.location.x, - shouldSnap: abs(value.velocity.width) <= 75 - ) - } - .onEnded { value in - update( - with: value.location.x, - shouldSnap: true - ) - } - ) - .onTapGesture(count: 2) { - if gradient.stops.count == 1 { - gradient.stops[0].location = 0.5 - } else { - let last = CGFloat(gradient.stops.count - 1) - gradient.stops = gradient.sortedStops - .enumerated() - .map { n, stop in - var stop = stop - stop.location = CGFloat(n) / last - return stop - } - } - } - .onTapGesture { - selectedStop = stop - } - .zIndex(Double(zOrderedStops.firstIndex(of: stop) ?? 0)) - .onChange(of: selectedStop == stop) { - deactivate() - DispatchQueue.main.async { - if self.selectedStop == stop { - activate() - } - } - } - .onKeyDown(key: .escape) { - selectedStop = nil - } - .onKeyDown(key: .delete) { - deleteSelectedStop() - } - } - } - - @ViewBuilder - private func handleView(cgColor: CGColor) -> some View { - Capsule() - .inset(by: -1) - .fill(Color(cgColor: cgColor)) - } - - @ViewBuilder - private var borderView: some View { - Capsule() - .inset(by: -1) - .stroke() - .foregroundStyle(.secondary.opacity(0.75)) - .blendMode(.softLight) - } - - @ViewBuilder - private func selectionIndicator(isSelected: Bool) -> some View { - if isSelected { - Capsule() - .inset(by: -1.5) - .stroke(.primary, lineWidth: 1.5) - } - } - - private func update(with location: CGFloat, shouldSnap: Bool) { - guard var stop else { - return - } - let newLocation = (location - (width / 2)) / (geometry.size.width - width) - if let index = zOrderedStops.firstIndex(of: stop) { - zOrderedStops.remove(at: index) - } - let isSelected = selectedStop == stop - if - shouldSnap, - (0.48...0.52).contains(newLocation) - { - stop.location = 0.5 - } else { - stop.location = min(1, max(0, newLocation)) - } - self.stop = stop - if isSelected { - selectedStop = stop - } - zOrderedStops.append(stop) - } - - private func activate() { - guard canActivate else { - return - } - - deactivate() - - NSColorPanel.shared.showsAlpha = supportsOpacity - NSColorPanel.shared.mode = mode - if let color = stop.flatMap({ NSColor(cgColor: $0.color) }) { - NSColorPanel.shared.color = color - } - NSColorPanel.shared.orderFrontRegardless() - - if let index = stop.flatMap(zOrderedStops.firstIndex) { - zOrderedStops.append(zOrderedStops.remove(at: index)) - } - - var c = Set() - - NSColorPanel.shared.publisher(for: \.color) - .receive(on: DispatchQueue.main) - .dropFirst() - .sink { color in - canActivate = false - defer { - canActivate = true - } - if stop?.color != color.cgColor { - stop?.color = color.cgColor - selectedStop = stop - } - } - .store(in: &c) - - NSColorPanel.shared.publisher(for: \.isVisible) - .sink { isVisible in - if isVisible { - if NSColorPanel.shared.frame.origin == .zero { - NSColorPanel.shared.center() - } - } else { - selectedStop = nil - } - } - .store(in: &c) - - cancellables = c - } - - private func deactivate() { - for cancellable in cancellables { - cancellable.cancel() - } - cancellables.removeAll() - NSColorPanel.shared.close() - } - - private func deleteSelectedStop() { - deactivate() - guard - let selectedStop, - let index = gradient.stops.firstIndex(of: selectedStop) - else { - return - } - gradient.stops.remove(at: index) - self.selectedStop = nil - } -} - -#if DEBUG -private struct CustomGradientPickerPreview: View { - @State private var gradient = CustomGradient(unsortedStops: [ - ColorStop(color: NSColor.systemRed.cgColor, location: 0), - ColorStop(color: NSColor.systemBlue.cgColor, location: 1 / 3), - ColorStop(color: NSColor.systemIndigo.cgColor, location: 2 / 3), - ColorStop(color: NSColor.systemPurple.cgColor, location: 1), - ]) - - var body: some View { - CustomGradientPicker( - gradient: $gradient, - supportsOpacity: false, - allowsEmptySelections: false, - mode: .crayon - ) - } -} - -#Preview { - CustomGradientPickerPreview() - .padding() -} -#endif diff --git a/Ice/Utilities/CodableColor.swift b/Ice/UI/Utilities/IceColor.swift similarity index 92% rename from Ice/Utilities/CodableColor.swift rename to Ice/UI/Utilities/IceColor.swift index 8f50ed8cb..30dbe42e3 100644 --- a/Ice/Utilities/CodableColor.swift +++ b/Ice/UI/Utilities/IceColor.swift @@ -1,19 +1,19 @@ // -// CodableColor.swift +// IceColor.swift // Ice // import CoreGraphics import Foundation -/// A Codable wrapper around a CGColor. -struct CodableColor { - /// The CGColor contained within the wrapper. +/// A custom color. +struct IceColor: Hashable { + /// The color, represented as a `CGColor`. var cgColor: CGColor } -// MARK: CodableColor: Codable -extension CodableColor: Codable { +// MARK: IceColor: Codable +extension IceColor: Codable { private enum CodingKeys: CodingKey { case components case colorSpace diff --git a/Ice/UI/Utilities/IceGradient.swift b/Ice/UI/Utilities/IceGradient.swift new file mode 100644 index 000000000..bd37ab9f0 --- /dev/null +++ b/Ice/UI/Utilities/IceGradient.swift @@ -0,0 +1,257 @@ +// +// IceGradient.swift +// Ice +// + +import SwiftUI + +// MARK: - IceGradient + +/// A custom gradient. +struct IceGradient: Codable, Hashable { + /// The color stops in the gradient. + var stops: [ColorStop] + + /// Creates a gradient with the given array of color stops. + /// + /// - Parameter stops: An array of color stops. + init(stops: [ColorStop] = []) { + self.stops = stops + } + + /// Returns a copy of the gradient with the given alpha value. + func withAlpha(_ alpha: CGFloat) -> IceGradient { + let newStops = stops.map { $0.withAlpha(alpha) } + return IceGradient(stops: newStops) + } + + /// Returns a Cocoa representation of the gradient, converted to the + /// given color space. + /// + /// - Parameter colorSpace: The color space to convert the gradient to. + func nsGradient(using colorSpace: NSColorSpace) -> NSGradient? { + guard !stops.isEmpty else { + return nil + } + + var colors = [NSColor]() + var locations = [CGFloat]() + + for stop in stops { + guard let color = NSColor(cgColor: stop.color) else { + continue + } + colors.append(color) + locations.append(stop.location) + } + + return NSGradient(colors: colors, atLocations: &locations, colorSpace: colorSpace) + } + + /// Returns a SwiftUI representation of the gradient, converted to the + /// given color space. + /// + /// - Parameter colorSpace: The color space to convert the gradient to. + func swiftUIView(using colorSpace: Color.RGBColorSpace) -> some View { + GeometryReader { geometry in + if stops.isEmpty { + Color.clear + } else if let space = colorSpace.nsColorSpace { + Image(nsImage: NSImage(size: geometry.size, flipped: false) { bounds in + guard let gradient = nsGradient(using: space) else { + return false + } + gradient.draw(in: bounds, angle: 0) + return true + }) + } + } + } + + /// Returns the color at the given location in the gradient. + /// + /// This method does not simply return the color of the nearest color + /// stop. Instead, it computes the actual rendered color at `location`. + /// + /// - Parameters: + /// - location: A value between 0 and 1 representing the location + /// of the color to return. + /// - colorSpace: The color space used to process the colors in the + /// gradient. The returned color also uses this color space. + func color(at location: CGFloat, using colorSpace: CGColorSpace) -> CGColor? { + guard + let space = NSColorSpace(cgColorSpace: colorSpace), + let gradient = nsGradient(using: space) + else { + return nil + } + return gradient.interpolatedColor(atLocation: location).cgColor + } + + /// Returns the color at the given location in the gradient. + /// + /// This method does not simply return the color of the nearest color + /// stop. Instead, it computes the actual rendered color at `location`. + /// + /// This method uses the extended Display P3 color space to process the + /// colors in the gradient. The same color space is also used to create + /// the returned color. Converting the color to a different color space + /// may produce unexpected results. Prefer ``color(at:using:)`` if you + /// need the color returned in a different color space. + /// + /// - Parameter location: A value between 0 and 1 representing the + /// location of the color to return. + func color(at location: CGFloat) -> CGColor? { + guard let space = Color.RGBColorSpace.displayP3.cgColorSpace else { + return nil + } + return color(at: location, using: space) + } + + /// Returns the average color of the gradient. + /// + /// - Parameters: + /// - colorSpace: The color space used to process the colors in the + /// gradient. The returned color also uses this color space. Must + /// be an RGB color space, or this parameter is ignored. Pass `nil` + /// to let the method decide the color space. + /// - option: Options for computing the color. + func averageColor(using colorSpace: CGColorSpace? = nil, option: CGImage.ColorAverageOption = []) -> CGColor? { + guard !stops.isEmpty else { + return nil + } + + let colorSpace: CGColorSpace = { + if let colorSpace, colorSpace.model == .rgb { + return colorSpace + } + if let colorSpace = Color.RGBColorSpace.displayP3.cgColorSpace { + return colorSpace + } + return CGColorSpaceCreateDeviceRGB() + }() + + let colors = stride(from: 0, through: 1, by: 1 / CGFloat(stops.count)).compactMap { location in + color(at: location, using: colorSpace) + } + + var totals: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) = (0, 0, 0, 0) + var count: CGFloat = 0 + + for color in colors { + guard let components = color.components else { + continue + } + totals.red += components[0] + totals.green += components[1] + totals.blue += components[2] + totals.alpha += components[3] + count += 1 + } + + var components: [CGFloat] = [ + totals.red / count, + totals.green / count, + totals.blue / count, + option.contains(.ignoreAlpha) ? 1 : (totals.alpha / count), + ] + + return CGColor(colorSpace: colorSpace, components: &components) + } +} + +// MARK: IceGradient Static Members +extension IceGradient { + /// The default menu bar tint gradient. + static let defaultMenuBarTint = IceGradient(stops: [ + ColorStop.white(location: 0), + ColorStop.black(location: 1), + ]) +} + +// MARK: - IceGradient.ColorStop + +extension IceGradient { + /// A color stop in a gradient. + struct ColorStop: Hashable { + /// The stop's color. + var color: CGColor + /// The stop's relative location in a gradient. + var location: CGFloat + + /// Returns a stop with the given color and location. + static func stop(_ color: CGColor, location: CGFloat) -> ColorStop { + ColorStop(color: color, location: location) + } + + /// Returns a stop with a white color suitable for use in a gradient. + static func white(location: CGFloat) -> ColorStop { + let srgbWhite = CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) + return ColorStop(color: srgbWhite, location: location) + } + + /// Returns a stop with a black color suitable for use in a gradient. + static func black(location: CGFloat) -> ColorStop { + let srgbBlack = CGColor(srgbRed: 0, green: 0, blue: 0, alpha: 1) + return ColorStop(color: srgbBlack, location: location) + } + + /// Returns a copy of the stop with the given alpha value. + func withAlpha(_ alpha: CGFloat) -> ColorStop { + let newColor = color.copy(alpha: alpha) ?? color + return ColorStop(color: newColor, location: location) + } + + /// Returns a copy of the stop with the given location. + func withLocation(_ location: CGFloat) -> ColorStop { + ColorStop(color: color, location: location) + } + } +} + +// MARK: IceGradient.ColorStop: Codable +extension IceGradient.ColorStop: Codable { + private enum CodingKeys: CodingKey { + case color + case location + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.color = try container.decode(IceColor.self, forKey: .color).cgColor + self.location = try container.decode(CGFloat.self, forKey: .location) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(IceColor(cgColor: color), forKey: .color) + try container.encode(location, forKey: .location) + } +} + +// MARK: - Color Space Helpers + +private extension Color.RGBColorSpace { + var cgColorSpaceName: CFString? { + switch self { + case .sRGB: CGColorSpace.extendedSRGB + case .sRGBLinear: CGColorSpace.extendedLinearSRGB + case .displayP3: CGColorSpace.extendedDisplayP3 + @unknown default: nil + } + } + + var cgColorSpace: CGColorSpace? { + guard let name = cgColorSpaceName else { + return nil + } + return CGColorSpace(name: name) + } + + var nsColorSpace: NSColorSpace? { + guard let space = cgColorSpace else { + return nil + } + return NSColorSpace(cgColorSpace: space) + } +} diff --git a/Ice/UI/ViewModifiers/LayoutBarStyle.swift b/Ice/UI/ViewModifiers/LayoutBarStyle.swift deleted file mode 100644 index 67c3783c9..000000000 --- a/Ice/UI/ViewModifiers/LayoutBarStyle.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// LayoutBarStyle.swift -// Ice -// - -import SwiftUI - -extension View { - /// Returns a view that is drawn in the style of a layout bar. - /// - /// - Note: The view this modifier is applied to must be transparent, or the style - /// will be drawn incorrectly. - @ViewBuilder - func layoutBarStyle(appState: AppState, averageColorInfo: MenuBarAverageColorInfo?) -> some View { - background { - if appState.isActiveSpaceFullscreen { - Color.black - } else if let averageColorInfo { - switch averageColorInfo.source { - case .menuBarWindow: - Color(cgColor: averageColorInfo.color) - .overlay( - Material.bar - .opacity(0.2) - .blendMode(.softLight) - ) - case .desktopWallpaper: - Color(cgColor: averageColorInfo.color) - .overlay( - Material.bar - .opacity(0.5) - .blendMode(.softLight) - ) - } - } else { - Color.defaultLayoutBar - } - } - .overlay { - if !appState.isActiveSpaceFullscreen { - switch appState.appearanceManager.configuration.current.tintKind { - case .none: - EmptyView() - case .solid: - Color(cgColor: appState.appearanceManager.configuration.current.tintColor) - .opacity(0.2) - .allowsHitTesting(false) - case .gradient: - appState.appearanceManager.configuration.current.tintGradient - .opacity(0.2) - .allowsHitTesting(false) - } - } - } - } -} diff --git a/Ice/UI/ViewModifiers/LocalEventMonitorModifier.swift b/Ice/UI/ViewModifiers/LocalEventMonitorModifier.swift index a6ca6ce61..75acc4ffe 100644 --- a/Ice/UI/ViewModifiers/LocalEventMonitorModifier.swift +++ b/Ice/UI/ViewModifiers/LocalEventMonitorModifier.swift @@ -3,46 +3,62 @@ // Ice // +import Combine import SwiftUI -private final class LocalEventMonitorModifierState: ObservableObject { - let monitor: LocalEventMonitor +private struct LocalEventMonitorModifier: ViewModifier { + @MainActor + private final class Model: ObservableObject { + @Published var isEnabled = false - init(mask: NSEvent.EventTypeMask, action: @escaping (NSEvent) -> NSEvent?) { - self.monitor = LocalEventMonitor(mask: mask, handler: action) - self.monitor.start() - } + private let monitor: EventMonitor + private var cancellable: AnyCancellable? + + init(mask: NSEvent.EventTypeMask, action: @escaping (NSEvent) -> NSEvent?) { + self.monitor = EventMonitor.local(for: mask, handler: action) + self.cancellable = $isEnabled.receive(on: DispatchQueue.main).sink { [weak self] isEnabled in + guard let self else { + return + } + if isEnabled { + monitor.start() + } else { + monitor.stop() + } + } + } - deinit { - monitor.stop() + deinit { + monitor.stop() + } } -} -private struct LocalEventMonitorModifier: ViewModifier { - @StateObject private var state: LocalEventMonitorModifierState + @StateObject private var model: Model + @Binding var isEnabled: Bool - init(mask: NSEvent.EventTypeMask, action: @escaping (NSEvent) -> NSEvent?) { - let state = LocalEventMonitorModifierState(mask: mask, action: action) - self._state = StateObject(wrappedValue: state) + init(mask: NSEvent.EventTypeMask, isEnabled: Binding, action: @escaping (NSEvent) -> NSEvent?) { + self._model = StateObject(wrappedValue: Model(mask: mask, action: action)) + self._isEnabled = isEnabled } func body(content: Content) -> some View { - content + content.onChange(of: isEnabled, initial: true) { _, newValue in + model.isEnabled = newValue + } } } extension View { - /// Returns a view that performs the given action when events - /// specified by the given mask are received. + /// Returns a view that performs the given action when events corresponding + /// to the given event type mask are received. /// /// - Parameters: /// - mask: An event type mask specifying which events to monitor. - /// - action: An action to perform when the event monitor receives - /// an event corresponding to the event types in `mask`. - func localEventMonitor( - mask: NSEvent.EventTypeMask, - action: @escaping (NSEvent) -> NSEvent? - ) -> some View { - modifier(LocalEventMonitorModifier(mask: mask, action: action)) + /// - isEnabled: A Boolean value that determines whether the event monitor + /// is enabled. + /// - action: An action to perform when the event monitor receives events + /// corresponding to `mask`. + func localEventMonitor(mask: NSEvent.EventTypeMask, isEnabled: Bool = true, action: @escaping (NSEvent) -> NSEvent?) -> some View { + modifier(LocalEventMonitorModifier(mask: mask, isEnabled: .constant(isEnabled), action: action)) } } diff --git a/Ice/UI/ViewModifiers/OnKeyDown.swift b/Ice/UI/ViewModifiers/OnKeyDown.swift index a28759180..e11f01d93 100644 --- a/Ice/UI/ViewModifiers/OnKeyDown.swift +++ b/Ice/UI/ViewModifiers/OnKeyDown.swift @@ -8,13 +8,33 @@ import SwiftUI extension View { /// Returns a view that performs the given action when /// the specified key is pressed. - func onKeyDown(key: KeyCode, action: @escaping () -> Void) -> some View { - localEventMonitor(mask: .keyDown) { event in + func onKeyDown( + key: KeyCode, + isEnabled: Bool = true, + action: @escaping () -> KeyCode.PressResult + ) -> some View { + localEventMonitor(mask: .keyDown, isEnabled: isEnabled) { event in if event.keyCode == key.rawValue { - action() - return nil + return switch action() { + case .handled: nil + case .ignored: event + } } return event } } } + +extension KeyCode { + /// A result value from a key press action that indicates + /// whether the action consumed the event. + enum PressResult { + /// The action consumed the event, preventing dispatch + /// from continuing. + case handled + + /// The action ignored the event, allowing dispatch to + /// continue. + case ignored + } +} diff --git a/Ice/UI/Views/DismissWindowButton.swift b/Ice/UI/Views/DismissWindowButton.swift new file mode 100644 index 000000000..c19ce39a5 --- /dev/null +++ b/Ice/UI/Views/DismissWindowButton.swift @@ -0,0 +1,49 @@ +// +// DismissWindowButton.swift +// Ice +// + +import SwiftUI + +struct DismissWindowButton: View { + @State private var dismissWindow: (() -> Void)? + + private let label: Label + + init(@ViewBuilder label: () -> Label) { + self.label = label() + } + + init(_ titleKey: LocalizedStringKey) where Label == Text { + self.label = Text(titleKey) + } + + private var role: ButtonRole? { + if #available(macOS 26.0, *) { + return .close + } else { + return nil + } + } + + var body: some View { + Button(role: role) { + dismissWindow?() + } label: { + label + } + .onWindowChange { window in + updateAction(with: window) + } + } + + private func updateAction(with window: NSWindow?) { + guard let window else { + dismissWindow = nil + return + } + dismissWindow = { [weak window] in + window?.close() + } + } +} diff --git a/Ice/UI/Views/HotkeyRecorder.swift b/Ice/UI/Views/HotkeyRecorder.swift index 1cba6a199..87175bf8e 100644 --- a/Ice/UI/Views/HotkeyRecorder.swift +++ b/Ice/UI/Views/HotkeyRecorder.swift @@ -13,14 +13,6 @@ struct HotkeyRecorder: View { private let label: Label - private var size: CGSize { - if #available(macOS 26.0, *) { - CGSize(width: 140, height: 24) - } else { - CGSize(width: 132, height: 24) - } - } - init(hotkey: Hotkey, @ViewBuilder label: () -> Label) { self._model = StateObject(wrappedValue: HotkeyRecorderModel(hotkey: hotkey)) self.label = label() @@ -48,13 +40,17 @@ struct HotkeyRecorder: View { leadingSegment trailingSegment } - .frame(width: size.width, height: size.height) + .frame(width: 132, height: 24) } @ViewBuilder private var leadingSegment: some View { Button { - model.startRecording() + if model.isRecording { + model.stopRecording() + } else { + model.startRecording() + } } label: { leadingSegmentLabel } @@ -132,7 +128,7 @@ private final class HotkeyRecorderModel: ObservableObject { let hotkey: Hotkey - private lazy var monitor = LocalEventMonitor(mask: .keyDown) { [weak self] event in + private lazy var monitor = EventMonitor.local(for: .keyDown) { [weak self] event in guard let self else { return event } @@ -228,7 +224,7 @@ private struct HotkeyRecorderButtonStyle: ButtonStyle { } func makeBody(configuration: Configuration) -> some View { - let isProminent = isHighlighted || configuration.isPressed + let isProminent = configuration.isPressed != isHighlighted borderShape .fill(isProminent ? .tertiary : .quaternary) .opacity(isProminent ? 0.5 : 0.75) diff --git a/Ice/UI/Views/MenuBarItemContainer.swift b/Ice/UI/Views/MenuBarItemContainer.swift new file mode 100644 index 000000000..99e113934 --- /dev/null +++ b/Ice/UI/Views/MenuBarItemContainer.swift @@ -0,0 +1,117 @@ +// +// MenuBarItemContainer.swift +// Ice +// + +import SwiftUI + +/// A view that is drawn in the style of the menu bar. +/// +/// - Important: This view performs drawing on layers above and +/// below the content view. The resulting view will probably look +/// incorrect if the content view's background is not transparent. +struct MenuBarItemContainer: View { + enum ColorInfoAccessor { + case automatic + case manual(MenuBarAverageColorInfo?) + } + + @ObservedObject private var appState: AppState + @ObservedObject private var appearanceManager: MenuBarAppearanceManager + @ObservedObject private var menuBarManager: MenuBarManager + + private let accessor: ColorInfoAccessor + private let content: Content + + private var colorInfo: MenuBarAverageColorInfo? { + switch accessor { + case .automatic: + menuBarManager.averageColorInfo + case .manual(let colorInfo): + colorInfo + } + } + + private var foreground: Color { + colorInfo?.isBright == true ? .black : .white + } + + private var configuration: MenuBarAppearancePartialConfiguration { + appearanceManager.configuration.current + } + + init(appState: AppState, accessor: ColorInfoAccessor, @ViewBuilder content: () -> Content) { + self.appState = appState + self.appearanceManager = appState.appearanceManager + self.menuBarManager = appState.menuBarManager + self.accessor = accessor + self.content = content() + } + + var body: some View { + content + .foregroundStyle(foreground) + .background { + contentBackground + } + .overlay { + contentOverlay + .opacity(0.2) + .allowsHitTesting(false) + } + } + + @ViewBuilder + private var contentBackground: some View { + if appState.activeSpace.isFullscreen { + Color.black + } else if let colorInfo { + Color(cgColor: colorInfo.color) + } else { + Color.defaultLayoutBar + } + } + + @ViewBuilder + private var contentOverlay: some View { + if !appState.activeSpace.isFullscreen { + if case .solid = configuration.tintKind { + Color(cgColor: configuration.tintColor) + } else if + case .gradient = configuration.tintKind, + let color = configuration.tintGradient.averageColor() + { + Color(cgColor: color) + } + } + } +} + +extension View { + /// Draws the view in the style of the menu bar. + /// + /// - Important: This modifier performs drawing on layers above and + /// below the current view. The resulting view will probably look + /// incorrect if the current view's background is not transparent. + /// + /// - Parameter appState: The shared ``AppState`` object. + func menuBarItemContainer(appState: AppState) -> some View { + MenuBarItemContainer(appState: appState, accessor: .automatic) { self } + } + + /// Draws the view in the style of the menu bar. + /// + /// This modifier ignores the ``MenuBarManager/averageColorInfo`` + /// property, and instead uses the provided color information. + /// + /// - Important: This modifier performs drawing on layers above and + /// below the current view. The resulting view will probably look + /// incorrect if the current view's background is not transparent. + /// + /// - Parameters: + /// - appState: The shared ``AppState`` object. + /// - colorInfo: Information for the average color of the menu bar. + func menuBarItemContainer(appState: AppState, colorInfo: MenuBarAverageColorInfo?) -> some View { + MenuBarItemContainer(appState: appState, accessor: .manual(colorInfo)) { self } + } +} diff --git a/Ice/UI/Views/SectionedList.swift b/Ice/UI/Views/SectionedList.swift index 6737908f0..c66d21107 100644 --- a/Ice/UI/Views/SectionedList.swift +++ b/Ice/UI/Views/SectionedList.swift @@ -74,24 +74,27 @@ struct SectionedList: View { } } .scrollIndicatorsFlash(trigger: scrollIndicatorsFlashTrigger) - .onKeyDown(key: .downArrow) { + .onKeyDown(key: .downArrow, isEnabled: selection != nil) { DispatchQueue.main.async { if let nextSelectableItem { selection = nextSelectableItem.id } } + return .handled } - .onKeyDown(key: .upArrow) { + .onKeyDown(key: .upArrow, isEnabled: selection != nil) { DispatchQueue.main.async { if let previousSelectableItem { selection = previousSelectableItem.id } } + return .handled } - .onKeyDown(key: .return) { + .onKeyDown(key: .return, isEnabled: selection != nil) { DispatchQueue.main.async { items.first { $0.id == selection }?.action?() } + return .handled } .task { scrollIndicatorsFlashTrigger += 1 @@ -144,7 +147,7 @@ struct SectionedList: View { extension SectionedList { /// Sets the padding of the sectioned list's content. func contentPadding(_ insets: EdgeInsets) -> SectionedList { - with(self) { copy in + withMutableCopy(of: self) { copy in copy.contentPadding = insets } } @@ -192,7 +195,7 @@ private struct SectionedListItemView: View { environment.colorScheme == .light, selection == item.id { - Color.primary.resolve(in: with(environment) { $0.colorScheme = .dark }) + Color.primary.resolve(in: withMutableCopy(of: environment) { $0.colorScheme = .dark }) } else { Color.primary.resolve(in: environment) } diff --git a/Ice/Utilities/Extensions.swift b/Ice/Utilities/Extensions.swift index 9265b9c36..c607df50f 100644 --- a/Ice/Utilities/Extensions.swift +++ b/Ice/Utilities/Extensions.swift @@ -71,15 +71,30 @@ extension CGImage { // MARK: Average Color + /// Options that effect how colors are processed when computing + /// an average color. + struct ColorAverageOption: OptionSet { + let rawValue: Int + + /// Includes the alpha component in the resulting average. + static let ignoreAlpha = ColorAverageOption(rawValue: 1 << 0) + } + /// Computes and returns the average color of the image. /// /// - Parameters: - /// - alphaThreshold: An alpha value below which pixels should be ignored. Pixels with - /// an alpha component greater than or equal to this value contribute to the average. - /// - makeOpaque: A Boolean value that indicates whether the resulting color should be - /// made opaque, regardless of the alpha content of the image. - func averageColor(alphaThreshold: CGFloat = 0.5, makeOpaque: Bool = false) -> CGColor? { - func createPixelData(width: Int, height: Int) -> [UInt32]? { + /// - colorSpace: The color space used to process the colors in the image. + /// The returned color also uses this color space. Must be an RGB color + /// space, or this parameter is ignored. + /// - alphaThreshold: An alpha value below which pixels should be ignored. + /// Pixels with an alpha component greater than or equal to this value + /// contribute to the average. + /// - option: Options for computing the color. + func averageColor(using colorSpace: CGColorSpace? = nil, alphaThreshold: CGFloat = 0.5, option: ColorAverageOption = []) -> CGColor? { + func createPixelData(width: Int, height: Int, colorSpace: CGColorSpace) -> [UInt32]? { + guard width > 0 && height > 0 else { + return nil + } var data = [UInt32](repeating: 0, count: width * height) guard let context = CGContext( data: &data, @@ -87,8 +102,8 @@ extension CGImage { height: height, bitsPerComponent: 8, bytesPerRow: width * 4, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGImageByteOrderInfo.order32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue + space: colorSpace, + bitmapInfo: CGBitmapInfo(alpha: .premultipliedFirst, byteOrder: .order32Little) ) else { return nil } @@ -96,54 +111,70 @@ extension CGImage { return data } - func computeComponent(shift: UInt32, pixel: UInt32) -> Int { - return Int((pixel >> shift) & 255) + func computeComponent(pixel: UInt32, shift: UInt32) -> UInt64 { + UInt64((pixel >> shift) & 255) } + let colorSpace: CGColorSpace = { + if let colorSpace, colorSpace.model == .rgb { + return colorSpace + } + if let colorSpace = self.colorSpace, colorSpace.model == .rgb { + return colorSpace + } + if let colorSpace = CGColorSpace(name: CGColorSpace.displayP3) { + return colorSpace + } + return CGColorSpaceCreateDeviceRGB() + }() + // Resize the image for better performance. let width = min(width, 10) let height = min(height, 10) - guard let pixelData = createPixelData(width: width, height: height) else { + guard let pixelData = createPixelData(width: width, height: height, colorSpace: colorSpace) else { return nil } // Convert the alpha threshold to a valid component for comparison. - let alphaThreshold = Int((alphaThreshold.clamped(to: 0...1) * 255).rounded(.toNearestOrAwayFromZero)) + let alphaThreshold = UInt64((alphaThreshold.clamped(to: 0...1) * 255).rounded(.toNearestOrAwayFromZero)) - var includedPixelCount = width * height - var totals = (red: 0, green: 0, blue: 0, alpha: 0) + var count = UInt64(width * height) + var totals: (r: UInt64, g: UInt64, b: UInt64, a: UInt64) = (0, 0, 0, 0) for column in 0..= alphaThreshold else { - includedPixelCount -= 1 // Don't include this pixel. + guard alpha >= alphaThreshold else { + count -= 1 // Don't include this pixel. continue } - // Add the components to the totals. - totals.red += computeComponent(shift: 16, pixel: pixel) - totals.green += computeComponent(shift: 8, pixel: pixel) - totals.blue += computeComponent(shift: 0, pixel: pixel) - totals.alpha += alphaComponent + totals.r += computeComponent(pixel: pixel, shift: 16) + totals.g += computeComponent(pixel: pixel, shift: 8) + totals.b += computeComponent(pixel: pixel, shift: 0) + totals.a += alpha } } - // Multiply the included pixel count by 255 to convert the components - // to their corresponding floating point values. - let adjustedPixelCount = CGFloat(includedPixelCount * 255) + // Components are currently in integer format (0 to 255), but need + // to be converted to floating point (0 to 1). Makes more sense to + // scale the count up to match the components, rather than scale + // the components down to match the count. + let scaledCount = CGFloat(count * 255) - return CGColor( - red: CGFloat(totals.red) / adjustedPixelCount, - green: CGFloat(totals.green) / adjustedPixelCount, - blue: CGFloat(totals.blue) / adjustedPixelCount, - alpha: makeOpaque ? 1 : CGFloat(totals.alpha) / adjustedPixelCount - ) + var components: [CGFloat] = [ + CGFloat(totals.r) / scaledCount, + CGFloat(totals.g) / scaledCount, + CGFloat(totals.b) / scaledCount, + option.contains(.ignoreAlpha) ? 1 : CGFloat(totals.a) / scaledCount, + ] + + return CGColor(colorSpace: colorSpace, components: &components) } // MARK: Trim Transparent Pixels @@ -301,24 +332,6 @@ extension CGImage { } } -// MARK: - CGPoint - -extension CGPoint { - /// Returns the distance between this point and another point. - func distance(to other: CGPoint) -> CGFloat { - hypot(x - other.x, y - other.y) - } -} - -// MARK: - CGRect - -extension CGRect { - /// The center point of the rectangle. - var center: CGPoint { - CGPoint(x: midX, y: midY) - } -} - // MARK: - Collection where Element == MenuBarItem extension Collection where Element == MenuBarItem { @@ -332,15 +345,29 @@ extension Collection where Element == MenuBarItem { // MARK: - Comparable extension Comparable { - /// Returns a copy of this value that has been clamped within the bounds - /// of the given limiting range. + /// Clamps this value to the given limiting range. + /// + /// - Parameter limits: A range of values to clamp this value to. + mutating func clamp(to limits: ClosedRange) { + self = min(max(self, limits.lowerBound), limits.upperBound) + } + + /// Returns a copy of this value, clamped to the given limiting + /// range. /// - /// - Parameter limits: A closed range within which to clamp this value. + /// - Parameter limits: A range of values to clamp the copy to. func clamped(to limits: ClosedRange) -> Self { - min(max(self, limits.lowerBound), limits.upperBound) + withMutableCopy(of: self) { $0.clamp(to: limits) } } } +// MARK: - DistributedNotificationCenter + +extension DistributedNotificationCenter { + /// A notification posted whenever the system-wide interface theme changes. + static let interfaceThemeChangedNotification = Notification.Name("AppleInterfaceThemeChangedNotification") +} + // MARK: - EdgeInsets extension EdgeInsets { @@ -466,6 +493,45 @@ extension NSScreen { let menuBarWindow = WindowInfo.menuBarWindow(for: displayID) return menuBarWindow?.bounds.height } + + /// Returns the frame of the application menu on this screen. + func getApplicationMenuFrame() -> CGRect? { + let displayBounds = CGDisplayBounds(displayID) + + guard + let menuBar = try? systemWideElement.elementAtPosition(displayBounds.origin), + let role = try? menuBar.role(), + role == .menuBar + else { + return nil + } + + let applicationMenuFrame = menuBar.children.reduce(CGRect.null) { result, child in + guard child.isEnabled, let childFrame = child.frame else { + return result + } + return result.union(childFrame) + } + + if applicationMenuFrame.width <= 0 { + return nil + } + + // The Accessibility API returns the menu bar for the active screen, regardless of the + // display origin used. This workaround prevents an incorrect frame from being returned + // for inactive displays in multi-display setups where one display has a notch. + if + let mainScreen = NSScreen.main, + self != mainScreen, + let notchedScreen = NSScreen.screens.first(where: { $0.hasNotch }), + let leftArea = notchedScreen.auxiliaryTopLeftArea, + applicationMenuFrame.width >= leftArea.maxX + { + return nil + } + + return applicationMenuFrame + } } // MARK: - NSStatusItem @@ -503,6 +569,10 @@ extension Publisher { replace { output } } + func removeNil() -> Publishers.CompactMap where Output == T? { + compactMap { $0 } + } + func mergeReplace(_ other: P, with output: T) -> Publishers.Merge, Publishers.Map> { replace(with: output).merge(with: other.replace(with: output)) } @@ -510,6 +580,47 @@ extension Publisher { func mergeReplace(_ other: P, transform: @escaping () -> T) -> Publishers.Merge, Publishers.Map> { replace(transform).merge(with: other.replace(transform)) } + + func discardMerge(_ other: P) -> Publishers.Merge, Publishers.Map> { + mergeReplace(other, with: ()) + } + + func removeDuplicates() -> Publishers.RemoveDuplicates where Output == (repeat each T) { + removeDuplicates { lhs, rhs in + for (left, right) in repeat (each lhs, each rhs) { + guard left == right else { return false } + } + return true + } + } +} + +extension Publisher { + func publisher( + for keyPath: KeyPath, + options: NSKeyValueObservingOptions = [.initial, .new] + ) -> some Publisher where Output: NSObject { + flatMap { $0.publisher(for: keyPath, options: options) } + } + + func publisher( + for keyPath: KeyPath, + options: NSKeyValueObservingOptions = [.initial, .new] + ) -> some Publisher where Output == Wrapped? { + flatMap { $0.publisher } + .flatMap { $0.publisher(for: keyPath, options: options) } + .map { $0 as Value? } + .replaceEmpty(with: nil) + } + + func publisher( + for keyPath: KeyPath, + options: NSKeyValueObservingOptions = [.initial, .new] + ) -> some Publisher where Output == Wrapped? { + flatMap { $0.publisher } + .flatMap { $0.publisher(for: keyPath, options: options) } + .replaceEmpty(with: nil) + } } // MARK: - Publisher where Output: Sequence, Failure == Never diff --git a/Ice/Utilities/Helpers.swift b/Ice/Utilities/Helpers.swift new file mode 100644 index 000000000..d3eb3deb8 --- /dev/null +++ b/Ice/Utilities/Helpers.swift @@ -0,0 +1,39 @@ +// +// Helpers.swift +// Ice +// + +// MARK: - Update + +/// Updates the given value in place using a closure. +/// +/// Use this function to group multiple updates under one mutation. +func update( + _ value: inout Value, + _ body: (inout Value) throws(E) -> Void +) throws(E) { + try body(&value) +} + +/// Updates the given value in place using a closure. +/// +/// Use this function to group multiple updates under one mutation. +func update( + _ value: inout Value, + _ body: (inout Value) async throws(E) -> Void +) async throws(E) { + try await body(&value) +} + +// MARK: - With Mutable Copy + +/// Invokes the given closure with a mutable copy of the given value. +@discardableResult +func withMutableCopy( + of value: Value, + _ body: (inout Value) throws(E) -> Void +) throws(E) -> Value { + var mutable = copy value + try body(&mutable) + return mutable +} diff --git a/Ice/Utilities/Injection.swift b/Ice/Utilities/Injection.swift deleted file mode 100644 index 87a95abc0..000000000 --- a/Ice/Utilities/Injection.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// Injection.swift -// Ice -// - -/// Updates the given value in place using a closure. -/// -/// Use this function to repeatedly update a value while ensuring it is only mutated once. -func update(_ value: inout Value, body: (inout Value) throws -> Void) rethrows { - try body(&value) -} - -/// Updates the given value in place using a closure. -/// -/// Use this function to repeatedly update a value while ensuring it is only mutated once. -func update(_ value: inout Value, body: (inout Value) async throws -> Void) async rethrows { - try await body(&value) -} - -/// Updates a copy of the given value using a closure and returns the updated value. -@discardableResult -func with(_ value: Value, update: (inout Value) throws -> Void) rethrows -> Value { - var copy = value - try update(©) - return copy -} - -/// Updates a copy of the given value using a closure and returns the updated value. -@discardableResult -func with(_ value: Value, update: (inout Value) async throws -> Void) async rethrows -> Value { - var copy = value - try await update(©) - return copy -} diff --git a/Ice/Utilities/Migration.swift b/Ice/Utilities/Migration.swift index 4b1012f8d..cd6b51b31 100644 --- a/Ice/Utilities/Migration.swift +++ b/Ice/Utilities/Migration.swift @@ -288,7 +288,7 @@ extension MigrationManager { } do { let oldConfiguration = try decoder.decode(MenuBarAppearanceConfigurationV1.self, from: oldData) - let newConfiguration = with(MenuBarAppearanceConfigurationV2.defaultConfiguration) { configuration in + let newConfiguration = withMutableCopy(of: MenuBarAppearanceConfigurationV2.defaultConfiguration) { configuration in let partialConfiguration = MenuBarAppearancePartialConfiguration( hasShadow: oldConfiguration.hasShadow, hasBorder: oldConfiguration.hasBorder, diff --git a/Ice/Utilities/Notifications.swift b/Ice/Utilities/Notifications.swift deleted file mode 100644 index 4f81f7dba..000000000 --- a/Ice/Utilities/Notifications.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// Notifications.swift -// Ice -// - -import Foundation - -extension DistributedNotificationCenter { - /// A notification posted whenever the system-wide interface theme changes. - static let interfaceThemeChangedNotification = Notification.Name("AppleInterfaceThemeChangedNotification") -} diff --git a/Ice/Utilities/Predicates.swift b/Ice/Utilities/Predicates.swift index f8086bbf6..7d464975d 100644 --- a/Ice/Utilities/Predicates.swift +++ b/Ice/Utilities/Predicates.swift @@ -34,65 +34,6 @@ enum Predicates { } } -// MARK: - Menu Bar Item Predicates - -extension Predicates where Input == MenuBarItem { - /// A group of predicates that separates menu bar items into sections. - typealias SectionPredicates = ( - isInVisibleSection: NonThrowingPredicate, - isInHiddenSection: NonThrowingPredicate, - isInAlwaysHiddenSection: NonThrowingPredicate - ) - - private static func bounds(for item: MenuBarItem) -> CGRect { - Bridging.getWindowBounds(for: item.windowID) ?? item.bounds - } - - /// Creates a predicate that returns whether a menu bar item is in the visible section - /// using the control item for the hidden section as a delimiter. - static func isInVisibleSection(hiddenControlItem: MenuBarItem) -> NonThrowingPredicate { - predicate { item in - bounds(for: item).minX >= bounds(for: hiddenControlItem).maxX - } - } - - /// Creates a predicate that returns whether a menu bar item is in the hidden section - /// using the control items for the hidden and always hidden sections as delimiters. - static func isInHiddenSection(hiddenControlItem: MenuBarItem, alwaysHiddenControlItem: MenuBarItem?) -> NonThrowingPredicate { - if let alwaysHiddenControlItem { - predicate { item in - bounds(for: item).maxX <= bounds(for: hiddenControlItem).minX && - bounds(for: item).minX >= bounds(for: alwaysHiddenControlItem).maxX - } - } else { - predicate { item in - bounds(for: item).maxX <= bounds(for: hiddenControlItem).minX - } - } - } - - /// Creates a predicate that returns whether a menu bar item is in the always-hidden - /// section using the control item for the always hidden section as a delimiter. - static func isInAlwaysHiddenSection(alwaysHiddenControlItem: MenuBarItem?) -> NonThrowingPredicate { - if let alwaysHiddenControlItem { - predicate { item in - bounds(for: item).maxX <= bounds(for: alwaysHiddenControlItem).minX - } - } else { - predicate { false } - } - } - - /// Creates a group of predicates that separates menu bar items into sections. - static func sectionPredicates(hiddenControlItem: MenuBarItem, alwaysHiddenControlItem: MenuBarItem?) -> SectionPredicates { - SectionPredicates( - isInVisibleSection: isInVisibleSection(hiddenControlItem: hiddenControlItem), - isInHiddenSection: isInHiddenSection(hiddenControlItem: hiddenControlItem, alwaysHiddenControlItem: alwaysHiddenControlItem), - isInAlwaysHiddenSection: isInAlwaysHiddenSection(alwaysHiddenControlItem: alwaysHiddenControlItem) - ) - } -} - // MARK: - Control Item Predicates extension Predicates where Input == NSLayoutConstraint { diff --git a/Ice/Utilities/ScreenCapture.swift b/Ice/Utilities/ScreenCapture.swift index adb929999..3cfe38227 100644 --- a/Ice/Utilities/ScreenCapture.swift +++ b/Ice/Utilities/ScreenCapture.swift @@ -74,13 +74,13 @@ enum ScreenCapture { /// - screenBounds: The bounds to capture, specified in screen coordinates. Pass `nil` to /// capture the minimum rectangle that encloses the windows. /// - option: Options that specify which parts of the windows are captured. - static func captureWindows(_ windowIDs: [CGWindowID], screenBounds: CGRect? = nil, option: CGWindowImageOption = []) -> CGImage? { - captureQueue.sync { - guard let windowArray = Bridging.createCGWindowArray(with: windowIDs) else { - return nil - } - let screenBounds = screenBounds ?? .null - return CGImage.windowListImage(from: screenBounds, windowArray: windowArray, imageOption: option) + static func captureWindows(with windowIDs: [CGWindowID], screenBounds: CGRect? = nil, option: CGWindowImageOption = []) -> CGImage? { + guard let array = Bridging.createCGWindowArray(with: windowIDs) else { + return nil + } + let bounds = screenBounds ?? .null + return captureQueue.sync { + CGImage.createWindowListImageFromArray(screenBounds: bounds, windowArray: array, imageOption: option) } } @@ -91,8 +91,8 @@ enum ScreenCapture { /// - screenBounds: The bounds to capture, specified in screen coordinates. Pass `nil` to /// capture the minimum rectangle that encloses the window. /// - option: Options that specify which parts of the window are captured. - static func captureWindow(_ windowID: CGWindowID, screenBounds: CGRect? = nil, option: CGWindowImageOption = []) -> CGImage? { - captureWindows([windowID], screenBounds: screenBounds, option: option) + static func captureWindow(with windowID: CGWindowID, screenBounds: CGRect? = nil, option: CGWindowImageOption = []) -> CGImage? { + captureWindows(with: [windowID], screenBounds: screenBounds, option: option) } } @@ -108,7 +108,7 @@ private protocol WindowListImage { private extension WindowListImage { @inline(__always) // Ensure a direct call to the initializer. - static func windowListImage(from screenBounds: CGRect, windowArray: CFArray, imageOption: CGWindowImageOption) -> Self? { + static func createWindowListImageFromArray(screenBounds: CGRect, windowArray: CFArray, imageOption: CGWindowImageOption) -> Self? { Self(windowListFromArrayScreenBounds: screenBounds, windowArray: windowArray, imageOption: imageOption) } } diff --git a/Ice/Utilities/SpaceInfo.swift b/Ice/Utilities/SpaceInfo.swift new file mode 100644 index 000000000..cd5d58bbb --- /dev/null +++ b/Ice/Utilities/SpaceInfo.swift @@ -0,0 +1,38 @@ +// +// SpaceInfo.swift +// Ice +// + +import CoreGraphics + +/// Information for a desktop space. +struct SpaceInfo: Hashable { + /// The space's identifier. + let spaceID: CGSSpaceID + + /// A Boolean value that indicates whether the space is fullscreen. + let isFullscreen: Bool + + /// Creates a space with the given identifier. + /// + /// - Parameter spaceID: An identifier for a space. + init(spaceID: CGSSpaceID) { + self.spaceID = spaceID + self.isFullscreen = Bridging.isSpaceFullscreen(spaceID) + } + + /// Returns the active space. + static func activeSpace() -> SpaceInfo { + SpaceInfo(spaceID: Bridging.getActiveSpaceID()) + } + + /// Returns the current space on the given display. + /// + /// - Parameter displayID: An identifier for a display. + static func currentSpace(for displayID: CGDirectDisplayID) -> SpaceInfo? { + guard let spaceID = Bridging.getCurrentSpaceID(for: displayID) else { + return nil + } + return SpaceInfo(spaceID: spaceID) + } +} diff --git a/MenuBarItemService/Service.swift b/MenuBarItemService/Service.swift index e8f33ab2c..bd31958aa 100644 --- a/MenuBarItemService/Service.swift +++ b/MenuBarItemService/Service.swift @@ -8,6 +8,7 @@ import Foundation @main enum Service { static func main() throws { + Bridging.setProcessUnresponsiveTimeout(3) try Listener.shared.activate() RunLoop.current.run() } diff --git a/MenuBarItemService/SourcePIDCache.swift b/MenuBarItemService/SourcePIDCache.swift index c623ce18b..0335eba37 100644 --- a/MenuBarItemService/SourcePIDCache.swift +++ b/MenuBarItemService/SourcePIDCache.swift @@ -231,24 +231,6 @@ final class SourcePIDCache { } } -// MARK: - CGPoint Extension - -private extension CGPoint { - /// Returns the distance between this point and another point. - func distance(to other: CGPoint) -> CGFloat { - hypot(x - other.x, y - other.y) - } -} - -// MARK: - CGRect Extension - -private extension CGRect { - /// The center point of the rectangle. - var center: CGPoint { - CGPoint(x: midX, y: midY) - } -} - // MARK: - WindowInfo Extension private extension WindowInfo { diff --git a/Shared/Bridging/Bridging.swift b/Shared/Bridging/Bridging.swift index 6adacd85b..b3365f561 100644 --- a/Shared/Bridging/Bridging.swift +++ b/Shared/Bridging/Bridging.swift @@ -11,12 +11,30 @@ import OSLog /// A namespace for bridged APIs. enum Bridging { private static let mainConnectionID = CGSMainConnectionID() + private static let nullConnectionID: CGSConnectionID = 0 private static let logger = Logger(category: "Bridging") } // MARK: - CGSConnection extension Bridging { + /// Returns the value for a property in the app's window server connection. + /// + /// - Parameter key: A key for a property in the app's window server connection. + static func getConnectionProperty(forKey key: String) -> Any? { + var value: Unmanaged? + let result = CGSCopyConnectionProperty( + mainConnectionID, + mainConnectionID, + key as CFString, + &value + ) + if result != .success { + logger.error("CGSCopyConnectionProperty failed with error \(result.logString, privacy: .public)") + } + return value?.takeRetainedValue() + } + /// Sets the value for a property in the app's window server connection. /// /// - Parameters: @@ -33,22 +51,60 @@ extension Bridging { logger.error("CGSSetConnectionProperty failed with error \(result.logString, privacy: .public)") } } +} - /// Returns the value for a property in the app's window server connection. - /// - /// - Parameter key: A key for a property in the app's window server connection. - static func getConnectionProperty(forKey key: String) -> Any? { - var value: Unmanaged? - let result = CGSCopyConnectionProperty( - mainConnectionID, - mainConnectionID, - key as CFString, - &value - ) - if result != .success { - logger.error("CGSCopyConnectionProperty failed with error \(result.logString, privacy: .public)") +// MARK: - Display + +extension Bridging { + + // MARK: Private Display Helpers + + private static func getActiveDisplayCount() -> UInt32? { + var count: UInt32 = 0 + let result = CGGetActiveDisplayList(0, nil, &count) + guard result == .success else { + logger.error("CGGetActiveDisplayList failed with error \(result.logString, privacy: .public)") + return nil + } + return count + } + + private static func getActiveDisplayList() -> [CGDirectDisplayID] { + guard let count = getActiveDisplayCount() else { + return [] + } + var list = [CGDirectDisplayID](repeating: 0, count: Int(count)) + let result = CGGetActiveDisplayList(count, &list, nil) + guard result == .success else { + logger.error("CGGetActiveDisplayList failed with error \(result.logString, privacy: .public)") + return [] + } + return list + } + + private static func getDisplayUUID(for displayID: CGDirectDisplayID) -> CFUUID? { + guard let uuid = CGDisplayCreateUUIDFromDisplayID(displayID) else { + logger.error("CGDisplayCreateUUIDFromDisplayID returned nil for display \(displayID, privacy: .public)") + return nil + } + return uuid.takeRetainedValue() + } + + // MARK: Public Display API + + /// Returns the identifier of the display with the active menu bar. + static func getActiveMenuBarDisplayID() -> CGDirectDisplayID? { + guard let string = CGSCopyActiveMenuBarDisplayIdentifier(mainConnectionID) else { + logger.error("CGSCopyActiveMenuBarDisplayIdentifier returned nil") + return nil + } + guard let uuid = CFUUIDCreateFromString(nil, string.takeRetainedValue()) else { + logger.error("CFUUIDCreateFromString returned nil") + return nil + } + return getActiveDisplayList().first { displayID in + getDisplayUUID(for: displayID) == uuid } - return value?.takeRetainedValue() } } @@ -68,6 +124,16 @@ extension Bridging { } return CGSEventIsAppUnresponsive(mainConnectionID, &psn) } + + /// Sets the timeout used to determine if a process is unresponsive. + /// + /// - Parameter timeout: An amount of time in seconds. + static func setProcessUnresponsiveTimeout(_ timeout: TimeInterval) { + let result = CGSEventSetAppIsUnresponsiveNotificationTimeout(mainConnectionID, timeout) + if result != .success { + logger.error("CGSEventSetAppIsUnresponsiveNotificationTimeout failed with error \(result.logString, privacy: .public)") + } + } } // MARK: - CGSSpace @@ -75,18 +141,18 @@ extension Bridging { extension Bridging { /// Returns the identifier for the active space. static func getActiveSpaceID() -> CGSSpaceID { - return CGSGetActiveSpace(mainConnectionID) + CGSGetActiveSpace(mainConnectionID) } /// Returns the identifier for the current space on the given display. /// /// - Parameter displayID: An identifier for a display. static func getCurrentSpaceID(for displayID: CGDirectDisplayID) -> CGSSpaceID? { - guard - let uuid = CGDisplayCreateUUIDFromDisplayID(displayID), - let uuidString = CFUUIDCreateString(nil, uuid.takeRetainedValue()) - else { - logger.error("Failed to create UUID for display \(displayID, privacy: .public)") + guard let uuid = getDisplayUUID(for: displayID) else { + return nil + } + guard let uuidString = CFUUIDCreateString(nil, uuid) else { + logger.error("CFUUIDCreateString returned nil for display \(displayID, privacy: .public)") return nil } return CGSManagedDisplayGetCurrentSpace(mainConnectionID, uuidString) @@ -121,13 +187,6 @@ extension Bridging { let type = CGSSpaceGetType(mainConnectionID, spaceID) return type == .fullscreen } - - /// Returns a Boolean value that indicates whether the active space - /// is fullscreen. - static func isActiveSpaceFullscreen() -> Bool { - let activeSpaceID = getActiveSpaceID() - return isSpaceFullscreen(activeSpaceID) - } } // MARK: - CGSWindow @@ -170,15 +229,6 @@ extension Bridging { return list.contains(spaceID) } - /// Returns a Boolean value that indicates whether the given window - /// is on the active space. - /// - /// - Parameter windowID: An identifier for a window. - static func isWindowOnActiveSpace(_ windowID: CGWindowID) -> Bool { - let activeSpaceID = getActiveSpaceID() - return isWindowOnSpace(windowID, activeSpaceID) - } - /// Returns a Boolean value that indicates whether the given window /// intersects the given display bounds. /// @@ -205,58 +255,63 @@ extension Bridging { // MARK: Private Window List Helpers - private static func getFullWindowCount() -> Int32 { + private static func getWindowCount() -> Int32? { var count: Int32 = 0 - let result = CGSGetWindowCount(mainConnectionID, 0, &count) - if result != .success { + let result = CGSGetWindowCount(mainConnectionID, nullConnectionID, &count) + guard result == .success else { logger.error("CGSGetWindowCount failed with error \(result.logString, privacy: .public)") + return nil } return count } - private static func getOnScreenWindowCount() -> Int32 { + private static func getOnScreenWindowCount() -> Int32? { var count: Int32 = 0 - let result = CGSGetOnScreenWindowCount(mainConnectionID, 0, &count) - if result != .success { + let result = CGSGetOnScreenWindowCount(mainConnectionID, nullConnectionID, &count) + guard result == .success else { logger.error("CGSGetOnScreenWindowCount failed with error \(result.logString, privacy: .public)") + return nil } return count } - private static func getFullWindowList() -> [CGWindowID] { - let count = getFullWindowCount() + private static func getWindowList() -> [CGWindowID] { + guard var count = getWindowCount() else { + return [] + } var list = [CGWindowID](repeating: 0, count: Int(count)) - var outCount: Int32 = 0 - let result = CGSGetWindowList(mainConnectionID, 0, count, &list, &outCount) + let result = CGSGetWindowList(mainConnectionID, nullConnectionID, count, &list, &count) guard result == .success else { logger.error("CGSGetWindowList failed with error \(result.logString, privacy: .public)") return [] } - return [CGWindowID](list[.. [CGWindowID] { - let count = getOnScreenWindowCount() + guard var count = getOnScreenWindowCount() else { + return [] + } var list = [CGWindowID](repeating: 0, count: Int(count)) - var outCount: Int32 = 0 - let result = CGSGetOnScreenWindowList(mainConnectionID, 0, count, &list, &outCount) + let result = CGSGetOnScreenWindowList(mainConnectionID, nullConnectionID, count, &list, &count) guard result == .success else { logger.error("CGSGetOnScreenWindowList failed with error \(result.logString, privacy: .public)") return [] } - return [CGWindowID](list[.. [CGWindowID] { - let count = getFullWindowCount() + private static func getProcessMenuBarWindowList() -> [CGWindowID] { + guard var count = getWindowCount() else { + return [] + } var list = [CGWindowID](repeating: 0, count: Int(count)) - var outCount: Int32 = 0 - let result = CGSGetProcessMenuBarWindowList(mainConnectionID, 0, count, &list, &outCount) + let result = CGSGetProcessMenuBarWindowList(mainConnectionID, nullConnectionID, count, &list, &count) guard result == .success else { logger.error("CGSGetProcessMenuBarWindowList failed with error \(result.logString, privacy: .public)") return [] } - return [CGWindowID](list[.. CGSConnectionID @@ -53,7 +53,12 @@ func CGSSetConnectionProperty( _ value: CFTypeRef ) -> CGError -// MARK: - CGSEvent Functions +// MARK: - CGSDisplay + +@_silgen_name("CGSCopyActiveMenuBarDisplayIdentifier") +func CGSCopyActiveMenuBarDisplayIdentifier(_ cid: CGSConnectionID) -> Unmanaged? + +// MARK: - CGSEvent @_silgen_name("CGSEventIsAppUnresponsive") func CGSEventIsAppUnresponsive( @@ -61,7 +66,13 @@ func CGSEventIsAppUnresponsive( _ psn: inout ProcessSerialNumber ) -> Bool -// MARK: - CGSSpace Functions +@_silgen_name("CGSEventSetAppIsUnresponsiveNotificationTimeout") +func CGSEventSetAppIsUnresponsiveNotificationTimeout( + _ cid: CGSConnectionID, + _ timeout: Double +) -> CGError + +// MARK: - CGSSpace @_silgen_name("CGSGetActiveSpace") func CGSGetActiveSpace(_ cid: CGSConnectionID) -> CGSSpaceID @@ -85,28 +96,24 @@ func CGSSpaceGetType( _ sid: CGSSpaceID ) -> CGSSpaceType -// MARK: - CGSWindow Functions +// MARK: - CGSWindow -@_silgen_name("CGSGetWindowList") -func CGSGetWindowList( +@_silgen_name("CGSGetWindowCount") +func CGSGetWindowCount( _ cid: CGSConnectionID, _ targetCID: CGSConnectionID, - _ count: Int32, - _ list: UnsafeMutablePointer, _ outCount: inout Int32 ) -> CGError -@_silgen_name("CGSGetOnScreenWindowList") -func CGSGetOnScreenWindowList( +@_silgen_name("CGSGetOnScreenWindowCount") +func CGSGetOnScreenWindowCount( _ cid: CGSConnectionID, _ targetCID: CGSConnectionID, - _ count: Int32, - _ list: UnsafeMutablePointer, _ outCount: inout Int32 ) -> CGError -@_silgen_name("CGSGetProcessMenuBarWindowList") -func CGSGetProcessMenuBarWindowList( +@_silgen_name("CGSGetWindowList") +func CGSGetWindowList( _ cid: CGSConnectionID, _ targetCID: CGSConnectionID, _ count: Int32, @@ -114,17 +121,21 @@ func CGSGetProcessMenuBarWindowList( _ outCount: inout Int32 ) -> CGError -@_silgen_name("CGSGetWindowCount") -func CGSGetWindowCount( +@_silgen_name("CGSGetOnScreenWindowList") +func CGSGetOnScreenWindowList( _ cid: CGSConnectionID, _ targetCID: CGSConnectionID, + _ count: Int32, + _ list: UnsafeMutablePointer, _ outCount: inout Int32 ) -> CGError -@_silgen_name("CGSGetOnScreenWindowCount") -func CGSGetOnScreenWindowCount( +@_silgen_name("CGSGetProcessMenuBarWindowList") +func CGSGetProcessMenuBarWindowList( _ cid: CGSConnectionID, _ targetCID: CGSConnectionID, + _ count: Int32, + _ list: UnsafeMutablePointer, _ outCount: inout Int32 ) -> CGError @@ -142,7 +153,7 @@ func CGSGetWindowLevel( _ outLevel: inout CGWindowLevel ) -> CGError -// MARK: - PSN/PID Functions +// MARK: - ProcessSerialNumber @_silgen_name("GetProcessForPID") func GetProcessForPID( diff --git a/Shared/Utilities/SharedExtensions.swift b/Shared/Utilities/SharedExtensions.swift index 7dde1b651..7f0affdcb 100644 --- a/Shared/Utilities/SharedExtensions.swift +++ b/Shared/Utilities/SharedExtensions.swift @@ -28,6 +28,24 @@ extension CGError { } } +// MARK: - CGPoint + +extension CGPoint { + /// Returns the distance between this point and another point. + func distance(to other: CGPoint) -> CGFloat { + hypot(x - other.x, y - other.y) + } +} + +// MARK: - CGRect + +extension CGRect { + /// The center point of the rectangle. + var center: CGPoint { + CGPoint(x: midX, y: midY) + } +} + // MARK: - DispatchQueue extension DispatchQueue { From 389588ebf0fb4c2aeadf3e08e6f88096c8647e53 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Wed, 6 Aug 2025 10:27:23 -0600 Subject: [PATCH 42/80] Refactor UI - Update `IceForm` to provide a custom `LabeledContentStyle` - Remove `IceLabeledContent` and replace with standard `LabeledContent` - Improve accessibility handling in various container views - Improve styling in `IceGroupBox` and `IceSection` - Various other UI refinements --- .../MenuBarAppearanceEditor.swift | 2 +- .../SettingsPanes/AdvancedSettingsPane.swift | 6 +- .../SettingsPanes/GeneralSettingsPane.swift | 4 +- Ice/Settings/SettingsView.swift | 46 ++++++++------ Ice/UI/IceUI/IceColorPicker.swift | 2 +- Ice/UI/IceUI/IceForm.swift | 49 +++++++++------ Ice/UI/IceUI/IceGradientPicker.swift | 2 +- Ice/UI/IceUI/IceGroupBox.swift | 29 +++++---- Ice/UI/IceUI/IceLabeledContent.swift | 41 ------------- Ice/UI/IceUI/IceMenu.swift | 2 +- Ice/UI/IceUI/IcePicker.swift | 2 +- Ice/UI/IceUI/IceSection.swift | 61 ++++++++++++------- Ice/UI/Views/HotkeyRecorder.swift | 2 +- 13 files changed, 128 insertions(+), 120 deletions(-) delete mode 100644 Ice/UI/IceUI/IceLabeledContent.swift diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift index 3a1b8e90e..7d37059d4 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift @@ -143,7 +143,7 @@ private struct UnlabeledPartialEditor: View { @ViewBuilder private var tintPicker: some View { - IceLabeledContent("Tint") { + LabeledContent("Tint") { HStack { IcePicker("Tint", selection: $configuration.tintKind) { ForEach(MenuBarTintKind.allCases) { tintKind in diff --git a/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift b/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift index 55f5de1e7..07fcf4710 100644 --- a/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift @@ -105,7 +105,7 @@ struct AdvancedSettingsPane: View { @ViewBuilder private var showOnHoverDelay: some View { - IceLabeledContent { + LabeledContent { IceSlider( formattedToSeconds(settings.showOnHoverDelay), value: $settings.showOnHoverDelay, @@ -124,7 +124,7 @@ struct AdvancedSettingsPane: View { @ViewBuilder private var tempShowInterval: some View { - IceLabeledContent { + LabeledContent { IceSlider( formattedToSeconds(settings.tempShowInterval), value: $settings.tempShowInterval, @@ -144,7 +144,7 @@ struct AdvancedSettingsPane: View { @ViewBuilder private var allPermissions: some View { ForEach(appState.permissions.allPermissions) { permission in - IceLabeledContent { + LabeledContent { if permission.hasPermission { Label { Text("Permission Granted") diff --git a/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift b/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift index aa99f60f6..8669f102a 100644 --- a/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift @@ -220,7 +220,7 @@ struct GeneralSettingsPane: View { @ViewBuilder private var spacingOptions: some View { - IceLabeledContent { + LabeledContent { IceSlider( localizedOffsetString(for: tempItemSpacingOffset), value: $tempItemSpacingOffset, @@ -229,7 +229,7 @@ struct GeneralSettingsPane: View { ) .disabled(isApplyingOffset) } label: { - IceLabeledContent { + LabeledContent { Button("Apply") { applyOffset() } diff --git a/Ice/Settings/SettingsView.swift b/Ice/Settings/SettingsView.swift index 9bcfe61ec..8679a7440 100644 --- a/Ice/Settings/SettingsView.swift +++ b/Ice/Settings/SettingsView.swift @@ -8,8 +8,11 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var appState: AppState @EnvironmentObject var navigationState: AppNavigationState - @Environment(\.appearsActive) var appearsActive - @Environment(\.sidebarRowSize) var sidebarRowSize + @Environment(\.appearsActive) private var appearsActive + @Environment(\.colorScheme) private var colorScheme + @Environment(\.sidebarRowSize) private var sidebarRowSize + + private let sidebarPadding: CGFloat = 3 private var sidebarWidth: CGFloat { if #available(macOS 26.0, *) { @@ -38,7 +41,7 @@ struct SettingsView: View { } } - private var sidebarItemFontSize: CGFloat { + private var sidebarFontSize: CGFloat { switch sidebarRowSize { case .small: 13 case .medium: 15 @@ -47,6 +50,18 @@ struct SettingsView: View { } } + private var sidebarTextStyle: some ShapeStyle { + if colorScheme == .dark { + AnyShapeStyle(Color(nsColor: appearsActive ? .labelColor : .secondaryLabelColor)) + } else { + AnyShapeStyle(appearsActive ? .primary : .secondary) + } + } + + private var sidebarIconStyle: some ShapeStyle { + HierarchicalShapeStyle.primary.opacity(appearsActive ? 1 : 0.67) + } + private var navigationTitle: LocalizedStringKey { navigationState.settingsNavigationIdentifier.localized } @@ -69,9 +84,10 @@ struct SettingsView: View { } } header: { Text("Ice") - .font(.system(size: 40, weight: .medium)) - .foregroundStyle(appearsActive ? .primary : .tertiary) - .padding(.bottom, 10) + .font(.system(size: sidebarFontSize * 2.67, weight: .medium)) + .foregroundStyle(sidebarTextStyle) + .padding(.leading, sidebarPadding) + .padding(.bottom, sidebarFontSize) } .collapsible(false) } @@ -113,21 +129,13 @@ struct SettingsView: View { private func sidebarItem(for identifier: SettingsNavigationIdentifier) -> some View { Label { Text(identifier.localized) - .font(.system(size: sidebarItemFontSize)) - .padding(.leading, 2) + .font(.system(size: sidebarFontSize)) + .foregroundStyle(sidebarTextStyle) } icon: { - icon(for: identifier) - } - .frame(height: sidebarItemHeight) - .padding(.leading, 1) - } - - @ViewBuilder - private func icon(for identifier: SettingsNavigationIdentifier) -> some View { - if #available(macOS 26.0, *) { - identifier.iconResource.view.padding(3) - } else { identifier.iconResource.view + .foregroundStyle(sidebarIconStyle) + .padding(sidebarPadding) } + .frame(height: sidebarItemHeight) } } diff --git a/Ice/UI/IceUI/IceColorPicker.swift b/Ice/UI/IceUI/IceColorPicker.swift index 2092bb045..dc76e047c 100644 --- a/Ice/UI/IceUI/IceColorPicker.swift +++ b/Ice/UI/IceUI/IceColorPicker.swift @@ -49,7 +49,7 @@ struct IceColorPicker: View { } var body: some View { - IceLabeledContent { + LabeledContent { IceColorPickerRoot( selection: $selection, isActive: $isActive, diff --git a/Ice/UI/IceUI/IceForm.swift b/Ice/UI/IceUI/IceForm.swift index 8e5cb0f24..a3486a046 100644 --- a/Ice/UI/IceUI/IceForm.swift +++ b/Ice/UI/IceUI/IceForm.swift @@ -42,26 +42,31 @@ struct IceForm: View { } var body: some View { + contentScrollView + .focusSection() + .accessibilityElement(children: .contain) + } + + @ViewBuilder + private var contentScrollView: some View { if isScrollEnabled { GeometryReader { geometry in - if contentFrame.height > geometry.size.height { - ScrollView { - contentStack - } - .scrollContentBackground(.hidden) - } else { - contentStack + ScrollView { + contentLayout } + .scrollContentBackground(.hidden) + .scrollDisabled(contentFrame.height <= geometry.size.height) } } else { - contentStack + contentLayout } } @ViewBuilder - private var contentStack: some View { + private var contentLayout: some View { VStack(alignment: alignment, spacing: spacing) { content + .labeledContentStyle(IceFormLabeledContentStyle()) .toggleStyle(IceFormToggleStyle()) } .padding(padding) @@ -69,21 +74,31 @@ struct IceForm: View { } } -private struct IceFormToggleStyle: ToggleStyle { +// MARK: - IceFormLabeledContentStyle + +private struct IceFormLabeledContentStyle: LabeledContentStyle { func makeBody(configuration: Configuration) -> some View { - IceLabeledContent { - Toggle(isOn: configuration.$isOn) { - configuration.label - } - .labelsHidden() - .toggleStyle(.switch) - .controlSize(.mini) + LabeledContent { + configuration.content + .layoutPriority(1) } label: { configuration.label + .frame(maxWidth: .infinity, alignment: .leading) + .layoutPriority(0) } } } +// MARK: - IceFormToggleStyle + +private struct IceFormToggleStyle: ToggleStyle { + func makeBody(configuration: Configuration) -> some View { + Toggle(configuration) + .toggleStyle(.switch) + .controlSize(.mini) + } +} + extension EdgeInsets { /// The default padding for an ``IceForm``. static let iceFormDefaultPadding: EdgeInsets = { diff --git a/Ice/UI/IceUI/IceGradientPicker.swift b/Ice/UI/IceUI/IceGradientPicker.swift index ed9940411..87daf2e52 100644 --- a/Ice/UI/IceUI/IceGradientPicker.swift +++ b/Ice/UI/IceUI/IceGradientPicker.swift @@ -50,7 +50,7 @@ struct IceGradientPicker: View { } var body: some View { - IceLabeledContent { + LabeledContent { IceGradientPickerRoot( gradient: $gradient, selection: $selection, diff --git a/Ice/UI/IceUI/IceGroupBox.swift b/Ice/UI/IceUI/IceGroupBox.swift index 6b863d4d4..55b4d66d9 100644 --- a/Ice/UI/IceUI/IceGroupBox.swift +++ b/Ice/UI/IceUI/IceGroupBox.swift @@ -13,12 +13,20 @@ struct IceGroupBox: View { private var backgroundShape: some InsettableShape { if #available(macOS 26.0, *) { - RoundedRectangle(cornerRadius: 10, style: .continuous) + RoundedRectangle(cornerRadius: 11, style: .continuous) } else { RoundedRectangle(cornerRadius: 7, style: .circular) } } + private var borderStyle: some ShapeStyle { + if #available(macOS 26.0, *) { + AnyShapeStyle(.clear) + } else { + AnyShapeStyle(.quaternary) + } + } + init( padding: EdgeInsets = .iceGroupBoxDefaultPadding, @ViewBuilder header: () -> Header, @@ -134,8 +142,7 @@ struct IceGroupBox: View { @ViewBuilder content: () -> Content ) where Header == Text, Footer == EmptyView { self.init(padding: padding) { - Text(title) - .font(.headline) + Text(title).font(.headline) } content: { content() } @@ -147,8 +154,7 @@ struct IceGroupBox: View { @ViewBuilder content: () -> Content ) where Header == Text, Footer == EmptyView { self.init(padding: padding) { - Text(title) - .font(.headline) + Text(title).font(.headline) } content: { content() } @@ -157,24 +163,25 @@ struct IceGroupBox: View { var body: some View { VStack(alignment: .leading) { header - .padding(.top, 8) + .accessibilityAddTraits(.isHeader) + .padding([.top, .leading], 8) .padding(.bottom, 2) - .padding(.leading, 8) contentStack .padding(padding) .background { backgroundShape - .fill(.quinary.opacity(0.67)) - .strokeBorder(.quaternary) + .fill(.quinary.opacity(0.75)) + .strokeBorder(borderStyle) } .containerShape(backgroundShape) footer + .padding([.bottom, .leading], 8) .padding(.top, 2) - .padding(.bottom, 8) - .padding(.leading, 8) } + .focusSection() + .accessibilityElement(children: .contain) } @ViewBuilder diff --git a/Ice/UI/IceUI/IceLabeledContent.swift b/Ice/UI/IceUI/IceLabeledContent.swift deleted file mode 100644 index 63740c13b..000000000 --- a/Ice/UI/IceUI/IceLabeledContent.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// IceLabeledContent.swift -// Ice -// - -import SwiftUI - -struct IceLabeledContent: View { - private let label: Label - private let content: Content - - init( - @ViewBuilder content: () -> Content, - @ViewBuilder label: () -> Label - ) { - self.label = label() - self.content = content() - } - - init( - _ titleKey: LocalizedStringKey, - @ViewBuilder content: () -> Content - ) where Label == Text { - self.init { - content() - } label: { - Text(titleKey) - } - } - - var body: some View { - LabeledContent { - content - .layoutPriority(1) - } label: { - label - .frame(maxWidth: .infinity, alignment: .leading) - .layoutPriority(0) - } - } -} diff --git a/Ice/UI/IceUI/IceMenu.swift b/Ice/UI/IceUI/IceMenu.swift index 9ba8bce8e..6ebc596b9 100644 --- a/Ice/UI/IceUI/IceMenu.swift +++ b/Ice/UI/IceUI/IceMenu.swift @@ -47,7 +47,7 @@ struct IceMenu: View { } var body: some View { - IceLabeledContent { + LabeledContent { Menu { content .labelStyle(.titleAndIcon) diff --git a/Ice/UI/IceUI/IcePicker.swift b/Ice/UI/IceUI/IcePicker.swift index 4daddb502..fbae30d7f 100644 --- a/Ice/UI/IceUI/IcePicker.swift +++ b/Ice/UI/IceUI/IcePicker.swift @@ -34,7 +34,7 @@ struct IcePicker: View { } var body: some View { - IceLabeledContent { + LabeledContent { Picker(selection: $selection) { content .labelStyle(.titleAndIcon) diff --git a/Ice/UI/IceUI/IceSection.swift b/Ice/UI/IceUI/IceSection.swift index 15d0f79ce..f2fd96907 100644 --- a/Ice/UI/IceUI/IceSection.swift +++ b/Ice/UI/IceUI/IceSection.swift @@ -90,45 +90,50 @@ struct IceSection: View { @ViewBuilder content: () -> Content ) where Header == Text, Footer == EmptyView { self.init(spacing: spacing, options: options) { - Text(title) - .font(.headline) + Text(title).font(.headline) } content: { content() } } var body: some View { - if isBordered { - IceGroupBox(padding: spacing) { - header - } content: { - dividedContent - } footer: { - footer - } - } else { - VStack(alignment: .leading) { - header - dividedContent - footer + Section { + if isBordered { + IceGroupBox { + header + } content: { + contentLayout + } footer: { + footer + } + } else { + VStack(alignment: .leading) { + header.accessibilityAddTraits(.isHeader) + contentLayout + footer + } + .focusSection() + .accessibilityElement(children: .contain) } } + .focusSection() + .accessibilityElement(children: .contain) } @ViewBuilder - private var dividedContent: some View { + private var contentLayout: some View { if hasDividers { _VariadicView.Tree(IceSectionLayout(spacing: spacing)) { - content - .frame(maxWidth: .infinity) + content.frame(maxWidth: .infinity) } } else { - content - .frame(maxWidth: .infinity) + content.frame(maxWidth: .infinity) } } } +// MARK: - IceSectionLayout + private struct IceSectionLayout: _VariadicView_UnaryViewRoot { let spacing: CGFloat @@ -139,13 +144,27 @@ private struct IceSectionLayout: _VariadicView_UnaryViewRoot { ForEach(children) { child in child if child.id != last { - Divider() + IceSectionDivider() } } } } } +// MARK: - IceSectionDivider + +private struct IceSectionDivider: View { + var body: some View { + if #available(macOS 26.0, *) { + Rectangle() + .fill(.separator.quinary) + .frame(height: 1) + } else { + Divider() + } + } +} + extension CGFloat { /// The default spacing for an ``IceSection``. static let iceSectionDefaultSpacing: CGFloat = if #available(macOS 26.0, *) { 11 } else { 10 } diff --git a/Ice/UI/Views/HotkeyRecorder.swift b/Ice/UI/Views/HotkeyRecorder.swift index 87175bf8e..77455826e 100644 --- a/Ice/UI/Views/HotkeyRecorder.swift +++ b/Ice/UI/Views/HotkeyRecorder.swift @@ -19,7 +19,7 @@ struct HotkeyRecorder: View { } var body: some View { - IceLabeledContent { + LabeledContent { segmentStack } label: { label From 8d4b6a5b9bc957811538e0d17bbbdc109eb990b8 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Thu, 7 Aug 2025 04:22:59 -0600 Subject: [PATCH 43/80] Improve menu bar item movement - Cleanup, refactoring, and better concurrency handling - Replace `MouseCursor` and `MouseEvents` with `MouseHelpers` - More robust menu bar item movement logic - Update event taps for better concurrency management - Rename TaskHelpers.swift to ConcurrencyHelpers.swift - Add `CancellingContinuation` and related APIs - Tweak menu bar item description and display names --- Ice/Events/EventManager.swift | 16 +- Ice/Events/EventTap.swift | 114 ++-- Ice/MenuBar/IceBar/IceBar.swift | 2 +- .../LayoutBar/LayoutBarPaddingView.swift | 2 +- Ice/MenuBar/MenuBarItems/MenuBarItem.swift | 22 +- .../MenuBarItems/MenuBarItemManager.swift | 619 +++++++++--------- Ice/Utilities/ConcurrencyHelpers.swift | 218 ++++++ Ice/Utilities/MouseHelpers.swift | 43 +- Ice/Utilities/TaskHelpers.swift | 87 --- 9 files changed, 642 insertions(+), 481 deletions(-) create mode 100644 Ice/Utilities/ConcurrencyHelpers.swift delete mode 100644 Ice/Utilities/TaskHelpers.swift diff --git a/Ice/Events/EventManager.swift b/Ice/Events/EventManager.swift index 883aeddda..162a71621 100644 --- a/Ice/Events/EventManager.swift +++ b/Ice/Events/EventManager.swift @@ -238,7 +238,7 @@ extension EventManager { // Get the window that the user has clicked into. guard - let mouseLocation = MouseCursor.locationCoreGraphics, + let mouseLocation = MouseHelpers.locationCoreGraphics, let windowUnderMouse = WindowInfo.createWindows(option: .onScreen) .filter({ $0.layer < CGWindowLevelForKey(.cursorWindow) }) .first(where: { $0.bounds.contains(mouseLocation) && $0.title?.isEmpty == false }), @@ -273,7 +273,7 @@ extension EventManager { guard appState.settings.advanced.enableSecondaryContextMenu, isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen), - let mouseLocation = MouseCursor.locationAppKit + let mouseLocation = MouseHelpers.locationAppKit else { return } @@ -451,7 +451,7 @@ extension EventManager { let iceIcon = appState.menuBarManager.controlItem(withName: .visible), let iceIconFrame = iceIcon.frame, iceIconFrame.maxY <= screen.frame.maxY, - let mouseLocation = MouseCursor.locationAppKit + let mouseLocation = MouseHelpers.locationAppKit else { return false } @@ -467,7 +467,7 @@ extension EventManager { /// the bounds of the current application menu. func isMouseInsideApplicationMenu(appState: AppState, screen: NSScreen) -> Bool { guard - let mouseLocation = MouseCursor.locationCoreGraphics, + let mouseLocation = MouseHelpers.locationCoreGraphics, var applicationMenuFrame = screen.getApplicationMenuFrame() else { return false @@ -480,7 +480,7 @@ extension EventManager { /// A Boolean value that indicates whether the mouse pointer is within /// the bounds of a menu bar item. func isMouseInsideMenuBarItem(appState: AppState, screen: NSScreen) -> Bool { - guard let mouseLocation = MouseCursor.locationCoreGraphics else { + guard let mouseLocation = MouseHelpers.locationCoreGraphics else { return false } let windowIDs = Bridging.getMenuBarWindowList(option: [.onScreen, .activeSpace, .itemsOnly]) @@ -498,7 +498,7 @@ extension EventManager { /// If the screen does not have a notch, this property returns `false`. func isMouseInsideNotch(appState: AppState, screen: NSScreen) -> Bool { guard - let mouseLocation = MouseCursor.locationAppKit, + let mouseLocation = MouseHelpers.locationAppKit, var frameOfNotch = screen.frameOfNotch else { return false @@ -519,7 +519,7 @@ extension EventManager { /// A Boolean value that indicates whether the mouse pointer is within /// the bounds of the Ice Bar panel. func isMouseInsideIceBar(appState: AppState) -> Bool { - guard let mouseLocation = MouseCursor.locationAppKit else { + guard let mouseLocation = MouseHelpers.locationAppKit else { return false } let panel = appState.menuBarManager.iceBarPanel @@ -535,7 +535,7 @@ extension EventManager { guard let visibleSection = appState.menuBarManager.section(withName: .visible), let iceIconFrame = visibleSection.controlItem.frame, - let mouseLocation = MouseCursor.locationAppKit + let mouseLocation = MouseHelpers.locationAppKit else { return false } diff --git a/Ice/Events/EventTap.swift b/Ice/Events/EventTap.swift index 2f76f25af..a3366ba80 100644 --- a/Ice/Events/EventTap.swift +++ b/Ice/Events/EventTap.swift @@ -9,7 +9,7 @@ import OSLog /// A type that receives system events from various locations within the /// event stream. final class EventTap { - /// Constants that specify the possible tapping locations for events. + /// Constants that specify the possible locations for an event tap. enum Location { /// The location where HID system events enter the window server. case hidEventTap @@ -26,6 +26,7 @@ final class EventTap { /// process. case pid(pid_t) + /// A string to use for logging purposes. var logString: String { switch self { case .hidEventTap: "HID event tap" @@ -36,20 +37,24 @@ final class EventTap { } } + /// Shared logger for event taps. private static let logger = Logger(category: "EventTap") - private static let concurrentQueue = DispatchQueue( + + /// Top level concurrent queue to run the shared event tap callback. + private static let concurrentQueue = DispatchQueue.targetingGlobal( label: "EventTap.concurrentQueue", qos: .userInteractive, attributes: .concurrent ) - private static let eventTapCallBack: CGEventTapCallBack = { _, type, event, refcon in - concurrentQueue.sync { + /// The shared event tap callback. + private static let eventTapCallback: CGEventTapCallBack = { _, type, event, refcon in + concurrentQueue.asyncAndWait { guard let refcon else { return Unmanaged.passUnretained(event) } let tap: EventTap = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - return tap.callbackQueue.sync { + return tap.callbackQueue.asyncAndWait(flags: .barrier) { if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { tap.enable() return nil @@ -65,33 +70,37 @@ final class EventTap { } private var machPort: CFMachPort? - private var runLoop: CFRunLoop? private var source: CFRunLoopSource? + private let runLoop: CFRunLoop + private let callbackQueue: DispatchQueue private let callback: (EventTap, CGEvent) -> CGEvent? /// The label associated with the event tap. let label: String - /// The queue that performs the tap's callback. - var callbackQueue: DispatchQueue - - /// A Boolean value that indicates whether the event tap is enabled. + /// A Boolean value that indicates whether the event tap is actively + /// listening for events. var isEnabled: Bool { - guard let machPort else { - return false - } + guard let machPort else { return false } return CGEvent.tapIsEnabled(tap: machPort) } + /// A Boolean value that indicates whether the event tap is valid and + /// able to receive events. + var isValid: Bool { + guard let machPort else { return false } + return CFMachPortIsValid(machPort) + } + /// Creates a new event tap for the given event types. /// /// - Parameters: /// - label: The label associated with the tap. /// - options: A constant that specifies whether the tap is an active /// filter or a passive listener. - /// - location: The location of the tap. - /// - placement: The placement of the tap relative to other active taps. - /// - types: The set of event types observed by the tap. + /// - location: The location in the event stream to insert the tap. + /// - placement: The tap's placement relative to other active taps. + /// - types: Specifies the types of the events received by the tap. /// - callbackQueue: A dispatch queue that performs the tap's callback. /// - callback: A callback function to perform when events are received. init( @@ -99,32 +108,30 @@ final class EventTap { options: CGEventTapOptions, location: Location, placement: CGEventTapPlacement, - types: Set, + types: [CGEventType], callbackQueue: DispatchQueue? = nil, callback: @escaping (_ tap: EventTap, _ event: CGEvent) -> CGEvent? ) { self.label = label self.callback = callback + self.runLoop = RunLoop.current.getCFRunLoop() self.callbackQueue = callbackQueue ?? DispatchQueue(label: label) guard - let machPort = createMachPort( + let machPort = EventTap.createMachPort( location: location, placement: placement, options: options, - types: types + eventMask: types.reduce(0) { $0 | (1 << $1.rawValue) }, + userInfo: Unmanaged.passUnretained(self).toOpaque() ), - let runLoop = CFRunLoopGetCurrent(), let source = CFMachPortCreateRunLoopSource(nil, machPort, 0) else { EventTap.logger.error(#"Error creating event tap "\#(label, privacy: .public)""#) return } - CFRunLoopAddSource(runLoop, source, .commonModes) - self.machPort = machPort - self.runLoop = runLoop self.source = source } @@ -134,9 +141,9 @@ final class EventTap { /// - label: The label associated with the tap. /// - options: A constant that specifies whether the tap is an active /// filter or a passive listener. - /// - location: The location of the tap. - /// - placement: The placement of the tap relative to other active taps. - /// - types: The event type observed by the tap. + /// - location: The location in the event stream to insert the tap. + /// - placement: The tap's placement relative to other active taps. + /// - type: Specifies the type of the events received by the tap. /// - callbackQueue: A dispatch queue that performs the tap's callback. /// - callback: A callback function to perform when events are received. convenience init( @@ -160,7 +167,7 @@ final class EventTap { } deinit { - if let runLoop, let source { + if let source { CFRunLoopRemoveSource(runLoop, source, .commonModes) } if let machPort { @@ -169,63 +176,64 @@ final class EventTap { } } - private func createMachPort( + private static func createMachPort( location: Location, placement: CGEventTapPlacement, options: CGEventTapOptions, - types: Set + eventMask: CGEventMask, + userInfo: UnsafeMutableRawPointer ) -> CFMachPort? { - func createEventMask() -> CGEventMask { - types.reduce(0) { $0 | (1 << $1.rawValue) } - } - - func createUserInfo() -> UnsafeMutableRawPointer { - Unmanaged.passUnretained(self).toOpaque() - } - - func createMachPortForLocation(_ location: CGEventTapLocation) -> CFMachPort? { + func createMachPort(location: CGEventTapLocation) -> CFMachPort? { CGEvent.tapCreate( tap: location, place: placement, options: options, - eventsOfInterest: createEventMask(), - callback: EventTap.eventTapCallBack, - userInfo: createUserInfo() + eventsOfInterest: eventMask, + callback: eventTapCallback, + userInfo: userInfo ) } - func createMachPortForPid(_ pid: pid_t) -> CFMachPort? { + func createMachPort(pid: pid_t) -> CFMachPort? { CGEvent.tapCreateForPid( pid: pid, place: placement, options: options, - eventsOfInterest: createEventMask(), - callback: EventTap.eventTapCallBack, - userInfo: createUserInfo() + eventsOfInterest: eventMask, + callback: eventTapCallback, + userInfo: userInfo ) } switch location { case .hidEventTap: - return createMachPortForLocation(.cghidEventTap) + return createMachPort(location: .cghidEventTap) case .sessionEventTap: - return createMachPortForLocation(.cgSessionEventTap) + return createMachPort(location: .cgSessionEventTap) case .annotatedSessionEventTap: - return createMachPortForLocation(.cgAnnotatedSessionEventTap) + return createMachPort(location: .cgAnnotatedSessionEventTap) case .pid(let pid): - return createMachPortForPid(pid) + return createMachPort(pid: pid) } } /// Enables the event tap. func enable() { - guard let machPort else { return } - CGEvent.tapEnable(tap: machPort, enable: true) + if let source { + CFRunLoopAddSource(runLoop, source, .commonModes) + } + if let machPort { + CGEvent.tapEnable(tap: machPort, enable: true) + } } /// Disables the event tap. func disable() { - guard let machPort else { return } - CGEvent.tapEnable(tap: machPort, enable: false) + if let source { + CFRunLoopRemoveSource(runLoop, source, .commonModes) + } + if let machPort { + CGEvent.tapEnable(tap: machPort, enable: false) + } } } diff --git a/Ice/MenuBar/IceBar/IceBar.swift b/Ice/MenuBar/IceBar/IceBar.swift index 07ff64151..9d5863c6a 100644 --- a/Ice/MenuBar/IceBar/IceBar.swift +++ b/Ice/MenuBar/IceBar/IceBar.swift @@ -123,7 +123,7 @@ final class IceBarPanel: NSPanel { } return getOrigin(for: .iceIcon) case .mousePointer: - guard let location = MouseCursor.locationAppKit else { + guard let location = MouseHelpers.locationAppKit else { return getOrigin(for: .iceIcon) } diff --git a/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift b/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift index 2cbba602a..b87420dfd 100644 --- a/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift +++ b/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift @@ -122,7 +122,7 @@ final class LayoutBarPaddingView: NSView { Task { try await Task.sleep(for: .milliseconds(25)) do { - try await appState.itemManager.slowMove(item: item, to: destination) + try await appState.itemManager.move(item: item, to: destination) appState.itemManager.removeTempShownItemFromCache(with: item.tag) } catch { Logger.general.error("Error moving menu bar item: \(error, privacy: .public)") diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift index cfd696a27..d523eea14 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift @@ -97,20 +97,24 @@ struct MenuBarItem: CustomStringConvertible { // Most items will use their computed "best name", but we need to // handle a few special cases for system items. + + if tag == .controlCenter { + return bestName + } + return switch tag.namespace { - case .passwords, .weather: + case .passwords, .weather, .textInputMenuAgent: // "PasswordsMenuBarExtra" -> "Passwords" // "WeatherMenu" -> "Weather" - String(toTitleCase(bestName).prefix { !$0.isWhitespace }) - case .textInputMenuAgent: - toTitleCase(bestName).components(separatedBy: .whitespaces).prefix { $0 != "Agent" }.joined(separator: " ") + // "TextInputMenuAgent" -> "Text Input" + toTitleCase(bestName.replacing(/Menu.*/, with: "")) case .controlCenter where title.hasPrefix("BentoBox"): - bestName - case .controlCenter where title == "WiFi": - title + toTitleCase(title.replacing(/-/, with: " ")) case .controlCenter where title.hasPrefix("Hearing"): // Changed to "Hearing_GlowE" in macOS 15.4. - String(toTitleCase(title).prefix { $0.isLetter || $0.isNumber }) + toTitleCase(title.prefix { $0.isLetter || $0.isNumber }) + case .controlCenter where title == "WiFi": + title case .systemUIServer where title.contains("TimeMachine"): // Sonoma: "TimeMachine.TMMenuExtraHost" // Sequoia: "TimeMachineMenuExtra.TMMenuExtraHost" @@ -128,7 +132,7 @@ struct MenuBarItem: CustomStringConvertible { /// A textual representation of the item. var description: String { - String(describing: tag) + "\(displayName) (\(tag))" } /// A string to use for logging purposes. diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index 1f0a47bea..7c618fa9c 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -106,6 +106,15 @@ final class MenuBarItemManager: ObservableObject { } return timestamp.duration(to: .now) <= duration } + + /// Returns a duration derived from the refresh rate of the given screen. + /// + /// We use this method to avoid tight loops where traditional observation + /// isn't supported (sometimes the case with private APIs). Should really + /// only be used after exhausting all other options. + private func getSleepDurationFromScreenRefreshRate(screen: NSScreen) -> Duration { + Duration.seconds(screen.maximumRefreshInterval.clamped(to: 0.01...0.1)) + } } // MARK: - Item Cache @@ -116,8 +125,8 @@ extension MenuBarItemManager { /// All cached menu bar items, keyed by section. private var storage = [MenuBarSection.Name: [MenuBarItem]]() - /// The identifier of the display with the active menu bar at - /// the time this cache was created. + /// The identifier of the display with the active menu bar at the + /// time this cache was created. let displayID: CGDirectDisplayID? /// The cached menu bar items as an array. @@ -335,12 +344,30 @@ extension MenuBarItemManager { // MARK: - Async Waiters extension MenuBarItemManager { + /// An error that can occur during an asynchronous wait operation. + private enum WaitOperationError: LocalizedError { + case timeout + case missingScreenWithMouse + case other(any Error) + + var errorDescription: String? { + switch self { + case .timeout: + "Wait operation timed out" + case .missingScreenWithMouse: + "Couldn't find screen with mouse" + case .other(let error): + "Wait operation failed with error: \(error.localizedDescription)" + } + } + } + /// Waits asynchronously for the given operation to complete. /// /// - Parameters: /// - timeout: Amount of time to wait before throwing an error. /// - operation: The operation to perform. - private func waitWithTask( + private func performWaitOperation( timeout: Duration?, @_inheritActorContext @_implicitSelfCapture operation: sending @escaping @isolated(any) () async throws -> Void @@ -350,21 +377,32 @@ extension MenuBarItemManager { } else { Task(operation: operation) } - try await task.value + do { + try await task.value + } catch let error as WaitOperationError { + throw error + } catch is TaskTimeoutError { + throw WaitOperationError.timeout + } catch { + throw WaitOperationError.other(error) + } } /// Waits asynchronously for the mouse to stop moving. /// /// - Parameter timeout: Amount of time to wait before throwing an error. private func waitForMouseToStopMoving(timeout: Duration? = nil) async throws { - let duration = Duration.milliseconds(100) - guard MouseEvents.lastMovementOccurred(within: duration) else { + guard let screen = NSScreen.screenWithMouse else { + throw WaitOperationError.missingScreenWithMouse + } + let duration = getSleepDurationFromScreenRefreshRate(screen: screen) + guard MouseHelpers.lastMovementOccurred(within: duration) else { return } - try await waitWithTask(timeout: timeout) { + try await performWaitOperation(timeout: timeout) { while true { try Task.checkCancellation() - if !MouseEvents.lastMovementOccurred(within: duration) { + if !MouseHelpers.lastMovementOccurred(within: duration) { break } try await Task.sleep(for: duration) @@ -376,25 +414,28 @@ extension MenuBarItemManager { /// /// - Parameter timeout: Amount of time to wait before throwing an error. private func waitForAllMouseButtonsUp(timeout: Duration? = nil) async throws { - guard MouseEvents.isButtonPressed() else { + guard MouseHelpers.isButtonPressed() else { return } - try await waitWithTask(timeout: timeout) { + try await performWaitOperation(timeout: timeout) { var cancellable: AnyCancellable? - await withCheckedContinuation { continuation in + try await withCancellingContinuation { continuation in let mask: NSEvent.EventTypeMask = [.leftMouseUp, .rightMouseUp, .otherMouseUp] cancellable = RunLoopLocalEventMonitor.publisher(for: mask, mode: .eventTracking) .merge(with: EventMonitor.publish(events: mask, scope: .universal)) .removeDuplicates() .combineLatest(Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()) .sink { _ in - if MouseEvents.isButtonPressed() { + if MouseHelpers.isButtonPressed() { return } cancellable?.cancel() continuation.resume() } + } onCancel: { continuation in + cancellable?.cancel() + continuation.cancel() } } } @@ -406,10 +447,10 @@ extension MenuBarItemManager { if NSEvent.modifierFlags.isEmpty { return } - try await waitWithTask(timeout: timeout) { + try await performWaitOperation(timeout: timeout) { var cancellable: AnyCancellable? - await withCheckedContinuation { continuation in + try await withCancellingContinuation { continuation in let mask: NSEvent.EventTypeMask = .flagsChanged cancellable = RunLoopLocalEventMonitor.publisher(for: mask, mode: .eventTracking) .merge(with: EventMonitor.publish(events: mask, scope: .universal)) @@ -422,6 +463,9 @@ extension MenuBarItemManager { cancellable?.cancel() continuation.resume() } + } onCancel: { continuation in + cancellable?.cancel() + continuation.cancel() } } } @@ -566,7 +610,7 @@ extension MenuBarItemManager { /// Returns the current mouse location. private func getMouseLocation(item: MenuBarItem) throws -> CGPoint { - guard let location = MouseCursor.locationCoreGraphics else { + guard let location = MouseHelpers.locationCoreGraphics else { throw EventError(code: .missingMouseLocation, item: item) } return location @@ -636,23 +680,22 @@ extension MenuBarItemManager { item: MenuBarItem, timeout: Duration ) async throws { - var eventTap: EventTap? + let timeoutTask = Task(timeout: timeout) { [weak self] in + guard let self else { + throw EventError(code: .couldNotComplete, item: item) + } - let timeoutTask = Task(timeout: timeout) { - try await withCheckedThrowingContinuation { continuation in + var eventTap: EventTap? + + await withCheckedContinuation { continuation in eventTap = EventTap( options: .listenOnly, location: location, placement: .tailAppendEventTap, type: event.type, - callbackQueue: scrombleQueue - ) { [weak self] tap, rEvent in - guard let self else { - tap.disable() - return rEvent - } - - guard eventsMatch([rEvent, event], by: CGEventField.menuBarItemEventFields) else { + callbackQueue: self.scrombleQueue + ) { tap, rEvent in + guard self.eventsMatch([rEvent, event], by: CGEventField.menuBarItemEventFields) else { return rEvent } @@ -663,11 +706,9 @@ extension MenuBarItemManager { } eventTap?.enable() - - postEvent(event, to: location) + self.postEvent(event, to: location) } } - do { try await timeoutTask.value } catch is TaskTimeoutError { @@ -677,19 +718,18 @@ extension MenuBarItemManager { } } - /// Does a lot of weird magic to make a menu bar item receive - /// an event. + /// Does a lot of weird magic to make a menu bar item receive an event. /// /// - Parameters: /// - event: The event to post. - /// - firstLocation: The first location to post the event. - /// - secondLocation: The second location to post the event. + /// - firstTapLocation: The first event tap location to post the event. + /// - secondTapLocation: The second event tap location to post the event. /// - item: The menu bar item that the event targets. /// - timeout: The duration to wait before throwing an error. private func scrombleEvent( _ event: CGEvent, - from firstLocation: EventTap.Location, - to secondLocation: EventTap.Location, + from firstTapLocation: EventTap.Location, + to secondTapLocation: EventTap.Location, item: MenuBarItem, timeout: Duration ) async throws { @@ -697,70 +737,86 @@ extension MenuBarItemManager { throw EventError(code: .eventCreationFailure, item: item) } - var eventTap1: EventTap? - var eventTap2: EventTap? + let timeoutTask = Task(timeout: timeout) { [weak self] in + guard let self else { + throw EventError(code: .couldNotComplete, item: item) + } + + var eventTap1: EventTap? + var eventTap2: EventTap? + var eventTap3: EventTap? - let timeoutTask = Task(timeout: timeout) { await withCheckedContinuation { continuation in - // Create an event tap for the null event at the first location. - // Once this tap receives the event, it posts the real event to - // the second location and discards the null event. + // Create an event tap that listens for the null event at the first tap + // location. This tap posts the actual event to the second tap location + // and discards the null event. eventTap1 = EventTap( label: "EventTap 1", options: .defaultTap, - location: firstLocation, + location: firstTapLocation, placement: .headInsertEventTap, type: nullEvent.type, - callbackQueue: scrombleQueue - ) { [weak self] tap, rEvent in - guard let self else { - tap.disable() - return rEvent - } - - guard eventsMatch([rEvent, nullEvent], by: [.eventSourceUserData]) else { + callbackQueue: self.scrombleQueue + ) { tap, rEvent in + guard self.eventsMatch([rEvent, nullEvent], by: [.eventSourceUserData]) else { return rEvent } tap.disable() - postEvent(event, to: secondLocation) + self.postEvent(event, to: secondTapLocation) return nil } - // Create an event tap for the real event at the second location. - // Once this tap receives the event, it resumes the continuation. + // Create an event tap that listens for the actual event at the second + // tap location. This tap posts the event to the first tap location and + // returns normally. eventTap2 = EventTap( label: "EventTap 2", options: .listenOnly, - location: secondLocation, + location: secondTapLocation, placement: .tailAppendEventTap, type: event.type, - callbackQueue: scrombleQueue - ) { [weak self] tap, rEvent in - guard let self else { - tap.disable() + callbackQueue: self.scrombleQueue + ) { tap, rEvent in + guard self.eventsMatch([rEvent, event], by: CGEventField.menuBarItemEventFields) else { return rEvent } - guard eventsMatch([rEvent, event], by: CGEventField.menuBarItemEventFields) else { + tap.disable() + self.postEvent(event, to: firstTapLocation) + + return rEvent + } + + // Create an event tap that listens for the actual event at the first tap + // location. This tap resumes the continuation and discards the event. + eventTap3 = EventTap( + label: "EventTap 3", + options: .defaultTap, + location: firstTapLocation, + placement: .headInsertEventTap, + type: event.type, + callbackQueue: self.scrombleQueue + ) { tap, rEvent in + guard self.eventsMatch([rEvent, event], by: CGEventField.menuBarItemEventFields) else { return rEvent } tap.disable() continuation.resume() - return rEvent + return nil } eventTap1?.enable() eventTap2?.enable() + eventTap3?.enable() - // Post the null event to the first location. - postEvent(nullEvent, to: firstLocation) + // Post the null event to the first tap location to start the event chain. + self.postEvent(nullEvent, to: firstTapLocation) } } - do { try await timeoutTask.value } catch is TaskTimeoutError { @@ -769,6 +825,56 @@ extension MenuBarItemManager { throw EventError(code: .couldNotComplete, item: item) } } + + /// Does a lot of weird magic to make a menu bar item receive an event, + /// then waits for the item to respond. + /// + /// - Parameters: + /// - event: The event to post. + /// - firstTapLocation: The first event tap location to post the event. + /// - secondTapLocation: The second event tap location to post the event. + /// - item: The menu bar item that the event targets. + /// - screen: A screen whose refresh rate determines the duration between + /// each response check. + /// - timeout: The duration to wait before throwing an error. + private func scrombleEvent( + _ event: CGEvent, + from firstTapLocation: EventTap.Location, + to secondTapLocation: EventTap.Location, + untilItemResponds item: MenuBarItem, + screen: NSScreen, + timeout: Duration + ) async throws { + let duration = getSleepDurationFromScreenRefreshRate(screen: screen) + let initialBounds = try getCurrentBounds(for: item) + let boundsCheckTask = Task(timeout: timeout) { + while true { + try Task.checkCancellation() + try await scrombleEvent(event, from: firstTapLocation, to: secondTapLocation, item: item, timeout: timeout) + let currentBounds = try getCurrentBounds(for: item) + guard currentBounds != initialBounds else { + try await Task.sleep(for: duration) + continue + } + logger.debug( + """ + Bounds for \(item.logString, privacy: .public) changed \ + to \(NSStringFromRect(currentBounds), privacy: .public) + """ + ) + return + } + } + do { + try await boundsCheckTask.value + } catch let error as EventError { + throw error + } catch is TaskTimeoutError { + throw EventError(code: .boundsCheckTimeout, item: item) + } catch { + throw EventError(code: .couldNotComplete, item: item) + } + } } // MARK: - Move Operations @@ -801,8 +907,8 @@ extension MenuBarItemManager { private func getEndLocation(for destination: MoveDestination) throws -> CGPoint { let bounds = try getCurrentBounds(for: destination.targetItem) return switch destination { - case .leftOfItem: CGPoint(x: bounds.minX, y: bounds.midY) - case .rightOfItem: CGPoint(x: bounds.maxX, y: bounds.midY) + case .leftOfItem: CGPoint(x: bounds.minX, y: bounds.minY) + case .rightOfItem: CGPoint(x: bounds.maxX, y: bounds.minY) } } @@ -817,113 +923,6 @@ extension MenuBarItemManager { } } - /// Actions to perform after an event is received. - enum ScrombleEventDeferredAction { - struct BoundsChangeOptions: OptionSet { - let rawValue: Int - - static let ignoreErrors = BoundsChangeOptions(rawValue: 1 << 0) - static let sleepOnError = BoundsChangeOptions(rawValue: 1 << 1) - } - - case waitForBoundsChange(options: BoundsChangeOptions = []) - - func createTask( - with item: MenuBarItem, - timeout: Duration, - manager: MenuBarItemManager - ) async -> () async throws -> Void { - switch self { - case .waitForBoundsChange(let options): - let boundsResult = await Task { - try await manager.getCurrentBounds(for: item) - }.result - return { - do { - let bounds = try boundsResult.get() - try await manager.waitForBoundsChange( - of: item, - initialBounds: bounds, - timeout: timeout - ) - } catch { - manager.logger.warning("Bounds check failed with error: \(error, privacy: .public)") - if options.contains(.sleepOnError) { - await manager.eventSleep(for: .milliseconds(100)) - } - if options.contains(.ignoreErrors) { - return - } - throw error - } - } - } - } - } - - /// Does a lot of weird magic to make a menu bar item receive - /// an event, then performs the given action. - /// - /// - Parameters: - /// - event: The event to post. - /// - firstLocation: The first location to post the event. - /// - secondLocation: The second location to post the event. - /// - item: The menu bar item that the event targets. - /// - timeout: The duration to wait before throwing an error. - /// - deferredAction: An action to perform after the event is - /// received. - private func scrombleEvent( - _ event: CGEvent, - from firstLocation: EventTap.Location, - to secondLocation: EventTap.Location, - item: MenuBarItem, - timeout: Duration, - deferredAction: ScrombleEventDeferredAction - ) async throws { - let deferredTask = await deferredAction.createTask(with: item, timeout: timeout, manager: self) - try await scrombleEvent(event, from: firstLocation, to: secondLocation, item: item, timeout: timeout) - try await deferredTask() - } - - /// Waits for a menu bar item's bounds to change from an initial value. - /// - /// - Parameters: - /// - item: The menu bar item to check for bounds changes. - /// - initialBounds: An initial value to determine whether the item's - /// bounds have changed. - /// - timeout: The duration to wait before throwing an error. - private func waitForBoundsChange( - of item: MenuBarItem, - initialBounds: CGRect, - timeout: Duration - ) async throws { - let boundsCheckTask = Task(timeout: timeout) { - while true { - try Task.checkCancellation() - let currentBounds = try getCurrentBounds(for: item) - guard currentBounds != initialBounds else { - continue - } - logger.debug( - """ - Bounds for \(item.logString, privacy: .public) changed \ - to \(NSStringFromRect(currentBounds), privacy: .public) - """ - ) - return - } - } - do { - try await boundsCheckTask.value - } catch let error as EventError { - throw error - } catch is TaskTimeoutError { - throw EventError(code: .boundsCheckTimeout, item: item) - } catch { - throw EventError(code: .couldNotComplete, item: item) - } - } - /// Attempts to move a menu bar item to the given destination. /// /// - Parameters: @@ -936,29 +935,41 @@ extension MenuBarItemManager { item: MenuBarItem, destination: MoveDestination, source: CGEventSource, + screen: NSScreen, timeout: Duration ) async throws { + let itemBounds = try getCurrentBounds(for: item) + let startLocation = CGPoint(x: 20_000, y: 20_000) + let endLocation = try getEndLocation(for: destination) + let fallbackLocation = CGPoint(x: itemBounds.midX, y: itemBounds.minY) let pid = item.sourcePID ?? item.ownerPID guard let moveEvent1 = CGEvent.menuBarItemEvent( source: source, - type: .move(.leftMouseDown), - location: CGPoint(x: 20_000, y: 20_000), + type: .move(.mouseDown), + location: startLocation, item: item, pid: pid ), let moveEvent2 = CGEvent.menuBarItemEvent( source: source, - type: .move(.leftMouseUp), - location: try getEndLocation(for: destination), + type: .move(.mouseDragged), + location: endLocation, + item: item, + pid: pid + ), + let moveEvent3 = CGEvent.menuBarItemEvent( + source: source, + type: .move(.mouseUp), + location: endLocation, item: destination.targetItem, pid: pid ), let fallbackEvent = CGEvent.menuBarItemEvent( source: source, - type: .move(.leftMouseUp), - location: try getCurrentBounds(for: item).center, + type: .move(.mouseUp), + location: fallbackLocation, item: item, pid: pid ) @@ -974,16 +985,22 @@ extension MenuBarItemManager { from: .pid(pid), to: .sessionEventTap, item: item, - timeout: timeout, - deferredAction: .waitForBoundsChange(options: [.ignoreErrors, .sleepOnError]) + timeout: timeout ) try await scrombleEvent( moveEvent2, from: .pid(pid), to: .sessionEventTap, + untilItemResponds: item, + screen: screen, + timeout: timeout + ) + try await scrombleEvent( + moveEvent3, + from: .pid(pid), + to: .sessionEventTap, item: item, - timeout: timeout, - deferredAction: .waitForBoundsChange(options: .sleepOnError) + timeout: timeout ) } catch { logger.warning("Move events failed. Posting fallback.") @@ -1017,7 +1034,7 @@ extension MenuBarItemManager { func move( item: MenuBarItem, to destination: MoveDestination, - timeout: Duration = .milliseconds(100) + timeout: Duration = .milliseconds(250) ) async throws { guard item.isMovable else { throw EventError(code: .notMovable, item: item) @@ -1032,18 +1049,19 @@ extension MenuBarItemManager { } do { - // FIXME: Running these checks sequentially like this is prone to error. + // FIXME: Running these checks sequentially is prone to error. // - // For example, say the user is holding down a modifier key while moving - // their mouse - they release the modifier, continue moving their mouse, - // then press the modifier again. We would completely miss this, as the - // modifier check would already be finished. We'd have the same problem - // running the checks concurrently. + // Say, for example, the user is holding down a modifier key while + // dragging their mouse. It's reasonable that they could finish the + // drag and start a new one, all while still holding the modifier. + // Since the mouse movement and button checks would have finished at + // the end of the first drag, we would completely miss this. We'd + // have the same problem running the checks concurrently. // // We need a way to cooperatively restart each check as needed. - try await waitForAllModifierKeysUp() try await waitForMouseToStopMoving() try await waitForAllMouseButtonsUp() + try await waitForAllModifierKeysUp() } catch { throw EventError(code: .couldNotComplete, item: item) } @@ -1061,16 +1079,23 @@ extension MenuBarItemManager { item: item ) + guard + let displayID = Bridging.getActiveMenuBarDisplayID(), + let screen = NSScreen.screens.first(where: { $0.displayID == displayID }) + else { + throw EventError(code: .couldNotComplete, item: item) + } + appState.eventManager.stopAll() defer { appState.eventManager.startAll() } - MouseCursor.hide() + MouseHelpers.hideCursor() defer { - MouseCursor.warp(to: mouseLocation) - MouseCursor.show() + MouseHelpers.warpCursor(to: mouseLocation) + MouseHelpers.showCursor() } logger.debug( @@ -1090,6 +1115,7 @@ extension MenuBarItemManager { item: item, destination: destination, source: source, + screen: screen, timeout: timeout ) } catch where n < 5 { @@ -1112,33 +1138,6 @@ extension MenuBarItemManager { throw EventError(code: .couldNotComplete, item: item) } } - - /// Moves a menu bar item to the given destination and waits until - /// the move is finished before returning. - /// - /// - Parameters: - /// - item: The menu bar item to move. - /// - destination: The destination to move the menu bar item. - /// - timeout: The duration to wait before throwing an error. - func slowMove( - item: MenuBarItem, - to destination: MoveDestination, - timeout: Duration = .seconds(1) - ) async throws { - try await move(item: item, to: destination, timeout: .milliseconds(100)) - - let waitTask = Task(timeout: timeout) { - while try !itemHasCorrectPosition(item: item, for: destination) { - try Task.checkCancellation() - } - } - - do { - try await waitTask.value - } catch is TaskTimeoutError { - throw EventError(code: .otherTimeout, item: item) - } - } } // MARK: - Click Operations @@ -1153,7 +1152,7 @@ extension MenuBarItemManager { func click( item: MenuBarItem, with mouseButton: CGMouseButton, - timeout: Duration = .milliseconds(100) + timeout: Duration = .milliseconds(250) ) async throws { guard let appState else { throw EventError(code: .invalidAppState, item: item) @@ -1161,30 +1160,30 @@ extension MenuBarItemManager { let source = try getEventSource(item: item) let mouseLocation = try getMouseLocation(item: item) - let currentBounds = try getCurrentBounds(for: item) + let itemBounds = try getCurrentBounds(for: item) - let buttonStates = mouseButton.buttonStates - let clickLocation = currentBounds.center + let mouseStates = mouseButton.mouseStates + let clickLocation = itemBounds.center let pid = item.sourcePID ?? item.ownerPID guard let clickEvent1 = CGEvent.menuBarItemEvent( source: source, - type: .click(buttonStates.down), + type: .click(mouseStates.down), location: clickLocation, item: item, pid: pid ), let clickEvent2 = CGEvent.menuBarItemEvent( source: source, - type: .click(buttonStates.up), + type: .click(mouseStates.up), location: clickLocation, item: item, pid: pid ), let fallbackEvent = CGEvent.menuBarItemEvent( source: source, - type: .click(buttonStates.up), + type: .click(mouseStates.up), location: clickLocation, item: item, pid: pid @@ -1208,11 +1207,11 @@ extension MenuBarItemManager { appState.eventManager.startAll() } - MouseCursor.hide() + MouseHelpers.hideCursor() defer { - MouseCursor.warp(to: mouseLocation) - MouseCursor.show() + MouseHelpers.warpCursor(to: mouseLocation) + MouseHelpers.showCursor() } logger.debug( @@ -1230,6 +1229,7 @@ extension MenuBarItemManager { item: item, timeout: timeout ) + await eventSleep() try await scrombleEvent( clickEvent2, from: .pid(pid), @@ -1237,6 +1237,7 @@ extension MenuBarItemManager { item: item, timeout: timeout ) + await eventSleep() logger.debug("Successfully clicked item") } catch { logger.warning("Click events failed. Posting fallback.") @@ -1266,7 +1267,7 @@ extension MenuBarItemManager { extension MenuBarItemManager { /// Context for a temporarily shown menu bar item. - private struct TempShownItemContext { + private final class TempShownItemContext { /// The tag associated with the item. let tag: MenuBarItemTag @@ -1274,7 +1275,7 @@ extension MenuBarItemManager { let returnDestination: MoveDestination /// The window of the item's shown interface. - let shownInterfaceWindow: WindowInfo? + var shownInterfaceWindow: WindowInfo? /// The number of attempts that have been made to rehide the item. var rehideAttempts = 0 @@ -1297,6 +1298,11 @@ extension MenuBarItemManager { return currentWindow.isOnScreen } } + + init(tag: MenuBarItemTag, returnDestination: MoveDestination) { + self.tag = tag + self.returnDestination = returnDestination + } } /// Gets the destination to return the given item to after it is @@ -1399,18 +1405,21 @@ extension MenuBarItemManager { logger.debug("Temporarily showing \(item.logString, privacy: .public)") do { - try await slowMove(item: item, to: .leftOfItem(targetItem)) + try await move(item: item, to: .leftOfItem(targetItem)) } catch { logger.error("Error showing item: \(error, privacy: .public)") return } + let context = TempShownItemContext(tag: item.tag, returnDestination: destination) + tempShownItemContexts.append(context) + rehideTimer?.invalidate() defer { runRehideTimer() } - await eventSleep() + await eventSleep(for: .milliseconds(100)) let idsBeforeClick = Set(Bridging.getWindowList(option: .onScreen)) @@ -1418,29 +1427,16 @@ extension MenuBarItemManager { try await click(item: item, with: mouseButton) } catch { logger.error("Error clicking item: \(error, privacy: .public)") - let context = TempShownItemContext( - tag: item.tag, - returnDestination: destination, - shownInterfaceWindow: nil - ) - tempShownItemContexts.append(context) return } - await eventSleep(for: .seconds(0.5)) + await eventSleep(for: .milliseconds(500)) let windowsAfterClick = WindowInfo.createWindows(option: .onScreen) - let window = windowsAfterClick.first { window in + context.shownInterfaceWindow = windowsAfterClick.first { window in window.ownerPID == item.sourcePID && !idsBeforeClick.contains(window.windowID) } - - let context = TempShownItemContext( - tag: item.tag, - returnDestination: destination, - shownInterfaceWindow: window - ) - tempShownItemContexts.append(context) } /// Rehides all temporarily shown items. @@ -1463,12 +1459,12 @@ extension MenuBarItemManager { logger.debug("Rehiding temporarily shown items") - while var context = tempShownItemContexts.popLast() { + while let context = tempShownItemContexts.popLast() { guard let item = items.first(where: { $0.tag == context.tag }) else { continue } do { - try await slowMove(item: item, to: context.returnDestination) + try await move(item: item, to: context.returnDestination) } catch { context.rehideAttempts += 1 logger.warning( @@ -1534,7 +1530,7 @@ extension MenuBarItemManager { do { logger.debug("Control items have incorrect order") - try await slowMove(item: alwaysHidden, to: .leftOfItem(hidden)) + try await move(item: alwaysHidden, to: .leftOfItem(hidden)) } catch { logger.error("Error enforcing control item order: \(error, privacy: .public)") } @@ -1543,33 +1539,32 @@ extension MenuBarItemManager { // MARK: - Helper Types -/// Button states for menu bar item events. -private enum MenuBarItemEventButtonState { +/// Mouse states for menu bar item move events. +private enum MenuBarItemMoveEventMouseState { + case mouseDown + case mouseUp + case mouseDragged + + var cgEventType: CGEventType { + switch self { + case .mouseDown: .leftMouseDown + case .mouseUp: .leftMouseUp + case .mouseDragged: .leftMouseDragged + } + } +} + +/// Mouse states for menu bar item click events. +private enum MenuBarItemClickEventMouseState { case leftMouseDown case leftMouseUp case rightMouseDown case rightMouseUp case otherMouseDown case otherMouseUp -} - -/// Event types for menu bar item events. -private enum MenuBarItemEventType { - /// The event type for moving a menu bar item. - case move(MenuBarItemEventButtonState) - /// The event type for clicking a menu bar item. - case click(MenuBarItemEventButtonState) - - /// The button state of this event type. - var buttonState: MenuBarItemEventButtonState { - switch self { - case .move(let state), .click(let state): state - } - } - /// This event type's equivalent CGEventType. var cgEventType: CGEventType { - switch buttonState { + switch self { case .leftMouseDown: .leftMouseDown case .leftMouseUp: .leftMouseUp case .rightMouseDown: .rightMouseDown @@ -1579,20 +1574,40 @@ private enum MenuBarItemEventType { } } - /// The event flags for this event type. + var cgMouseButton: CGMouseButton { + switch self { + case .leftMouseDown, .leftMouseUp: .left + case .rightMouseDown, .rightMouseUp: .right + case .otherMouseDown, .otherMouseUp: .center + } + } +} + +/// Event types for menu bar item events. +private enum MenuBarItemEventType { + /// The event type for moving a menu bar item. + case move(MenuBarItemMoveEventMouseState) + /// The event type for clicking a menu bar item. + case click(MenuBarItemClickEventMouseState) + + var cgEventType: CGEventType { + switch self { + case .move(let state): state.cgEventType + case .click(let state): state.cgEventType + } + } + var cgEventFlags: CGEventFlags { switch self { - case .move(.leftMouseDown): .maskCommand + case .move(.mouseDown): .maskCommand case .move, .click: [] } } - /// The mouse button for this event type. - var mouseButton: CGMouseButton { - switch buttonState { - case .leftMouseDown, .leftMouseUp: .left - case .rightMouseDown, .rightMouseUp: .right - case .otherMouseDown, .otherMouseUp: .center + var cgMouseButton: CGMouseButton { + switch self { + case .move: .left + case .click(let state): state.cgMouseButton } } } @@ -1603,20 +1618,13 @@ private extension CGEventField { /// Key to access a field that contains the event's window identifier. static let windowID = CGEventField(rawValue: 0x33)! // swiftlint:disable:this force_unwrapping - /// An array of integer fields that are required for a menu bar item event. - static let menuBarItemRequiredWindowFields: [CGEventField] = [ + /// Fields that can be used to compare menu bar item events. + static let menuBarItemEventFields: [CGEventField] = [ + .eventSourceUserData, .mouseEventWindowUnderMousePointer, .mouseEventWindowUnderMousePointerThatCanHandleThisEvent, + .windowID, ] - - /// An array of integer fields that may be set for a menu bar item event. - static let menuBarItemOptionalWindowFields: [CGEventField] = [.windowID] - - /// An array of integer event fields that can be used to compare menu bar item events. - static let menuBarItemEventFields: [CGEventField] = { - let baseFields: [CGEventField] = [.eventSourceUserData] - return baseFields + menuBarItemRequiredWindowFields + menuBarItemOptionalWindowFields - }() } // MARK: - CGEventFilterMask Helpers @@ -1673,8 +1681,8 @@ private extension CGMouseButton { } } - /// The equivalent down and up button states for menu bar item click events. - var buttonStates: (down: MenuBarItemEventButtonState, up: MenuBarItemEventButtonState) { + /// The equivalent down and up mouse states for menu bar item click events. + var mouseStates: (down: MenuBarItemClickEventMouseState, up: MenuBarItemClickEventMouseState) { switch self { case .left: (.leftMouseDown, .leftMouseUp) case .right: (.rightMouseDown, .rightMouseUp) @@ -1691,11 +1699,11 @@ private extension CGEvent { /// - Parameters: /// - source: The source of the event. /// - type: The type of the event. - /// - location: The location of the event. Does not need to be - /// within the bounds of the item. + /// - location: The location of the event. Does not need to be within + /// the bounds of the item. /// - item: The target item of the event. - /// - pid: The target process identifier of the event. Does not - /// need to be the item's `ownerPID`. + /// - pid: The target process identifier of the event. Does not need + /// to be the item's `ownerPID`. static func menuBarItemEvent( source: CGEventSource, type: MenuBarItemEventType, @@ -1707,13 +1715,13 @@ private extension CGEvent { mouseEventSource: source, mouseType: type.cgEventType, mouseCursorPosition: location, - mouseButton: type.mouseButton + mouseButton: type.cgMouseButton ) else { return nil } event.setFlags(for: type) - event.setTargetPID(pid) event.setUserData(ObjectIdentifier(event)) + event.setTargetPID(pid) event.setWindowID(item.windowID, for: type) event.setClickState(for: type) return event @@ -1732,22 +1740,21 @@ private extension CGEvent { flags = type.cgEventFlags } - private func setTargetPID(_ pid: pid_t) { - let targetPID = Int64(pid) - setIntegerValueField(.eventTargetUnixProcessID, value: targetPID) - } - private func setUserData(_ bitPattern: ObjectIdentifier) { let userData = Int64(Int(bitPattern: bitPattern)) setIntegerValueField(.eventSourceUserData, value: userData) } + private func setTargetPID(_ pid: pid_t) { + let targetPID = Int64(pid) + setIntegerValueField(.eventTargetUnixProcessID, value: targetPID) + } + private func setWindowID(_ windowID: CGWindowID, for type: MenuBarItemEventType) { let windowID = Int64(windowID) - for field in CGEventField.menuBarItemRequiredWindowFields { - setIntegerValueField(field, value: windowID) - } + setIntegerValueField(.mouseEventWindowUnderMousePointer, value: windowID) + setIntegerValueField(.mouseEventWindowUnderMousePointerThatCanHandleThisEvent, value: windowID) if case .move = type { setIntegerValueField(.windowID, value: windowID) diff --git a/Ice/Utilities/ConcurrencyHelpers.swift b/Ice/Utilities/ConcurrencyHelpers.swift new file mode 100644 index 000000000..96142bb62 --- /dev/null +++ b/Ice/Utilities/ConcurrencyHelpers.swift @@ -0,0 +1,218 @@ +// +// ConcurrencyHelpers.swift +// Ice +// + +import Foundation +import os.lock + +// MARK: - Task Timeout + +/// An error that indicates that a task timed out. +struct TaskTimeoutError: CustomStringConvertible, LocalizedError { + let description = "Task timed out before completion" + var errorDescription: String? { description } +} + +extension Task { + /// Runs the given throwing operation asynchronously alongside a + /// timeout operation in a structured task group. + /// + /// If the operation does not complete within the provided + /// duration, the timeout operation cancels the group and throws + /// a ``TaskTimeoutError``. + /// + /// - Parameters: + /// - timeout: The duration the operation must complete within. + /// - tolerance: The precision threshold of the timeout operation. + /// - clock: The clock that manages the timeout operation. + /// - operation: The operation to perform. + /// + /// - Returns: The result of the operation, if successful. + private static func withTimeout( + _ timeout: C.Instant.Duration, + tolerance: C.Instant.Duration?, + clock: C, + operation: sending @escaping @isolated(any) () async throws -> Success + ) async throws -> Success { + try await withThrowingTaskGroup(of: Success.self) { group in + group.addTask { + try await operation() + } + group.addTask { + try await _Concurrency.Task.sleep(for: timeout, tolerance: tolerance, clock: clock) + throw TaskTimeoutError() + } + guard let success = try await group.next() else { + throw _Concurrency.CancellationError() + } + group.cancelAll() + return success + } + } +} + +extension Task where Failure == any Error { + /// Runs the given throwing operation asynchronously as part of a + /// new _unstructured_ top-level task. + /// + /// If the operation does not complete within the provided duration, + /// the task is cancelled and a ``TaskTimeoutError`` is thrown. + /// + /// - Parameters: + /// - timeout: The duration the operation must complete within. + /// - tolerance: The precision threshold of the timeout operation. + /// - clock: The clock that manages the timeout operation. + /// - name: Human readable name of the task. + /// - priority: The priority of the operation. + /// - operation: The operation to perform. + @discardableResult + init( + timeout: C.Instant.Duration, + tolerance: C.Instant.Duration? = nil, + clock: C = .continuous, + name: String? = nil, + priority: TaskPriority? = nil, + @_inheritActorContext @_implicitSelfCapture + operation: sending @escaping @isolated(any) () async throws -> Success + ) { + self.init(name: name, priority: priority) { + try await Task.withTimeout(timeout, tolerance: tolerance, clock: clock, operation: operation) + } + } + + /// Runs the given throwing operation asynchronously as part of a + /// new _unstructured_ _detached_ top-level task. + /// + /// If the operation does not complete within the provided duration, + /// the task is cancelled and a ``TaskTimeoutError`` is thrown. + /// + /// - Parameters: + /// - timeout: The duration the operation must complete within. + /// - tolerance: The precision threshold of the timeout operation. + /// - clock: The clock that manages the timeout operation. + /// - name: Human readable name of the task. + /// - priority: The priority of the operation. + /// - operation: The operation to perform. + /// + /// - Returns: A reference to the task. + @discardableResult + static func detached( + timeout: C.Instant.Duration, + tolerance: C.Instant.Duration? = nil, + clock: C = .continuous, + name: String? = nil, + priority: TaskPriority? = nil, + operation: sending @escaping @isolated(any) () async throws -> Success + ) -> Task { + detached(name: name, priority: priority) { + try await withTimeout(timeout, tolerance: tolerance, clock: clock, operation: operation) + } + } +} + +// MARK: - CancellingContinuation + +struct CancellingContinuation: Sendable { + private enum State: @unchecked Sendable { + case initial + case willCancel + case willResume(Result) + case awaiting(CheckedContinuation) + case cancelled + case resumed + + mutating func set(_ continuation: CheckedContinuation, function: String) { + switch self { + case .initial: + self = .awaiting(continuation) + case .willCancel: + continuation.resume(throwing: CancellationError()) + self = .cancelled + case .willResume(let result): + continuation.resume(with: result) + self = .resumed + case .awaiting, .cancelled, .resumed: + fatalError("SWIFT TASK CONTINUATION MISUSE: \(function) tried to await its continuation more than once.") + } + } + + mutating func cancel() { + switch self { + case .initial, .willCancel, .willResume: + self = .willCancel + case .awaiting(let continuation): + continuation.resume(throwing: CancellationError()) + self = .cancelled + case .cancelled, .resumed: + break // Ignore. + } + } + + mutating func resume(result: sending Result, function: String) { + switch self { + case .initial: + self = .willResume(result) + case .willCancel, .cancelled: + break // Ignore. + case .willResume, .resumed: + fatalError("SWIFT TASK CONTINUATION MISUSE: \(function) tried to resume its continuation more than once.") + case .awaiting(let continuation): + continuation.resume(with: result) + self = .resumed + } + } + } + + private let state = OSAllocatedUnfairLock(initialState: State.initial) + private let function: String + + fileprivate init(function: String) { + self.function = function + } + + func resume(with result: sending Result) { + state.withLock { [result] in $0.resume(result: result, function: function) } + } + + func resume(returning value: sending T) { + resume(with: .success(value)) + } + + func resume(throwing error: any Error) { + resume(with: .failure(error)) + } + + func resume() where T == Void { + resume(returning: ()) + } + + func cancel() { + state.withLock { $0.cancel() } + } + + fileprivate func wait( + isolation: isolated (any Actor)? = #isolation, + body: (CancellingContinuation) -> Void, + onCancel: (CancellingContinuation) -> Void + ) async throws -> sending T { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation(function: function) { continuation in + state.withLock { $0.set(continuation, function: function) } + body(self) + } + } onCancel: { + onCancel(self) + } + } +} + +func withCancellingContinuation( + isolation: isolated (any Actor)? = #isolation, + function: String = #function, + body: (_ continuation: CancellingContinuation) -> Void, + onCancel: (_ continuation: CancellingContinuation) -> Void +) async throws -> sending T { + let continuation = CancellingContinuation(function: function) + return try await continuation.wait(body: body, onCancel: onCancel) +} diff --git a/Ice/Utilities/MouseHelpers.swift b/Ice/Utilities/MouseHelpers.swift index ec3b19814..004d283c0 100644 --- a/Ice/Utilities/MouseHelpers.swift +++ b/Ice/Utilities/MouseHelpers.swift @@ -6,51 +6,62 @@ import CoreGraphics import OSLog -/// A namespace for mouse cursor operations. -enum MouseCursor { - /// Returns the location of the mouse cursor in the coordinate space used by - /// the `AppKit` framework, with the origin at the bottom left of the screen. +/// A namespace for mouse helper operations. +enum MouseHelpers { + /// Returns the location of the mouse cursor in the coordinate + /// space used by `AppKit`, with the origin at the bottom left + /// of the screen. static var locationAppKit: CGPoint? { CGEvent(source: nil)?.unflippedLocation } - /// Returns the location of the mouse cursor in the coordinate space used by - /// the `CoreGraphics` framework, with the origin at the top left of the screen. + /// Returns the location of the mouse cursor in the coordinate + /// space used by `CoreGraphics`, with the origin at the top left + /// of the screen. static var locationCoreGraphics: CGPoint? { CGEvent(source: nil)?.location } /// Hides the mouse cursor and increments the hide cursor count. - static func hide() { + static func hideCursor() { let result = CGDisplayHideCursor(CGMainDisplayID()) if result != .success { Logger.general.error("CGDisplayHideCursor failed with error \(result.logString, privacy: .public)") } } - /// Decrements the hide cursor count and shows the mouse cursor if the count is `0`. - static func show() { + /// Decrements the hide cursor count and shows the mouse cursor + /// if the count is `0`. + static func showCursor() { let result = CGDisplayShowCursor(CGMainDisplayID()) if result != .success { Logger.general.error("CGDisplayShowCursor failed with error \(result.logString, privacy: .public)") } } - /// Moves the mouse cursor to the given point without generating events. + /// Moves the mouse cursor to the given point without generating + /// events. /// - /// - Parameter point: The point to move the cursor to in global display coordinates. - static func warp(to point: CGPoint) { + /// - Parameter point: The point to move the cursor to in global + /// display coordinates. + static func warpCursor(to point: CGPoint) { let result = CGWarpMouseCursorPosition(point) if result != .success { Logger.general.error("CGWarpMouseCursorPosition failed with error \(result.logString, privacy: .public)") } } -} -// MARK: - MouseEvents + /// Connects or disconnects the positions of the mouse and cursor. + /// + /// - Parameter connected: A Boolean value that determines whether + /// to connect or disconnect the positions. + static func associateMouseAndCursor(_ connected: Bool) { + let result = CGAssociateMouseAndMouseCursorPosition(connected ? 1 : 0) + if result != .success { + Logger.general.error("CGAssociateMouseAndMouseCursorPosition failed with error \(result.logString, privacy: .public)") + } + } -/// A namespace for mouse event operations. -enum MouseEvents { /// Returns a Boolean value that indicates whether a mouse button /// is pressed. /// diff --git a/Ice/Utilities/TaskHelpers.swift b/Ice/Utilities/TaskHelpers.swift deleted file mode 100644 index 9ded002b4..000000000 --- a/Ice/Utilities/TaskHelpers.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// TaskHelpers.swift -// Ice -// - -import Foundation - -// MARK: - Task Timeout - -extension Task where Failure == any Error { - /// Runs the given throwing operation asynchronously as part of a new - /// top-level task on behalf of the current actor. - /// - /// - Parameters: - /// - timeout: The amount of time to wait before cancelling the task - /// by throwing a ``TaskTimeoutError``. - /// - tolerance: The tolerance of the clock. - /// - clock: The clock that manages the timeout operation. - /// - priority: The priority of the task. - /// - operation: The operation to perform. - @discardableResult - init( - timeout: C.Instant.Duration, - tolerance: C.Instant.Duration? = nil, - clock: C = ContinuousClock(), - priority: TaskPriority? = nil, - @_inheritActorContext @_implicitSelfCapture - operation: sending @escaping @isolated(any) () async throws -> Success - ) { - self.init(priority: priority) { - try await Task.run(operation: operation, withTimeout: timeout, tolerance: tolerance, clock: clock) - } - } - - /// Runs the given throwing operation asynchronously as part of a new - /// top-level task. - /// - /// - Parameters: - /// - timeout: The amount of time to wait before cancelling the task - /// by throwing a ``TaskTimeoutError``. - /// - tolerance: The tolerance of the clock. - /// - clock: The clock that manages the timeout operation. - /// - priority: The priority of the task. - /// - operation: The operation to perform. - /// - /// - Returns: A reference to the task. - @discardableResult - static func detached( - timeout: C.Instant.Duration, - tolerance: C.Instant.Duration? = nil, - clock: C = ContinuousClock(), - priority: TaskPriority? = nil, - operation: sending @escaping @isolated(any) () async throws -> Success - ) -> Task { - detached(priority: priority) { - try await run(operation: operation, withTimeout: timeout, tolerance: tolerance, clock: clock) - } - } - - private static func run( - operation: sending @escaping @isolated(any) () async throws -> Success, - withTimeout timeout: C.Instant.Duration, - tolerance: C.Instant.Duration?, - clock: C - ) async throws -> Success { - try await withThrowingTaskGroup(of: Success.self) { group in - group.addTask(operation: operation) - group.addTask { - try await _Concurrency.Task.sleep(for: timeout, tolerance: tolerance, clock: clock) - throw TaskTimeoutError() - } - guard let success = try await group.next() else { - throw _Concurrency.CancellationError() - } - group.cancelAll() - return success - } - } -} - -// MARK: TaskTimeoutError - -/// An error that indicates that a task timed out. -struct TaskTimeoutError: LocalizedError, CustomStringConvertible { - let description = "Task timed out before completion" - var errorDescription: String? { description } -} From 2053b418a77fcc926d06ed5d053f90164aebf7a6 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Wed, 13 Aug 2025 21:43:02 -0600 Subject: [PATCH 44/80] UI changes and cleanup - Remove BindingExposable - Update views to use bindings directly - Lots of other UI refactoring --- Ice/Main/AppState.swift | 3 - Ice/Main/Updates.swift | 3 - .../MenuBarShapes.swift} | 29 ++- .../MenuBarTintKind.swift | 2 +- .../MenuBarAppearanceEditor.swift | 2 +- .../MenuBarAppearanceEditorPanel.swift | 17 +- .../MenuBarShapePicker.swift | 11 +- Ice/MenuBar/ControlItem/ControlItem.swift | 35 +-- Ice/MenuBar/LayoutBar/LayoutBar.swift | 4 +- .../LayoutBar/LayoutBarPaddingView.swift | 15 +- .../LayoutBar/LayoutBarScrollView.swift | 37 +-- Ice/MenuBar/MenuBarItems/MenuBarItem.swift | 38 +-- Ice/MenuBar/MenuBarManager.swift | 3 - Ice/MenuBar/MenuBarSection.swift | 10 +- Ice/MenuBar/Search/MenuBarSearchPanel.swift | 238 +++++++++--------- .../SettingsPanes/AboutSettingsPane.swift | 42 ++-- .../SettingsPanes/AdvancedSettingsPane.swift | 8 +- .../MenuBarAppearanceSettingsPane.swift | 5 +- .../MenuBarLayoutSettingsPane.swift | 15 +- Ice/Settings/SettingsView.swift | 36 ++- Ice/Settings/SettingsWindow.swift | 5 +- Ice/UI/IceUI/IceForm.swift | 33 +-- .../ErasedToAnyView.swift | 0 .../LocalEventMonitorModifier.swift | 0 .../OnFrameChange.swift | 29 +-- .../OnKeyDown.swift | 0 .../OnWindowChange.swift | 0 .../{ViewModifiers => Modifiers}/Once.swift | 0 .../ViewModifiers/RemoveSidebarToggle.swift | 16 -- Ice/UI/Views/SectionedList.swift | 44 ++-- Ice/Utilities/BindingExposable.swift | 45 ---- 31 files changed, 285 insertions(+), 440 deletions(-) rename Ice/MenuBar/Appearance/{MenuBarShape.swift => Configurations/MenuBarShapes.swift} (67%) rename Ice/MenuBar/Appearance/{ => Configurations}/MenuBarTintKind.swift (94%) rename Ice/UI/{ViewModifiers => Modifiers}/ErasedToAnyView.swift (100%) rename Ice/UI/{ViewModifiers => Modifiers}/LocalEventMonitorModifier.swift (100%) rename Ice/UI/{ViewModifiers => Modifiers}/OnFrameChange.swift (51%) rename Ice/UI/{ViewModifiers => Modifiers}/OnKeyDown.swift (100%) rename Ice/UI/{ViewModifiers => Modifiers}/OnWindowChange.swift (100%) rename Ice/UI/{ViewModifiers => Modifiers}/Once.swift (100%) delete mode 100644 Ice/UI/ViewModifiers/RemoveSidebarToggle.swift delete mode 100644 Ice/Utilities/BindingExposable.swift diff --git a/Ice/Main/AppState.swift b/Ice/Main/AppState.swift index b9ea57e49..9135ef97a 100644 --- a/Ice/Main/AppState.swift +++ b/Ice/Main/AppState.swift @@ -261,6 +261,3 @@ final class AppState: ObservableObject { NSApp.deactivate() } } - -// MARK: AppState: BindingExposable -extension AppState: BindingExposable { } diff --git a/Ice/Main/Updates.swift b/Ice/Main/Updates.swift index 30e4a8dd8..39a115485 100644 --- a/Ice/Main/Updates.swift +++ b/Ice/Main/Updates.swift @@ -135,6 +135,3 @@ extension UpdatesManager: @preconcurrency SPUStandardUserDriverDelegate { appState.userNotificationManager.removeDeliveredNotifications(with: [.updateCheck]) } } - -// MARK: UpdatesManager: BindingExposable -extension UpdatesManager: BindingExposable { } diff --git a/Ice/MenuBar/Appearance/MenuBarShape.swift b/Ice/MenuBar/Appearance/Configurations/MenuBarShapes.swift similarity index 67% rename from Ice/MenuBar/Appearance/MenuBarShape.swift rename to Ice/MenuBar/Appearance/Configurations/MenuBarShapes.swift index 41f9cf4a7..32a3da72a 100644 --- a/Ice/MenuBar/Appearance/MenuBarShape.swift +++ b/Ice/MenuBar/Appearance/Configurations/MenuBarShapes.swift @@ -1,12 +1,12 @@ // -// MenuBarShape.swift +// MenuBarShapes.swift // Ice // -import CoreGraphics +import SwiftUI /// An end cap in a menu bar shape. -enum MenuBarEndCap: Int, Codable, Hashable, CaseIterable { +enum MenuBarEndCap: Int, CaseIterable, Codable, Hashable { /// An end cap with a square shape. case square = 0 /// An end cap with a rounded shape. @@ -14,18 +14,28 @@ enum MenuBarEndCap: Int, Codable, Hashable, CaseIterable { } /// A type that specifies a custom shape kind for the menu bar. -enum MenuBarShapeKind: Int, Codable, Hashable, CaseIterable { +enum MenuBarShapeKind: Int, CaseIterable, Codable, Identifiable { /// The menu bar does not use a custom shape. case noShape = 0 /// A custom shape that takes up the full menu bar. case full = 1 - /// A custom shape that splits the menu bar between - /// its leading and trailing sides. + /// A custom shape that splits the menu bar between its leading + /// and trailing sides. case split = 2 + + var id: Int { rawValue } + + /// Localized string key representation. + var localized: LocalizedStringKey { + switch self { + case .noShape: "None" + case .full: "Full" + case .split: "Split" + } + } } -/// Information for the ``MenuBarShapeKind/full`` menu bar -/// shape kind. +/// Information for the ``MenuBarShapeKind/full`` menu bar shape kind. struct MenuBarFullShapeInfo: Codable, Hashable { /// The leading end cap of the shape. var leadingEndCap: MenuBarEndCap @@ -43,8 +53,7 @@ extension MenuBarFullShapeInfo { static let `default` = MenuBarFullShapeInfo(leadingEndCap: .round, trailingEndCap: .round) } -/// Information for the ``MenuBarShapeKind/split`` menu bar -/// shape kind. +/// Information for the ``MenuBarShapeKind/split`` menu bar shape kind. struct MenuBarSplitShapeInfo: Codable, Hashable { /// The leading information of the shape. var leading: MenuBarFullShapeInfo diff --git a/Ice/MenuBar/Appearance/MenuBarTintKind.swift b/Ice/MenuBar/Appearance/Configurations/MenuBarTintKind.swift similarity index 94% rename from Ice/MenuBar/Appearance/MenuBarTintKind.swift rename to Ice/MenuBar/Appearance/Configurations/MenuBarTintKind.swift index 3a04c8c18..332a29a61 100644 --- a/Ice/MenuBar/Appearance/MenuBarTintKind.swift +++ b/Ice/MenuBar/Appearance/Configurations/MenuBarTintKind.swift @@ -19,7 +19,7 @@ enum MenuBarTintKind: Int, CaseIterable, Codable, Identifiable { /// Localized string key representation. var localized: LocalizedStringKey { switch self { - case .noTint: "No Tint" + case .noTint: "None" case .solid: "Solid" case .gradient: "Gradient" } diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift index 7d37059d4..18d5316d4 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift @@ -12,7 +12,7 @@ struct MenuBarAppearanceEditor: View { } @EnvironmentObject var appState: AppState - @EnvironmentObject var appearanceManager: MenuBarAppearanceManager + @ObservedObject var appearanceManager: MenuBarAppearanceManager let location: Location diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift index 7d10b6b7c..f53c3f3af 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift @@ -34,11 +34,10 @@ final class MenuBarAppearanceEditorPanel: NSPanel { self.titlebarAppearsTransparent = true self.isExcludedFromWindowsMenu = false self.becomesKeyOnlyIfNeeded = true - self.isMovableByWindowBackground = false - self.isMovable = false self.hidesOnDeactivate = false self.level = .floating self.collectionBehavior = [.fullScreenAuxiliary, .ignoresCycle, .moveToActiveSpace] + self.animationBehavior = .documentWindow standardWindowButton(.closeButton)?.isHidden = true } @@ -81,17 +80,16 @@ final class MenuBarAppearanceEditorPanel: NSPanel { cancellables = c } - /// Updates the origin of the panel's frame for display - /// on the given screen. - private func updateOrigin(for screen: NSScreen) { + /// Updates the panel's position for display on the given screen. + private func updatePosition(for screen: NSScreen) { let originX = screen.frame.midX - frame.width / 2 - let originY = screen.visibleFrame.maxY - frame.height - setFrameOrigin(CGPoint(x: originX, y: originY)) + let originY = screen.frame.maxY - frame.height / 8 + setFrameTopLeftPoint(CGPoint(x: originX, y: originY)) } /// Shows the panel on the given screen. func show(on screen: NSScreen) { - updateOrigin(for: screen) + updatePosition(for: screen) makeKeyAndOrderFront(nil) } @@ -132,7 +130,7 @@ private struct MenuBarAppearanceEditorContentView: View { @ObservedObject var appState: AppState var body: some View { - MenuBarAppearanceEditor(location: .panel) + MenuBarAppearanceEditor(appearanceManager: appState.appearanceManager, location: .panel) .background { Rectangle() .fill(.regularMaterial) @@ -141,6 +139,5 @@ private struct MenuBarAppearanceEditorContentView: View { .opacity(0.25) } .environmentObject(appState) - .environmentObject(appState.appearanceManager) } } diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarShapePicker.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarShapePicker.swift index ba4ccc6b3..47b85ec07 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarShapePicker.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarShapePicker.swift @@ -25,15 +25,8 @@ struct MenuBarShapePicker: View { @ViewBuilder private var shapeKindPicker: some View { IcePicker("Shape Kind", selection: $configuration.shapeKind) { - ForEach(MenuBarShapeKind.allCases, id: \.self) { shape in - switch shape { - case .noShape: - Text("No Shape").tag(shape) - case .full: - Text("Full").tag(shape) - case .split: - Text("Split").tag(shape) - } + ForEach(MenuBarShapeKind.allCases) { shapeKind in + Text(shapeKind.localized).tag(shapeKind) } } } diff --git a/Ice/MenuBar/ControlItem/ControlItem.swift b/Ice/MenuBar/ControlItem/ControlItem.swift index c9be784d2..d54c6084b 100644 --- a/Ice/MenuBar/ControlItem/ControlItem.swift +++ b/Ice/MenuBar/ControlItem/ControlItem.swift @@ -366,7 +366,7 @@ final class ControlItem { switch identifier { case .visible: updateStatusItemVisibility(true, state: state) - updateButtonEnabledState(true) // Make sure button is enabled. + button.appearsDisabled = false let icon = appState.settings.general.iceIcon @@ -395,7 +395,8 @@ final class ControlItem { switch appState.settings.advanced.sectionDividerStyle { case .noDivider: updateStatusItemVisibility(false, state: state) - updateButtonEnabledState(false) // Keep button from highlighting. + button.appearsDisabled = true + button.isHighlighted = false if appState.isDraggingMenuBarItem && appState.settings.advanced.showAllSectionsOnUserDrag { // We still want a subtle marker between sections. @@ -403,7 +404,7 @@ final class ControlItem { } case .chevron: updateStatusItemVisibility(true, state: state) - updateButtonEnabledState(true) // Make sure button is enabled. + button.appearsDisabled = false button.image = switch identifier { case .hidden: @@ -415,7 +416,8 @@ final class ControlItem { } case .hideSection: updateStatusItemVisibility(true, state: state) - updateButtonEnabledState(false) // Keep button from highlighting. + button.appearsDisabled = true + button.isHighlighted = false } } } @@ -473,19 +475,6 @@ final class ControlItem { ControlItemDefaults[.preferredPosition, autosaveName] = cached } - /// Updates the enabled state of the status item's button. - private func updateButtonEnabledState(_ isEnabled: Bool) { - guard let button = statusItem.button else { - return - } - if isEnabled { - button.cell?.isEnabled = true - } else { - button.cell?.isEnabled = false - button.isHighlighted = false - } - } - /// Performs the control item's action. @objc private func performAction() { guard @@ -496,7 +485,7 @@ final class ControlItem { } switch event.type { - case .leftMouseDown, .leftMouseUp: + case .leftMouseDown: let modifierFlags = NSEvent.modifierFlags // Running this from a Task seems to improve the visual @@ -576,7 +565,7 @@ final class ControlItem { continue } let item = NSMenuItem( - title: "\(section.isHidden ? "Show" : "Hide") the \(name.displayString) Section", + title: "\(section.isHidden ? "Show" : "Hide") \(name.displayString) Section", action: #selector(toggleMenuBarSection), keyEquivalent: "" ) @@ -647,13 +636,7 @@ final class ControlItem { /// Opens the menu bar search panel. @objc private func showSearchPanel() { - guard - let appState, - let screen = MenuBarSearchPanel.defaultScreen - else { - return - } - appState.menuBarManager.searchPanel.show(on: screen) + appState?.menuBarManager.searchPanel.show() } /// Opens the settings window and checks for app updates. diff --git a/Ice/MenuBar/LayoutBar/LayoutBar.swift b/Ice/MenuBar/LayoutBar/LayoutBar.swift index a89e67b2b..f05f0973d 100644 --- a/Ice/MenuBar/LayoutBar/LayoutBar.swift +++ b/Ice/MenuBar/LayoutBar/LayoutBar.swift @@ -18,7 +18,7 @@ struct LayoutBar: View { } @EnvironmentObject var appState: AppState - @EnvironmentObject var imageCache: MenuBarItemImageCache + @ObservedObject var imageCache: MenuBarItemImageCache let section: MenuBarSection.Name @@ -35,6 +35,7 @@ struct LayoutBar: View { .frame(height: 48) .frame(maxWidth: .infinity) .menuBarItemContainer(appState: appState) + .containerShape(backgroundShape) .clipShape(backgroundShape) .contentShape([.interaction, .focusEffect], backgroundShape) .overlay { @@ -47,6 +48,7 @@ struct LayoutBar: View { private var mainContent: some View { if imageCache.cacheFailed(for: section) { Text("Unable to display menu bar items") + .font(.body) } else { Representable(appState: appState, section: section) } diff --git a/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift b/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift index b87420dfd..2ed73bb58 100644 --- a/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift +++ b/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift @@ -12,10 +12,6 @@ final class LayoutBarPaddingView: NSView { private let container: LayoutBarContainer /// The layout view's arranged views. - /// - /// The views are laid out from left to right in the order that they - /// appear in the array. The ``spacing`` property determines the amount - /// of space between each view. var arrangedViews: [LayoutBarItemView] { get { container.arrangedViews } set { container.arrangedViews = newValue } @@ -30,20 +26,13 @@ final class LayoutBarPaddingView: NSView { self.container = LayoutBarContainer(appState: appState, section: section) super.init(frame: .zero) - addSubview(self.container) + addSubview(container) self.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ - // center the container along the y axis container.centerYAnchor.constraint(equalTo: centerYAnchor), - - // give the container a few points of trailing space trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: 7.5), - - // allow variable spacing between leading anchors to let the view stretch - // to fit whatever size is required; container should remain aligned toward - // the trailing edge; this view is itself nested in a scroll view, so if it - // has to expand to a larger size, it can be clipped leadingAnchor.constraint(lessThanOrEqualTo: container.leadingAnchor, constant: -7.5), ]) diff --git a/Ice/MenuBar/LayoutBar/LayoutBarScrollView.swift b/Ice/MenuBar/LayoutBar/LayoutBarScrollView.swift index 5a2afe70c..9532c55cf 100644 --- a/Ice/MenuBar/LayoutBar/LayoutBarScrollView.swift +++ b/Ice/MenuBar/LayoutBar/LayoutBarScrollView.swift @@ -28,29 +28,17 @@ final class LayoutBarScrollView: NSScrollView { super.init(frame: .zero) + self.documentView = paddingView self.hasHorizontalScroller = true - self.horizontalScroller = HorizontalScroller() - - self.autohidesScrollers = true - + self.hasVerticalScroller = false self.verticalScrollElasticity = .none - + self.autohidesScrollers = true self.drawsBackground = false - - self.documentView = self.paddingView - self.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ - // constrain the padding view's height to the content view's height paddingView.heightAnchor.constraint(equalTo: contentView.heightAnchor), - - // constrain the padding view's width to greater than or equal to the content - // view's width paddingView.widthAnchor.constraint(greaterThanOrEqualTo: contentView.widthAnchor), - - // constrain the padding view's trailing anchor to the content view's trailing - // anchor; this, in combination with the above width constraint, aligns the - // items in the layout bar to the trailing edge paddingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), ]) } @@ -66,20 +54,3 @@ extension LayoutBarScrollView { return arrangedViews } } - -extension LayoutBarScrollView { - /// A custom scroller that overrides its knob slot to be transparent. - final class HorizontalScroller: NSScroller { - override static var isCompatibleWithOverlayScrollers: Bool { true } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - self.controlSize = .mini - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - } -} diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift index d523eea14..d08c5bd97 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift @@ -303,13 +303,8 @@ private extension MenuBarItemTag { /// This initializer does not perform validity checks on its parameters. /// Only call it if you are certain the window is a valid menu bar item. init(uncheckedItemWindow itemWindow: WindowInfo) { - let title = itemWindow.title ?? "" - if title.hasPrefix("Ice.ControlItem") { - self.namespace = .ice - } else { - self.namespace = Namespace(uncheckedItemWindow: itemWindow) - } - self.title = title + self.namespace = Namespace(uncheckedItemWindow: itemWindow) + self.title = itemWindow.title ?? "" } /// Creates a tag without checks. @@ -319,34 +314,9 @@ private extension MenuBarItemTag { /// and the source pid belongs to the application that created it. @available(macOS 26.0, *) init(uncheckedItemWindow itemWindow: WindowInfo, sourcePID: pid_t?) { - let title = itemWindow.title ?? "" - if title.hasPrefix("Ice.ControlItem") { - self.namespace = .ice - } else { - self.namespace = Namespace(uncheckedItemWindow: itemWindow, sourcePID: sourcePID) - } - self.title = title + self.namespace = Namespace(uncheckedItemWindow: itemWindow, sourcePID: sourcePID) + self.title = itemWindow.title ?? "" } - -// /// Creates a tag without checks. -// /// -// /// This initializer does not perform validity checks on its parameters. -// /// Only call it if you are certain the window is a valid menu bar item. -// init(uncheckedItemWindow itemWindow: WindowInfo) { -// self.namespace = Namespace(uncheckedItemWindow: itemWindow) -// self.title = itemWindow.title ?? "" -// } -// -// /// Creates a tag without checks. -// /// -// /// This initializer does not perform validity checks on its parameters. -// /// Only call it if you are certain the window is a valid menu bar item -// /// and the source pid belongs to the application that created it. -// @available(macOS 26.0, *) -// init(uncheckedItemWindow itemWindow: WindowInfo, sourcePID: pid_t?) { -// self.namespace = Namespace(uncheckedItemWindow: itemWindow, sourcePID: sourcePID) -// self.title = itemWindow.title ?? "" -// } } // MARK: - MenuBarItemTag.Namespace Helper diff --git a/Ice/MenuBar/MenuBarManager.swift b/Ice/MenuBar/MenuBarManager.swift index b85d5387c..37ea8d644 100644 --- a/Ice/MenuBar/MenuBarManager.swift +++ b/Ice/MenuBar/MenuBarManager.swift @@ -359,9 +359,6 @@ final class MenuBarManager: ObservableObject { } } -// MARK: MenuBarManager: BindingExposable -extension MenuBarManager: BindingExposable { } - // MARK: - MenuBarAverageColorInfo /// Information for the average color of the menu bar. diff --git a/Ice/MenuBar/MenuBarSection.swift b/Ice/MenuBar/MenuBarSection.swift index 142382e57..db83ca61b 100644 --- a/Ice/MenuBar/MenuBarSection.swift +++ b/Ice/MenuBar/MenuBarSection.swift @@ -3,7 +3,7 @@ // Ice // -import Cocoa +import SwiftUI /// A representation of a section in a menu bar. @MainActor @@ -31,6 +31,11 @@ final class MenuBarSection { case .alwaysHidden: "always-hidden section" } } + + /// Localized string key representation. + var localized: LocalizedStringKey { + LocalizedStringKey(displayString) + } } /// The name of the section. @@ -277,6 +282,3 @@ final class MenuBarSection { rehideMonitor = nil } } - -// MARK: MenuBarSection: BindingExposable -extension MenuBarSection: BindingExposable { } diff --git a/Ice/MenuBar/Search/MenuBarSearchPanel.swift b/Ice/MenuBar/Search/MenuBarSearchPanel.swift index bb51bdea6..e7ce1693d 100644 --- a/Ice/MenuBar/Search/MenuBarSearchPanel.swift +++ b/Ice/MenuBar/Search/MenuBarSearchPanel.swift @@ -5,15 +5,11 @@ import Combine import Ifrit +import OSLog import SwiftUI /// A panel that contains the menu bar search interface. final class MenuBarSearchPanel: NSPanel { - /// The default screen to show the panel on. - static var defaultScreen: NSScreen? { - NSScreen.screenWithMouse ?? NSScreen.main - } - /// The shared app state. private weak var appState: AppState? @@ -51,6 +47,11 @@ final class MenuBarSearchPanel: NSPanel { return event } + /// The default screen to show the panel on. + var defaultScreen: NSScreen? { + NSScreen.screenWithMouse ?? NSScreen.main + } + /// Overridden to always be `true`. override var canBecomeKey: Bool { true } @@ -101,11 +102,16 @@ final class MenuBarSearchPanel: NSPanel { } /// Shows the search panel on the given screen. - func show(on screen: NSScreen) { + func show(on screen: NSScreen? = nil) { guard let appState else { return } + guard let screen = screen ?? defaultScreen else { + Logger.general.error("Missing screen for search panel") + return + } + // Important that we set the navigation state before updating the cache. appState.navigationState.isSearchPresented = true @@ -134,11 +140,7 @@ final class MenuBarSearchPanel: NSPanel { /// Toggles the panel's visibility. func toggle() { - if isVisible { - close() - } else if let screen = MenuBarSearchPanel.defaultScreen { - show(on: screen) - } + if isVisible { close() } else { show() } } /// Dismisses the search panel. @@ -196,6 +198,10 @@ private struct MenuBarSearchContentView: View { let displayID: CGDirectDisplayID let closePanel: () -> Void + private var hasItems: Bool { + !itemManager.itemCache.managedItems.isEmpty + } + private var bottomBarPadding: CGFloat { if #available(macOS 26.0, *) { return 7 @@ -206,63 +212,9 @@ private struct MenuBarSearchContentView: View { var body: some View { VStack(spacing: 0) { - TextField(text: $model.searchText, prompt: Text("Search menu bar items…")) { - Text("Search menu bar items…") - } - .labelsHidden() - .textFieldStyle(.plain) - .multilineTextAlignment(.leading) - .font(.system(size: 18)) - .padding(15) - .focused($searchFieldIsFocused) - - Divider() - - if itemManager.itemCache.managedItems.isEmpty { - VStack { - Text("Loading menu bar items…") - .font(.title2) - ProgressView() - .controlSize(.small) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if #available(macOS 26.0, *) { - GlassEffectContainer(spacing: 0) { - SectionedList(selection: $model.selection, items: $model.displayedItems) - .contentPadding(8) - .scrollContentBackground(.hidden) - } - .clipped() - } else { - SectionedList(selection: $model.selection, items: $model.displayedItems) - .contentPadding(8) - .scrollContentBackground(.hidden) - } - - Divider() - .offset(y: 1) - .zIndex(1) - - HStack { - SettingsButton { - closePanel() - itemManager.appState?.activate(withPolicy: .regular) - itemManager.appState?.openWindow(.settings) - } - - Spacer() - - if - let selection = model.selection, - let item = menuBarItem(for: selection) - { - ShowItemButton(item: item, displayID: displayID) { - performAction(for: item) - } - } - } - .padding(bottomBarPadding) - .background(.thinMaterial) + searchField + mainContent + bottomBar } .background { VisualEffectView(material: .sheet, blendingMode: .behindWindow) @@ -285,6 +237,70 @@ private struct MenuBarSearchContentView: View { } } + @ViewBuilder + private var searchField: some View { + let promptText = Text("Search menu bar items…") + + VStack(spacing: 0) { + TextField(text: $model.searchText, prompt: promptText) { + promptText + } + .labelsHidden() + .textFieldStyle(.plain) + .multilineTextAlignment(.leading) + .font(.system(size: 18)) + .padding(15) + .focused($searchFieldIsFocused) + + Divider() + } + } + + @ViewBuilder + private var mainContent: some View { + if hasItems { + SectionedList(selection: $model.selection, items: $model.displayedItems) + .contentPadding(8) + .scrollContentBackground(.hidden) + } else { + VStack { + Text("Loading menu bar items…") + .font(.title2) + ProgressView() + .controlSize(.small) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder + private var bottomBar: some View { + HStack { + SettingsButton { + closePanel() + itemManager.appState?.activate(withPolicy: .regular) + itemManager.appState?.openWindow(.settings) + } + + Spacer() + + if + let selection = model.selection, + let item = menuBarItem(for: selection) + { + ShowItemButton(item: item, displayID: displayID) { + performAction(for: item) + } + } + } + .padding(bottomBarPadding) + .background(.thinMaterial) + .buttonStyle(BottomBarButtonStyle()) + .overlay(alignment: .top) { + Divider() + } + } + private func selectFirstDisplayedItem() { model.selection = model.displayedItems.first { $0.isSelectable }?.id } @@ -367,65 +383,14 @@ private struct MenuBarSearchContentView: View { } } -private struct BottomBarButton: View { - @State private var frame = CGRect.zero - @State private var isHovering = false - @State private var isPressed = false - - let content: Content - let action: () -> Void - - private var backgroundShape: some InsettableShape { - if #available(macOS 26.0, *) { - RoundedRectangle(cornerRadius: 8, style: .continuous) - } else { - RoundedRectangle(cornerRadius: 5, style: .circular) - } - } - - init(action: @escaping () -> Void, @ViewBuilder content: () -> Content) { - self.action = action - self.content = content() - } - - var body: some View { - content - .padding(3) - .background { - backgroundShape - .fill(.regularMaterial) - .brightness(0.25) - .opacity(isPressed ? 0.5 : isHovering ? 0.25 : 0) - } - .contentShape(Rectangle()) - .onHover { hovering in - isHovering = hovering - } - .simultaneousGesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - isPressed = frame.contains(value.location) - } - .onEnded { value in - isPressed = false - if frame.contains(value.location) { - action() - } - } - ) - .onFrameChange(update: $frame) - } -} - private struct SettingsButton: View { let action: () -> Void var body: some View { - BottomBarButton(action: action) { + Button(action: action) { Image(.iceCubeStroke) .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 18, height: 18) .foregroundStyle(.secondary) .padding(2) } @@ -450,9 +415,9 @@ private struct ShowItemButton: View { } var body: some View { - BottomBarButton(action: action) { + Button(action: action) { HStack { - Text("\(isOnDisplay ? "Click" : "Show") item") + Text("\(isOnDisplay ? "Click" : "Show") Item") .padding(.leading, 5) Image(systemName: "return") @@ -474,6 +439,35 @@ private struct ShowItemButton: View { } } +private struct BottomBarButtonStyle: ButtonStyle { + @State private var isHovering = false + + private var borderShape: some InsettableShape { + if #available(macOS 26.0, *) { + RoundedRectangle(cornerRadius: 8, style: .continuous) + } else { + RoundedRectangle(cornerRadius: 5, style: .circular) + } + } + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(height: 22) + .frame(minWidth: 22) + .padding(3) + .background { + borderShape + .fill(.regularMaterial) + .brightness(0.25) + .opacity(configuration.isPressed ? 0.5 : isHovering ? 0.25 : 0) + } + .contentShape([.focusEffect, .interaction], borderShape) + .onHover { hovering in + isHovering = hovering + } + } +} + @MainActor private let controlCenterIcon: NSImage? = { guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.controlcenter").first else { diff --git a/Ice/Settings/SettingsPanes/AboutSettingsPane.swift b/Ice/Settings/SettingsPanes/AboutSettingsPane.swift index 3cf9223f0..a59184a18 100644 --- a/Ice/Settings/SettingsPanes/AboutSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/AboutSettingsPane.swift @@ -7,12 +7,9 @@ import SwiftUI struct AboutSettingsPane: View { @EnvironmentObject var appState: AppState + @ObservedObject var updatesManager: UpdatesManager @Environment(\.openURL) private var openURL - private var updatesManager: UpdatesManager { - appState.updatesManager - } - private var acknowledgementsURL: URL { // swiftlint:disable:next force_unwrapping Bundle.main.url(forResource: "Acknowledgements", withExtension: "pdf")! @@ -41,17 +38,25 @@ struct AboutSettingsPane: View { } var body: some View { - VStack(spacing: 0) { - mainForm + if #available(macOS 26.0, *) { + contentForm(cornerStyle: .continuous) + } else { + contentForm(cornerStyle: .circular) + } + } + + @ViewBuilder + private func contentForm(cornerStyle: RoundedCornerStyle) -> some View { + IceForm(spacing: 0) { + mainContent(containerShape: RoundedRectangle(cornerRadius: 20, style: cornerStyle)) Spacer(minLength: 10) - bottomBar + bottomBar(containerShape: Capsule(style: cornerStyle)) } - .padding(.iceFormDefaultPadding) } @ViewBuilder - private var mainForm: some View { - IceForm(padding: EdgeInsets(top: 5, leading: 30, bottom: 30, trailing: 30), spacing: 0) { + private func mainContent(containerShape: some InsettableShape) -> some View { + IceSection(spacing: 0, options: .plain) { appIconAndCopyrightSection .layoutPriority(1) @@ -61,9 +66,11 @@ struct AboutSettingsPane: View { updatesSection .layoutPriority(1) } - .scrollDisabled(true) + .padding(.top, 5) + .padding([.horizontal, .bottom], 30) .frame(maxHeight: 500) - .background(.quinary, in: RoundedRectangle(cornerRadius: 20, style: .circular)) + .background(.quinary, in: containerShape) + .containerShape(containerShape) } @ViewBuilder @@ -111,7 +118,7 @@ struct AboutSettingsPane: View { private var automaticallyCheckForUpdates: some View { Toggle( "Automatically check for updates", - isOn: updatesManager.bindings.automaticallyChecksForUpdates + isOn: $updatesManager.automaticallyChecksForUpdates ) } @@ -119,7 +126,7 @@ struct AboutSettingsPane: View { private var automaticallyDownloadUpdates: some View { Toggle( "Automatically download updates", - isOn: updatesManager.bindings.automaticallyDownloadsUpdates + isOn: $updatesManager.automaticallyDownloadsUpdates ) } @@ -136,7 +143,7 @@ struct AboutSettingsPane: View { } @ViewBuilder - private var bottomBar: some View { + private func bottomBar(containerShape: some InsettableShape) -> some View { HStack { Button("Quit Ice") { NSApp.terminate(nil) @@ -157,7 +164,8 @@ struct AboutSettingsPane: View { } .padding(8) .buttonStyle(BottomBarButtonStyle()) - .background(.quinary, in: Capsule(style: .circular)) + .background(.quinary, in: containerShape) + .containerShape(containerShape) .frame(height: 40) } } @@ -166,7 +174,7 @@ private struct BottomBarButtonStyle: ButtonStyle { @State private var isHovering = false private var borderShape: some InsettableShape { - Capsule(style: .circular) + ContainerRelativeShape() } func makeBody(configuration: Configuration) -> some View { diff --git a/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift b/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift index 07fcf4710..8aef63fba 100644 --- a/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift @@ -77,8 +77,8 @@ struct AdvancedSettingsPane: View { Text( """ Make more room in the menu bar by hiding the current app menus if \ - needed. macOS requires Ice to be visible in the Dock while this setting \ - is in effect. + needed. macOS requires Ice to make itself visible in the Dock while \ + this setting is in effect. """ ) .padding(.trailing, 75) @@ -95,8 +95,8 @@ struct AdvancedSettingsPane: View { Text( """ Right-click in an empty area of the menu bar to display a minimal \ - version of Ice's menu. Disable this if you encounter conflicts with \ - other apps. + version of Ice's menu. Disable this setting if you encounter conflicts \ + with other apps. """ ) .padding(.trailing, 75) diff --git a/Ice/Settings/SettingsPanes/MenuBarAppearanceSettingsPane.swift b/Ice/Settings/SettingsPanes/MenuBarAppearanceSettingsPane.swift index 657885d33..8bce92686 100644 --- a/Ice/Settings/SettingsPanes/MenuBarAppearanceSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/MenuBarAppearanceSettingsPane.swift @@ -6,10 +6,9 @@ import SwiftUI struct MenuBarAppearanceSettingsPane: View { - @EnvironmentObject var appState: AppState + @ObservedObject var appearanceManager: MenuBarAppearanceManager var body: some View { - MenuBarAppearanceEditor(location: .settings) - .environmentObject(appState.appearanceManager) + MenuBarAppearanceEditor(appearanceManager: appearanceManager, location: .settings) } } diff --git a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift index 5a3debb91..13af54cab 100644 --- a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift @@ -7,7 +7,7 @@ import SwiftUI struct MenuBarLayoutSettingsPane: View { @EnvironmentObject var appState: AppState - @EnvironmentObject var itemManager: MenuBarItemManager + @ObservedObject var itemManager: MenuBarItemManager private var hasItems: Bool { !itemManager.itemCache.managedItems.isEmpty @@ -39,7 +39,7 @@ struct MenuBarLayoutSettingsPane: View { @ViewBuilder private var layoutBars: some View { - VStack(spacing: 25) { + VStack(spacing: 15) { ForEach(MenuBarSection.Name.allCases, id: \.self) { section in layoutBar(for: section) } @@ -86,13 +86,10 @@ struct MenuBarLayoutSettingsPane: View { let section = appState.menuBarManager.section(withName: name), section.isEnabled { - VStack(alignment: .leading, spacing: 4) { - Text("\(name.displayString) Section") - .font(.system(size: 14)) - .padding(.leading, 2) - - LayoutBar(section: name) - .environmentObject(appState.imageCache) + VStack(alignment: .leading) { + Text(name.localized) + .font(.title3) + LayoutBar(imageCache: appState.imageCache, section: name) } } } diff --git a/Ice/Settings/SettingsView.swift b/Ice/Settings/SettingsView.swift index 8679a7440..9bc972527 100644 --- a/Ice/Settings/SettingsView.swift +++ b/Ice/Settings/SettingsView.swift @@ -7,10 +7,11 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var appState: AppState - @EnvironmentObject var navigationState: AppNavigationState + @ObservedObject var navigationState: AppNavigationState @Environment(\.appearsActive) private var appearsActive @Environment(\.colorScheme) private var colorScheme @Environment(\.sidebarRowSize) private var sidebarRowSize + @State private var usesHardScrollEdgeEffect = false private let sidebarPadding: CGFloat = 3 @@ -75,11 +76,22 @@ struct SettingsView: View { .navigationTitle(navigationTitle) } + @ToolbarContentBuilder + private var sidebarToolbarSpacer: some ToolbarContent { + if #available(macOS 26.0, *) { + ToolbarSpacer(.flexible) + } else { + ToolbarItem { + Spacer(minLength: 0) + } + } + } + @ViewBuilder private var sidebar: some View { List(selection: $navigationState.settingsNavigationIdentifier) { Section { - ForEach(SettingsNavigationIdentifier.allCases, id: \.self) { identifier in + ForEach(SettingsNavigationIdentifier.allCases) { identifier in sidebarItem(for: identifier) } } header: { @@ -92,7 +104,10 @@ struct SettingsView: View { .collapsible(false) } .scrollDisabled(true) - .removeSidebarToggle() + .toolbar(removing: .sidebarToggle) + .toolbar { + sidebarToolbarSpacer + } .navigationSplitViewColumnWidth(sidebarWidth) } @@ -100,7 +115,12 @@ struct SettingsView: View { private var detailView: some View { if #available(macOS 26.0, *) { settingsPane - .scrollEdgeEffectStyle(.hard, for: .top) + .onScrollGeometryChange(for: Bool.self) { geometry in + geometry.visibleRect.minY > -geometry.contentInsets.top + } action: { _, isScrolledPastTop in + usesHardScrollEdgeEffect = isScrolledPastTop + } + .scrollEdgeEffectStyle(usesHardScrollEdgeEffect ? .hard : .soft, for: .top) } else { settingsPane } @@ -112,16 +132,15 @@ struct SettingsView: View { case .general: GeneralSettingsPane(settings: appState.settings.general) case .menuBarLayout: - MenuBarLayoutSettingsPane() - .environmentObject(appState.itemManager) + MenuBarLayoutSettingsPane(itemManager: appState.itemManager) case .menuBarAppearance: - MenuBarAppearanceSettingsPane() + MenuBarAppearanceSettingsPane(appearanceManager: appState.appearanceManager) case .hotkeys: HotkeysSettingsPane(settings: appState.settings.hotkeys) case .advanced: AdvancedSettingsPane(settings: appState.settings.advanced) case .about: - AboutSettingsPane() + AboutSettingsPane(updatesManager: appState.updatesManager) } } @@ -137,5 +156,6 @@ struct SettingsView: View { .padding(sidebarPadding) } .frame(height: sidebarItemHeight) + .tag(identifier) } } diff --git a/Ice/Settings/SettingsWindow.swift b/Ice/Settings/SettingsWindow.swift index 7a34661bf..0c5b283ff 100644 --- a/Ice/Settings/SettingsWindow.swift +++ b/Ice/Settings/SettingsWindow.swift @@ -24,16 +24,15 @@ struct SettingsWindow: Scene { .windowResizability(.contentSize) .defaultSize(width: 900, height: 625) .environmentObject(appState) - .environmentObject(appState.navigationState) } @ViewBuilder private var settingsView: some View { if #available(macOS 26.0, *) { - SettingsView() + SettingsView(navigationState: appState.navigationState) .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) } else { - SettingsView() + SettingsView(navigationState: appState.navigationState) } } } diff --git a/Ice/UI/IceUI/IceForm.swift b/Ice/UI/IceUI/IceForm.swift index a3486a046..b350b66a0 100644 --- a/Ice/UI/IceUI/IceForm.swift +++ b/Ice/UI/IceUI/IceForm.swift @@ -6,7 +6,6 @@ import SwiftUI struct IceForm: View { - @Environment(\.isScrollEnabled) private var isScrollEnabled @State private var contentFrame = CGRect.zero private let alignment: HorizontalAlignment @@ -42,24 +41,20 @@ struct IceForm: View { } var body: some View { - contentScrollView - .focusSection() - .accessibilityElement(children: .contain) - } - - @ViewBuilder - private var contentScrollView: some View { - if isScrollEnabled { - GeometryReader { geometry in - ScrollView { - contentLayout - } - .scrollContentBackground(.hidden) - .scrollDisabled(contentFrame.height <= geometry.size.height) + GeometryReader { geometry in + ScrollView { + contentLayout.frame( + maxWidth: geometry.size.width, + minHeight: geometry.size.height, + alignment: .top + ) } - } else { - contentLayout + .scrollContentBackground(.hidden) + .scrollIndicatorsFlash(onAppear: true) + .scrollDisabled(contentFrame.height > 0 && contentFrame.height <= geometry.size.height) } + .focusSection() + .accessibilityElement(children: .contain) } @ViewBuilder @@ -74,8 +69,6 @@ struct IceForm: View { } } -// MARK: - IceFormLabeledContentStyle - private struct IceFormLabeledContentStyle: LabeledContentStyle { func makeBody(configuration: Configuration) -> some View { LabeledContent { @@ -89,8 +82,6 @@ private struct IceFormLabeledContentStyle: LabeledContentStyle { } } -// MARK: - IceFormToggleStyle - private struct IceFormToggleStyle: ToggleStyle { func makeBody(configuration: Configuration) -> some View { Toggle(configuration) diff --git a/Ice/UI/ViewModifiers/ErasedToAnyView.swift b/Ice/UI/Modifiers/ErasedToAnyView.swift similarity index 100% rename from Ice/UI/ViewModifiers/ErasedToAnyView.swift rename to Ice/UI/Modifiers/ErasedToAnyView.swift diff --git a/Ice/UI/ViewModifiers/LocalEventMonitorModifier.swift b/Ice/UI/Modifiers/LocalEventMonitorModifier.swift similarity index 100% rename from Ice/UI/ViewModifiers/LocalEventMonitorModifier.swift rename to Ice/UI/Modifiers/LocalEventMonitorModifier.swift diff --git a/Ice/UI/ViewModifiers/OnFrameChange.swift b/Ice/UI/Modifiers/OnFrameChange.swift similarity index 51% rename from Ice/UI/ViewModifiers/OnFrameChange.swift rename to Ice/UI/Modifiers/OnFrameChange.swift index 1a1924f1a..527a39b00 100644 --- a/Ice/UI/ViewModifiers/OnFrameChange.swift +++ b/Ice/UI/Modifiers/OnFrameChange.swift @@ -14,42 +14,37 @@ private struct FramePreferenceKey: PreferenceKey { } extension View { - /// Adds an action to perform when the view's frame changes. + /// Performs the given action when the view's frame changes. /// /// - Parameters: - /// - coordinateSpace: The coordinate space to use as a reference - /// when accessing the view's frame. - /// - action: The action to perform when the view's frame changes. - /// The `action` closure passes the new frame as its parameter. - /// - /// - Returns: A view that triggers `action` when its frame changes. + /// - coordinateSpace: The coordinate space to use when accessing + /// the view's frame. + /// - action: An action to perform when the view's frame changes. + /// The closure takes the new frame as a parameter. func onFrameChange( - in coordinateSpace: CoordinateSpace = .local, + in coordinateSpace: some CoordinateSpaceProtocol = .local, perform action: @escaping (CGRect) -> Void ) -> some View { background { - GeometryReader { proxy in + GeometryReader { geometry in Color.clear .preference( key: FramePreferenceKey.self, - value: proxy.frame(in: coordinateSpace) + value: geometry.frame(in: coordinateSpace) ) .onPreferenceChange(FramePreferenceKey.self, perform: action) } } } - /// Returns a version of this view that updates the given binding - /// when its frame changes. + /// Updates the given binding when the view's frame changes. /// /// - Parameters: - /// - coordinateSpace: The coordinate space to use as a reference - /// when accessing the view's frame. + /// - coordinateSpace: The coordinate space to use when accessing + /// the view's frame. /// - binding: A binding to update when the view's frame changes. - /// - /// - Returns: A view that updates `binding` when its frame changes. func onFrameChange( - in coordinateSpace: CoordinateSpace = .local, + in coordinateSpace: some CoordinateSpaceProtocol = .local, update binding: Binding ) -> some View { onFrameChange(in: coordinateSpace) { frame in diff --git a/Ice/UI/ViewModifiers/OnKeyDown.swift b/Ice/UI/Modifiers/OnKeyDown.swift similarity index 100% rename from Ice/UI/ViewModifiers/OnKeyDown.swift rename to Ice/UI/Modifiers/OnKeyDown.swift diff --git a/Ice/UI/ViewModifiers/OnWindowChange.swift b/Ice/UI/Modifiers/OnWindowChange.swift similarity index 100% rename from Ice/UI/ViewModifiers/OnWindowChange.swift rename to Ice/UI/Modifiers/OnWindowChange.swift diff --git a/Ice/UI/ViewModifiers/Once.swift b/Ice/UI/Modifiers/Once.swift similarity index 100% rename from Ice/UI/ViewModifiers/Once.swift rename to Ice/UI/Modifiers/Once.swift diff --git a/Ice/UI/ViewModifiers/RemoveSidebarToggle.swift b/Ice/UI/ViewModifiers/RemoveSidebarToggle.swift deleted file mode 100644 index 59fc29303..000000000 --- a/Ice/UI/ViewModifiers/RemoveSidebarToggle.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// RemoveSidebarToggle.swift -// Ice -// - -import SwiftUI - -extension View { - /// Removes the sidebar toggle button from the toolbar. - func removeSidebarToggle() -> some View { - toolbar(removing: .sidebarToggle) - .toolbar { - Color.clear - } - } -} diff --git a/Ice/UI/Views/SectionedList.swift b/Ice/UI/Views/SectionedList.swift index c66d21107..641ba6f90 100644 --- a/Ice/UI/Views/SectionedList.swift +++ b/Ice/UI/Views/SectionedList.swift @@ -14,11 +14,8 @@ struct SectionedList: View { } @Binding var selection: ItemID? - @Binding var items: [SectionedListItem] - @State private var itemFrames = [ItemID: CGRect]() - @State private var scrollIndicatorsFlashTrigger = 0 let spacing: CGFloat @@ -201,28 +198,38 @@ private struct SectionedListItemView: View { } } - private var backgroundShape: some InsettableShape { - if #available(macOS 26.0, *) { + private var borderShape: some InsettableShape { + if !item.isSelectable { + RoundedRectangle(cornerRadius: 0, style: .circular) + } else if #available(macOS 26.0, *) { RoundedRectangle(cornerRadius: 10, style: .continuous) } else { RoundedRectangle(cornerRadius: 5, style: .circular) } } + private var borderOpacity: CGFloat { + guard item.isSelectable else { + return 0 + } + if selection == item.id { + return 0.5 + } + if isHovering { + return 0.25 + } + return 0 + } + var body: some View { ZStack { - if item.isSelectable { - if selection == item.id { - itemBackground.opacity(0.5) - } else if isHovering { - itemBackground.opacity(0.25) - } - } + borderShape + .fill(.tint.opacity(borderOpacity)) item.content .foregroundStyle(foregroundStyle) } .frame(minWidth: 22, minHeight: 22) - .contentShape(Rectangle()) + .contentShape([.focusEffect, .interaction], borderShape) .onHover { hovering in isHovering = hovering } @@ -238,15 +245,4 @@ private struct SectionedListItemView: View { itemFrames[item.id] = frame } } - - @ViewBuilder - private var itemBackground: some View { - if #available(macOS 26.0, *) { - backgroundShape - .fill(.tint) - } else { - VisualEffectView(material: .selection, blendingMode: .withinWindow) - .clipShape(backgroundShape) - } - } } diff --git a/Ice/Utilities/BindingExposable.swift b/Ice/Utilities/BindingExposable.swift deleted file mode 100644 index f27a738dc..000000000 --- a/Ice/Utilities/BindingExposable.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// BindingExposable.swift -// Ice -// - -import SwiftUI - -/// A type that exposes its writable properties as bindings. -@MainActor -protocol BindingExposable { - /// A lens that exposes bindings to the writable properties of this type. - typealias Bindings = ExposedBindings - - /// A lens that exposes bindings to the writable properties of this instance. - var bindings: Bindings { get } -} - -extension BindingExposable { - var bindings: Bindings { - Bindings(base: self) - } -} - -/// A lens that exposes bindings to the writable properties of a base object. -@MainActor -@dynamicMemberLookup -struct ExposedBindings { - /// The object whose bindings are exposed. - private let base: Base - - /// Creates a lens that exposes the bindings of the given object. - init(base: Base) { - self.base = base - } - - /// Returns a binding to the property at the given key path. - subscript(dynamicMember keyPath: ReferenceWritableKeyPath) -> Binding { - Binding(get: { base[keyPath: keyPath] }, set: { base[keyPath: keyPath] = $0 }) - } - - /// Returns a lens that exposes the bindings of the object at the given key path. - subscript(dynamicMember keyPath: KeyPath) -> ExposedBindings { - ExposedBindings(base: base[keyPath: keyPath]) - } -} From 998474cfe32c3c349661efb196d5f9f029715062 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Sat, 16 Aug 2025 12:41:58 -0600 Subject: [PATCH 45/80] Changes to MenuBarItemService --- .../MenuBarItemServiceConnection.swift | 6 +- MenuBarItemService/Listener.swift | 40 +++--- MenuBarItemService/Service.swift | 15 --- MenuBarItemService/SourcePIDCache.swift | 123 ++++++++---------- MenuBarItemService/main.swift | 10 ++ Shared/Utilities/WindowInfo.swift | 23 +++- 6 files changed, 101 insertions(+), 116 deletions(-) delete mode 100644 MenuBarItemService/Service.swift create mode 100644 MenuBarItemService/main.swift diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift index 0f73e4d9e..8102a7b1a 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift @@ -10,7 +10,7 @@ import OSLog @available(macOS 26.0, *) extension MenuBarItemService { - /// A connection to the `MenuBarItemService` XPC process. + /// A connection to the `MenuBarItemService` XPC service. final class Connection: Sendable { /// The shared connection. static let shared = Connection() @@ -153,9 +153,7 @@ extension MenuBarItemService { /// Sends the given request to the service and returns the response. func send(request: Request) -> Response? { - storage.withLock { storage in - storage.send(request: request) - } + storage.withLock { $0.send(request: request) } } } } diff --git a/MenuBarItemService/Listener.swift b/MenuBarItemService/Listener.swift index b15bf7afe..8dc6a9601 100644 --- a/MenuBarItemService/Listener.swift +++ b/MenuBarItemService/Listener.swift @@ -6,49 +6,38 @@ import OSLog import XPC -/// A wrapper around an xpc listener object. +/// A wrapper around an XPC listener object. final class Listener { - /// An error that can be thrown during listener activation. - enum ActivationError: Error, CustomStringConvertible { - case alreadyActive - case failure(any Error) - - var description: String { - switch self { - case .alreadyActive: - "Listener is already active" - case .failure(let error): - "Listener activation failed with error \(error)" - } - } - } - /// The shared listener. static let shared = Listener() /// The service name. private let name = MenuBarItemService.name - /// The underlying xpc listener object. + /// The underlying XPC listener object. private var listener: XPCListener? /// Creates the shared listener. private init() { } + deinit { + cancel() + } + /// Handles a received message. private func handleMessage(_ message: XPCReceivedMessage) -> MenuBarItemService.Response? { do { let request = try message.decode(as: MenuBarItemService.Request.self) switch request { case .start: - SourcePIDCache.shared.start() + Logger.general.debug("Listener received start request") return .start case .sourcePID(let window): let pid = SourcePIDCache.shared.pid(for: window) return .sourcePID(pid) } } catch { - Logger.general.error("Service failed with error \(error)") + Logger.general.error("Listener failed to handle message with error \(error)") return nil } } @@ -75,12 +64,14 @@ final class Listener { } /// Activates the listener. - /// - /// - Note: This method throws an error if called on an active listener. - func activate() throws { + func activate() { guard listener == nil else { - throw ActivationError.alreadyActive + Logger.general.notice("Listener is already active") + return } + + Logger.general.debug("Activating listener") + do { if #available(macOS 26.0, *) { try uncheckedActivateWithSameTeamRequirement() @@ -88,12 +79,13 @@ final class Listener { try uncheckedActivate() } } catch { - throw ActivationError.failure(error) + Logger.general.error("Failed to activate listener with error \(error)") } } /// Cancels the listener. func cancel() { + Logger.general.debug("Canceling listener") listener.take()?.cancel() } } diff --git a/MenuBarItemService/Service.swift b/MenuBarItemService/Service.swift deleted file mode 100644 index bd31958aa..000000000 --- a/MenuBarItemService/Service.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// Service.swift -// MenuBarItemService -// - -import Foundation - -@main -enum Service { - static func main() throws { - Bridging.setProcessUnresponsiveTimeout(3) - try Listener.shared.activate() - RunLoop.current.run() - } -} diff --git a/MenuBarItemService/SourcePIDCache.swift b/MenuBarItemService/SourcePIDCache.swift index 0335eba37..e1d7b7948 100644 --- a/MenuBarItemService/SourcePIDCache.swift +++ b/MenuBarItemService/SourcePIDCache.swift @@ -6,22 +6,21 @@ import AXSwift import Cocoa import Combine -import os.lock +import os /// A cache for the source process identifiers for menu bar item windows. /// /// We use the term "source process" to refer to the process that created -/// a menu bar item. We used to be able to use the window's `ownerPID` to -/// determine this information, but in macOS 26 Tahoe, all item windows -/// are owned by Control Center. We need to be able to accurately identify -/// each item, and the source process is a good way to do that. Knowing -/// the source process also gives us an accurate name to show in various -/// places throughout the interface. +/// a given menu bar item. Originally, we could use the CGWindowList API, +/// as the item window's `kCGWindowOwnerPID` was always equivalent to the +/// source process identifier. However, as of macOS 26, all item windows +/// are owned by the Control Center. /// -/// We can find what we need using the Accessibility API, but it's quite -/// an intensive process. Since Accessibility blocks the main thread, the -/// cache lives in a separate XPC process, which the main process queries -/// asynchronously. +/// We can still what we need using the Accessibility API, but doing it +/// efficiently ends up being fairly complex. It doesn't help that calls +/// to Accessibility are thread blocking. We resolve this by doing most +/// of the heavy lifting in a dedicated XPC service, which we then call +/// asynchronously from the main app. final class SourcePIDCache { /// An object that contains a running application and provides an /// interface to access relevant information, such as its process @@ -93,16 +92,16 @@ final class SourcePIDCache { var cachedBounds = window.bounds for n in 1...5 { - guard let latestBounds = window.getLatestBounds() else { + guard let currentBounds = window.currentBounds() else { // Failure here means the window probably doesn't // exist anymore. return nil } - if latestBounds == cachedBounds { - return latestBounds + if currentBounds == cachedBounds { + return currentBounds } - cachedBounds = latestBounds - // Sleep interval increases with each attempt. + cachedBounds = currentBounds + // Compute the sleep interval from the current attempt. Thread.sleep(forTimeInterval: TimeInterval(n) / 100) } @@ -164,58 +163,57 @@ final class SourcePIDCache { /// The cache's protected state. private let state = OSAllocatedUnfairLock(initialState: State()) - /// Storage for the cache's observers. - private var cancellables = Set() + /// Observer for running applications. + private lazy var cancellable = NSWorkspace.shared.publisher(for: \.runningApplications).sink { [weak self] runningApps in + guard let self else { + return + } - /// Creates the shared cache. - private init() { } + Logger.general.debug("Received new running applications") - /// Starts the observers for the cache. - func start() { - var c = Set() + let windowIDs = Bridging.getMenuBarWindowList(option: .itemsOnly) - NSWorkspace.shared.publisher(for: \.runningApplications) - .sink { [weak self] runningApps in - guard let self else { - return + state.withLock { state in + // Convert the cached state to dictionaries keyed by pid to + // allow for efficient repeated access. + let appMappings = state.apps.reduce(into: [:]) { result, app in + result[app.processIdentifier] = app + } + let pidMappings: [pid_t: [CGWindowID: pid_t]] = windowIDs.reduce(into: [:]) { result, windowID in + if let pid = state.pids[windowID] { + result[pid, default: [:]][windowID] = pid } + } - let windowIDs = Bridging.getMenuBarWindowList(option: .itemsOnly) + // Create a new state that matches the current running apps. + state = runningApps.reduce(into: State()) { result, app in + let pid = app.processIdentifier - state.withLock { state in - // Convert the cached state to dictionaries keyed by pid to - // allow for efficient repeated access. - let appMappings = state.apps.reduce(into: [:]) { result, app in - result[app.processIdentifier] = app - } - let pidMappings: [pid_t: [CGWindowID: pid_t]] = windowIDs.reduce(into: [:]) { result, windowID in - if let pid = state.pids[windowID] { - result[pid, default: [:]][windowID] = pid - } - } + if let app = appMappings[pid] { + // Prefer the cached app, as it may have already done + // the work to initialize its extras menu bar. + result.apps.append(app) + } else { + // App wasn't in the cache, so it must be new. + result.apps.append(CachedApplication(app)) + } - // Create a new state that matches the current running apps. - state = runningApps.reduce(into: State()) { result, app in - let pid = app.processIdentifier - - if let app = appMappings[pid] { - // Prefer the cached app, as it may have already done - // the work to initialize its extras menu bar. - result.apps.append(app) - } else { - // App wasn't in the cache, so it must be new. - result.apps.append(CachedApplication(app)) - } - - if let pids = pidMappings[pid] { - result.pids.merge(pids) { (_, new) in new } - } - } + if let pids = pidMappings[pid] { + result.pids.merge(pids) { (_, new) in new } } } - .store(in: &c) + } + } + + /// Creates the shared cache. + private init() { + Bridging.setProcessUnresponsiveTimeout(3) + } - cancellables = c + /// Starts the observers for the cache. + func start() { + Logger.general.debug("Starting observers for source PID cache") + _ = cancellable } /// Returns the cached process identifier for the given window, @@ -230,12 +228,3 @@ final class SourcePIDCache { } } } - -// MARK: - WindowInfo Extension - -private extension WindowInfo { - /// Returns the latest bounds of the window. - func getLatestBounds() -> CGRect? { - Bridging.getWindowBounds(for: windowID) - } -} diff --git a/MenuBarItemService/main.swift b/MenuBarItemService/main.swift new file mode 100644 index 000000000..61a92f6aa --- /dev/null +++ b/MenuBarItemService/main.swift @@ -0,0 +1,10 @@ +// +// main.swift +// MenuBarItemService +// + +import Foundation + +SourcePIDCache.shared.start() +Listener.shared.activate() +RunLoop.current.run() diff --git a/Shared/Utilities/WindowInfo.swift b/Shared/Utilities/WindowInfo.swift index 6f11456c7..9b68fa333 100644 --- a/Shared/Utilities/WindowInfo.swift +++ b/Shared/Utilities/WindowInfo.swift @@ -73,8 +73,15 @@ struct WindowInfo { self = window } - // MARK: Create Windows + /// Returns the current bounds of the window. + func currentBounds() -> CGRect? { + Bridging.getWindowBounds(for: windowID) + } +} +// MARK: - Window List + +extension WindowInfo { /// Creates a list of windows from the given list of window identifiers. /// /// - Parameter windowIDs: A list of window identifiers. @@ -104,17 +111,20 @@ struct WindowInfo { static func createMenuBarWindows(option: Bridging.MenuBarWindowListOption = []) -> [WindowInfo] { createWindows(from: Bridging.getMenuBarWindowList(option: option)) } +} - // MARK: Wallpaper Window +// MARK: - Specific Windows +extension WindowInfo { /// Returns the wallpaper window for the given display from the /// given list of windows. static func wallpaperWindow(from windows: [WindowInfo], for display: CGDirectDisplayID) -> WindowInfo? { - windows.first { window in + let displayBounds = CGDisplayBounds(display) + return windows.first { window in // Wallpaper window belongs to the Dock process. window.owningApplication?.bundleIdentifier == "com.apple.dock" && window.title?.hasPrefix("Wallpaper") == true && - CGDisplayBounds(display).contains(window.bounds) + displayBounds.contains(window.bounds) } } @@ -128,13 +138,14 @@ struct WindowInfo { /// Returns the menu bar window for the given display from the /// given list of windows. static func menuBarWindow(from windows: [WindowInfo], for display: CGDirectDisplayID) -> WindowInfo? { - windows.first { window in + let displayBounds = CGDisplayBounds(display) + return windows.first { window in // Menu bar window belongs to the WindowServer process. window.isWindowServerWindow && window.isOnScreen && window.layer == kCGMainMenuWindowLevel && window.title == "Menubar" && - CGDisplayBounds(display).contains(window.bounds) + displayBounds.contains(window.bounds) } } From 927cbe0d6c75e5fd976f2be4458cc0abbd35aae1 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Sat, 16 Aug 2025 12:45:54 -0600 Subject: [PATCH 46/80] Update project files --- Ice.xcodeproj/project.pbxproj | 8 ++------ MenuBarItemService/Resources/Info.plist | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/Ice.xcodeproj/project.pbxproj b/Ice.xcodeproj/project.pbxproj index 66f518698..23b19a59d 100644 --- a/Ice.xcodeproj/project.pbxproj +++ b/Ice.xcodeproj/project.pbxproj @@ -333,6 +333,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Jordan Baird"; MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -392,6 +393,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Jordan Baird"; MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -423,7 +425,6 @@ INFOPLIST_FILE = Ice/Resources/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSUIElement = YES; - INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Jordan Baird"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -457,7 +458,6 @@ INFOPLIST_FILE = Ice/Resources/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSUIElement = YES; - INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Jordan Baird"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -481,8 +481,6 @@ GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = MenuBarItemService/Resources/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "MenuBarItemService (Ice)"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.jordanbaird.Ice.MenuBarItemService; @@ -509,8 +507,6 @@ GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = MenuBarItemService/Resources/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "MenuBarItemService (Ice)"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.jordanbaird.Ice.MenuBarItemService; diff --git a/MenuBarItemService/Resources/Info.plist b/MenuBarItemService/Resources/Info.plist index 2ab43d9c5..b5c5e1140 100644 --- a/MenuBarItemService/Resources/Info.plist +++ b/MenuBarItemService/Resources/Info.plist @@ -4,12 +4,12 @@ XPCService - ServiceType - Application JoinExistingSession RunLoopType NSRunLoop + ServiceType + Application From 2f0223159e5b8f33e0b17b304392d785fde6c957 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Sun, 17 Aug 2025 14:56:20 -0600 Subject: [PATCH 47/80] Event, caching, and concurrency reworks --- .../MenuBarItems/MenuBarItemManager.swift | 279 ++++++++---------- Ice/Utilities/ConcurrencyHelpers.swift | 106 ------- 2 files changed, 121 insertions(+), 264 deletions(-) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index 7c618fa9c..65febd10d 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -26,9 +26,6 @@ final class MenuBarItemManager: ObservableObject { /// The manager's menu bar item cache. @Published private(set) var itemCache = ItemCache(displayID: nil) - /// Logger for the menu bar item manager. - private let logger = Logger(category: "MenuBarItemManager") - /// Serial queue for posting events directly to menu bar items. private let scrombleQueue = DispatchQueue.targetingGlobal( label: "MenuBarItemManager.scrombleQueue", @@ -59,6 +56,9 @@ final class MenuBarItemManager: ObservableObject { /// The shared app state. private(set) weak var appState: AppState? + /// Logger for the menu bar item manager. + private nonisolated var logger: Logger { .menuBarItemManager } + /// Sets up the manager. func performSetup(with appState: AppState) async { self.appState = appState @@ -224,6 +224,7 @@ extension MenuBarItemManager { var cache: ItemCache var tempShownItems = [(MenuBarItem, MoveDestination)]() + var shouldClearCachedItemWindowIDs = false private(set) lazy var hiddenControlItemBounds = bestBounds(for: controlItems.hidden) private(set) lazy var alwaysHiddenControlItemBounds = controlItems.alwaysHidden.map(bestBounds) @@ -242,23 +243,25 @@ extension MenuBarItemManager { item.canBeHidden && (!item.isControlItem || item.tag == .visibleControlItem) } - mutating func isItemInSection(_ item: MenuBarItem, _ section: MenuBarSection.Name) -> Bool { + mutating func findSection(for item: MenuBarItem) -> MenuBarSection.Name? { lazy var itemBounds = bestBounds(for: item) - switch section { - case .visible: - return itemBounds.minX >= hiddenControlItemBounds.maxX - case .hidden: - if let alwaysHiddenControlItemBounds { - return itemBounds.maxX <= hiddenControlItemBounds.minX && - itemBounds.minX >= alwaysHiddenControlItemBounds.maxX - } else { - return itemBounds.maxX <= hiddenControlItemBounds.minX - } - case .alwaysHidden: - if let alwaysHiddenControlItemBounds { - return itemBounds.maxX <= alwaysHiddenControlItemBounds.minX - } else { - return false + return MenuBarSection.Name.allCases.first { section in + switch section { + case .visible: + return itemBounds.minX >= hiddenControlItemBounds.maxX + case .hidden: + if let alwaysHiddenControlItemBounds { + return itemBounds.maxX <= hiddenControlItemBounds.minX && + itemBounds.minX >= alwaysHiddenControlItemBounds.maxX + } else { + return itemBounds.maxX <= hiddenControlItemBounds.minX + } + case .alwaysHidden: + if let alwaysHiddenControlItemBounds { + return itemBounds.maxX <= alwaysHiddenControlItemBounds.minX + } else { + return false + } } } } @@ -269,7 +272,12 @@ extension MenuBarItemManager { private func uncheckedCacheItems(items: [MenuBarItem], context: CacheContext) { var context = context - outer: for item in items where context.isValidForCaching(item) { + for item in items where context.isValidForCaching(item) { + if item.sourcePID == nil { + logger.warning("Missing sourcePID for \(item.logString, privacy: .public)") + context.shouldClearCachedItemWindowIDs = true + } + if let temp = tempShownItemContexts.first(where: { $0.tag == item.tag }) { // Cache temporarily shown items as if they were in their original locations. // Keep track of them separately and use their return destinations to insert @@ -278,19 +286,24 @@ extension MenuBarItemManager { continue } - for section in MenuBarSection.Name.allCases where context.isItemInSection(item, section) { + if let section = context.findSection(for: item) { context.cache[section].append(item) - continue outer + continue } - logger.warning("\(item.logString, privacy: .public) was not cached") - cachedItemWindowIDs.removeAll() // Make sure we don't skip the next cache attempt. + logger.warning("Couldn't find section for caching \(item.logString, privacy: .public)") + context.shouldClearCachedItemWindowIDs = true } for (item, destination) in context.tempShownItems { context.cache.insert(item, at: destination) } + if context.shouldClearCachedItemWindowIDs { + logger.info("Clearing cached menu bar item windowIDs") + cachedItemWindowIDs.removeAll() // Make sure we don't skip the next cache attempt. + } + itemCache = context.cache logger.debug("Updated menu bar item cache") } @@ -330,10 +343,7 @@ extension MenuBarItemManager { let itemWindowIDs = Bridging.getMenuBarWindowList(option: [.itemsOnly, .activeSpace]) - if - cachedItemWindowIDs == itemWindowIDs, - itemCache.managedItems.allSatisfy({ $0.sourcePID != nil }) - { + guard cachedItemWindowIDs != itemWindowIDs else { return } @@ -420,7 +430,7 @@ extension MenuBarItemManager { try await performWaitOperation(timeout: timeout) { var cancellable: AnyCancellable? - try await withCancellingContinuation { continuation in + await withCheckedContinuation { continuation in let mask: NSEvent.EventTypeMask = [.leftMouseUp, .rightMouseUp, .otherMouseUp] cancellable = RunLoopLocalEventMonitor.publisher(for: mask, mode: .eventTracking) .merge(with: EventMonitor.publish(events: mask, scope: .universal)) @@ -433,9 +443,6 @@ extension MenuBarItemManager { cancellable?.cancel() continuation.resume() } - } onCancel: { continuation in - cancellable?.cancel() - continuation.cancel() } } } @@ -450,7 +457,7 @@ extension MenuBarItemManager { try await performWaitOperation(timeout: timeout) { var cancellable: AnyCancellable? - try await withCancellingContinuation { continuation in + await withCheckedContinuation { continuation in let mask: NSEvent.EventTypeMask = .flagsChanged cancellable = RunLoopLocalEventMonitor.publisher(for: mask, mode: .eventTracking) .merge(with: EventMonitor.publish(events: mask, scope: .universal)) @@ -463,9 +470,6 @@ extension MenuBarItemManager { cancellable?.cancel() continuation.resume() } - } onCancel: { continuation in - cancellable?.cancel() - continuation.cancel() } } } @@ -633,39 +637,6 @@ extension MenuBarItemManager { source.localEventsSuppressionInterval = suppressionInterval } - /// Returns a Boolean value that indicates whether the given events have the - /// same values for each integer value field. - /// - /// - Parameters: - /// - events: The events to compare. - /// - integerFields: An array of integer value fields to compare on each event. - private nonisolated func eventsMatch(_ events: [CGEvent], by integerFields: [CGEventField]) -> Bool { - var fieldValues = Set<[Int64]>() - for event in events { - let values = integerFields.map(event.getIntegerValueField) - fieldValues.insert(values) - if fieldValues.count != 1 { - return false - } - } - return true - } - - /// Posts an event to the given event tap location. - /// - /// - Parameters: - /// - event: The event to post. - /// - location: The event tap location to post the event to. - private nonisolated func postEvent(_ event: CGEvent, to location: EventTap.Location) { - logger.debug("Posting \(event.type.logString, privacy: .public) to \(location.logString, privacy: .public)") - switch location { - case .hidEventTap: event.post(tap: .cghidEventTap) - case .sessionEventTap: event.post(tap: .cgSessionEventTap) - case .annotatedSessionEventTap: event.post(tap: .cgAnnotatedSessionEventTap) - case .pid(let pid): event.postToPid(pid) - } - } - /// Posts an event to the given event tap location and waits /// until it is received before returning. /// @@ -680,33 +651,28 @@ extension MenuBarItemManager { item: MenuBarItem, timeout: Duration ) async throws { - let timeoutTask = Task(timeout: timeout) { [weak self] in - guard let self else { - throw EventError(code: .couldNotComplete, item: item) - } - + let timeoutTask = Task(timeout: timeout) { var eventTap: EventTap? + defer { + eventTap?.disable() + } + await withCheckedContinuation { continuation in eventTap = EventTap( options: .listenOnly, location: location, placement: .tailAppendEventTap, type: event.type, - callbackQueue: self.scrombleQueue - ) { tap, rEvent in - guard self.eventsMatch([rEvent, event], by: CGEventField.menuBarItemEventFields) else { - return rEvent + callbackQueue: scrombleQueue + ) { _, rEvent in + if rEvent.matches(event, by: CGEventField.menuBarItemEventFields) { + continuation.resume() } - - tap.disable() - continuation.resume() - return rEvent } - eventTap?.enable() - self.postEvent(event, to: location) + event.post(to: location) } } do { @@ -733,88 +699,66 @@ extension MenuBarItemManager { item: MenuBarItem, timeout: Duration ) async throws { - guard let nullEvent = CGEvent.uniqueNullEvent() else { + guard + let entryEvent = CGEvent.uniqueNullEvent(), + let exitEvent = CGEvent.uniqueNullEvent() + else { throw EventError(code: .eventCreationFailure, item: item) } - let timeoutTask = Task(timeout: timeout) { [weak self] in - guard let self else { - throw EventError(code: .couldNotComplete, item: item) - } - + let timeoutTask = Task(timeout: timeout) { var eventTap1: EventTap? var eventTap2: EventTap? - var eventTap3: EventTap? + + defer { + eventTap1?.disable() + eventTap2?.disable() + } await withCheckedContinuation { continuation in - // Create an event tap that listens for the null event at the first tap - // location. This tap posts the actual event to the second tap location - // and discards the null event. + // Create a tap for the entry and exit events at the first location. + // This tap is responsible for posting the actual event to the second + // location and resuming the continuation. eventTap1 = EventTap( label: "EventTap 1", options: .defaultTap, location: firstTapLocation, placement: .headInsertEventTap, - type: nullEvent.type, - callbackQueue: self.scrombleQueue - ) { tap, rEvent in - guard self.eventsMatch([rEvent, nullEvent], by: [.eventSourceUserData]) else { - return rEvent + type: .null, + callbackQueue: scrombleQueue + ) { _, rEvent in + if rEvent.matches(entryEvent, by: [.eventSourceUserData]) { + event.post(to: secondTapLocation) + return nil } - - tap.disable() - self.postEvent(event, to: secondTapLocation) - - return nil + if rEvent.matches(exitEvent, by: [.eventSourceUserData]) { + continuation.resume() + return nil + } + return rEvent } - // Create an event tap that listens for the actual event at the second - // tap location. This tap posts the event to the first tap location and - // returns normally. + // Create a tap for the actual event at the second location. This tap + // is responsible for posting the exit event to the first location. eventTap2 = EventTap( label: "EventTap 2", options: .listenOnly, location: secondTapLocation, placement: .tailAppendEventTap, type: event.type, - callbackQueue: self.scrombleQueue - ) { tap, rEvent in - guard self.eventsMatch([rEvent, event], by: CGEventField.menuBarItemEventFields) else { - return rEvent + callbackQueue: scrombleQueue + ) { _, rEvent in + if rEvent.matches(event, by: CGEventField.menuBarItemEventFields) { + exitEvent.post(to: firstTapLocation) } - - tap.disable() - self.postEvent(event, to: firstTapLocation) - return rEvent } - // Create an event tap that listens for the actual event at the first tap - // location. This tap resumes the continuation and discards the event. - eventTap3 = EventTap( - label: "EventTap 3", - options: .defaultTap, - location: firstTapLocation, - placement: .headInsertEventTap, - type: event.type, - callbackQueue: self.scrombleQueue - ) { tap, rEvent in - guard self.eventsMatch([rEvent, event], by: CGEventField.menuBarItemEventFields) else { - return rEvent - } - - tap.disable() - continuation.resume() - - return nil - } - eventTap1?.enable() eventTap2?.enable() - eventTap3?.enable() - // Post the null event to the first tap location to start the event chain. - self.postEvent(nullEvent, to: firstTapLocation) + // Post the entry event to the first location to start the event chain. + entryEvent.post(to: firstTapLocation) } } do { @@ -847,10 +791,10 @@ extension MenuBarItemManager { ) async throws { let duration = getSleepDurationFromScreenRefreshRate(screen: screen) let initialBounds = try getCurrentBounds(for: item) + try await scrombleEvent(event, from: firstTapLocation, to: secondTapLocation, item: item, timeout: timeout) let boundsCheckTask = Task(timeout: timeout) { while true { try Task.checkCancellation() - try await scrombleEvent(event, from: firstTapLocation, to: secondTapLocation, item: item, timeout: timeout) let currentBounds = try getCurrentBounds(for: item) guard currentBounds != initialBounds else { try await Task.sleep(for: duration) @@ -939,7 +883,6 @@ extension MenuBarItemManager { timeout: Duration ) async throws { let itemBounds = try getCurrentBounds(for: item) - let startLocation = CGPoint(x: 20_000, y: 20_000) let endLocation = try getEndLocation(for: destination) let fallbackLocation = CGPoint(x: itemBounds.midX, y: itemBounds.minY) let pid = item.sourcePID ?? item.ownerPID @@ -948,18 +891,11 @@ extension MenuBarItemManager { let moveEvent1 = CGEvent.menuBarItemEvent( source: source, type: .move(.mouseDown), - location: startLocation, - item: item, - pid: pid - ), - let moveEvent2 = CGEvent.menuBarItemEvent( - source: source, - type: .move(.mouseDragged), location: endLocation, item: item, pid: pid ), - let moveEvent3 = CGEvent.menuBarItemEvent( + let moveEvent2 = CGEvent.menuBarItemEvent( source: source, type: .move(.mouseUp), location: endLocation, @@ -984,19 +920,12 @@ extension MenuBarItemManager { moveEvent1, from: .pid(pid), to: .sessionEventTap, - item: item, - timeout: timeout - ) - try await scrombleEvent( - moveEvent2, - from: .pid(pid), - to: .sessionEventTap, untilItemResponds: item, screen: screen, timeout: timeout ) try await scrombleEvent( - moveEvent3, + moveEvent2, from: .pid(pid), to: .sessionEventTap, item: item, @@ -1543,13 +1472,11 @@ extension MenuBarItemManager { private enum MenuBarItemMoveEventMouseState { case mouseDown case mouseUp - case mouseDragged var cgEventType: CGEventType { switch self { case .mouseDown: .leftMouseDown case .mouseUp: .leftMouseUp - case .mouseDragged: .leftMouseDragged } } } @@ -1736,6 +1663,37 @@ private extension CGEvent { return event } + /// Returns a Boolean value that indicates whether the given fields on + /// this event are equivalent to the same fields on the given event. + /// + /// - Parameters: + /// - other: The event to compare with this event. + /// - fields: The fields to check. + func matches(_ other: CGEvent, by fields: [CGEventField]) -> Bool { + fields.allSatisfy { field in + getIntegerValueField(field) == other.getIntegerValueField(field) && + getDoubleValueField(field) == other.getDoubleValueField(field) + } + } + + /// Posts the event to the given event tap location. + /// + /// - Parameter location: The event tap location to post the event to. + func post(to location: EventTap.Location) { + Logger.menuBarItemManager.debug( + """ + Posting \(self.type.logString, privacy: .public) \ + to \(location.logString, privacy: .public) + """ + ) + switch location { + case .hidEventTap: post(tap: .cghidEventTap) + case .sessionEventTap: post(tap: .cgSessionEventTap) + case .annotatedSessionEventTap: post(tap: .cgAnnotatedSessionEventTap) + case .pid(let pid): postToPid(pid) + } + } + private func setFlags(for type: MenuBarItemEventType) { flags = type.cgEventFlags } @@ -1768,3 +1726,8 @@ private extension CGEvent { setIntegerValueField(.mouseEventClickState, value: 1) } } + +private extension Logger { + /// Logger for the menu bar item manager. + static let menuBarItemManager = Logger(category: "MenuBarItemManager") +} diff --git a/Ice/Utilities/ConcurrencyHelpers.swift b/Ice/Utilities/ConcurrencyHelpers.swift index 96142bb62..9d93fb6e7 100644 --- a/Ice/Utilities/ConcurrencyHelpers.swift +++ b/Ice/Utilities/ConcurrencyHelpers.swift @@ -110,109 +110,3 @@ extension Task where Failure == any Error { } } } - -// MARK: - CancellingContinuation - -struct CancellingContinuation: Sendable { - private enum State: @unchecked Sendable { - case initial - case willCancel - case willResume(Result) - case awaiting(CheckedContinuation) - case cancelled - case resumed - - mutating func set(_ continuation: CheckedContinuation, function: String) { - switch self { - case .initial: - self = .awaiting(continuation) - case .willCancel: - continuation.resume(throwing: CancellationError()) - self = .cancelled - case .willResume(let result): - continuation.resume(with: result) - self = .resumed - case .awaiting, .cancelled, .resumed: - fatalError("SWIFT TASK CONTINUATION MISUSE: \(function) tried to await its continuation more than once.") - } - } - - mutating func cancel() { - switch self { - case .initial, .willCancel, .willResume: - self = .willCancel - case .awaiting(let continuation): - continuation.resume(throwing: CancellationError()) - self = .cancelled - case .cancelled, .resumed: - break // Ignore. - } - } - - mutating func resume(result: sending Result, function: String) { - switch self { - case .initial: - self = .willResume(result) - case .willCancel, .cancelled: - break // Ignore. - case .willResume, .resumed: - fatalError("SWIFT TASK CONTINUATION MISUSE: \(function) tried to resume its continuation more than once.") - case .awaiting(let continuation): - continuation.resume(with: result) - self = .resumed - } - } - } - - private let state = OSAllocatedUnfairLock(initialState: State.initial) - private let function: String - - fileprivate init(function: String) { - self.function = function - } - - func resume(with result: sending Result) { - state.withLock { [result] in $0.resume(result: result, function: function) } - } - - func resume(returning value: sending T) { - resume(with: .success(value)) - } - - func resume(throwing error: any Error) { - resume(with: .failure(error)) - } - - func resume() where T == Void { - resume(returning: ()) - } - - func cancel() { - state.withLock { $0.cancel() } - } - - fileprivate func wait( - isolation: isolated (any Actor)? = #isolation, - body: (CancellingContinuation) -> Void, - onCancel: (CancellingContinuation) -> Void - ) async throws -> sending T { - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation(function: function) { continuation in - state.withLock { $0.set(continuation, function: function) } - body(self) - } - } onCancel: { - onCancel(self) - } - } -} - -func withCancellingContinuation( - isolation: isolated (any Actor)? = #isolation, - function: String = #function, - body: (_ continuation: CancellingContinuation) -> Void, - onCancel: (_ continuation: CancellingContinuation) -> Void -) async throws -> sending T { - let continuation = CancellingContinuation(function: function) - return try await continuation.wait(body: body, onCancel: onCancel) -} From e3c63f26df32dd923ff08d587f5e0c00cd15765b Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Sun, 17 Aug 2025 22:36:58 -0600 Subject: [PATCH 48/80] Improve menu bar item event handling --- Ice/Events/EventManager.swift | 4 +- Ice/Events/EventTap.swift | 252 +++++--- .../MenuBarItems/MenuBarItemManager.swift | 567 +++++++++++------- Shared/Bridging/Bridging.swift | 6 +- Shared/Bridging/Shims.swift | 10 + 5 files changed, 510 insertions(+), 329 deletions(-) diff --git a/Ice/Events/EventManager.swift b/Ice/Events/EventManager.swift index 162a71621..a9ffa9d6c 100644 --- a/Ice/Events/EventManager.swift +++ b/Ice/Events/EventManager.swift @@ -60,10 +60,10 @@ final class EventManager: ObservableObject { /// Tap for mouse moved events. private(set) lazy var mouseMovedTap = EventTap( - options: .listenOnly, + type: .mouseMoved, location: .hidEventTap, placement: .tailAppendEventTap, - type: .mouseMoved + option: .listenOnly ) { [weak self] _, event in if let self, let appState, let screen = bestScreen(appState: appState) { handleShowOnHover(appState: appState, screen: screen) diff --git a/Ice/Events/EventTap.swift b/Ice/Events/EventTap.swift index a3366ba80..19c5d59ce 100644 --- a/Ice/Events/EventTap.swift +++ b/Ice/Events/EventTap.swift @@ -6,24 +6,26 @@ import Cocoa import OSLog -/// A type that receives system events from various locations within the -/// event stream. +/// An object that receives events from a defined point in +/// the event stream. final class EventTap { - /// Constants that specify the possible locations for an event tap. + /// Constants that specify the possible insertion points + /// for event taps. enum Location { - /// The location where HID system events enter the window server. + /// The point where HID system events enter the window + /// server. case hidEventTap - /// The location where HID system and remote control events enter - /// a login session. + /// The point where HID system and remote control events + /// enter a login session. case sessionEventTap - /// The location where session events have been annotated to flow - /// to an application. + /// The point for session events that have been annotated + /// to flow to an application. case annotatedSessionEventTap - /// The location where annotated events are delivered to a specific - /// process. + /// The point where events are delivered to the process + /// with the specified identifier. case pid(pid_t) /// A string to use for logging purposes. @@ -40,90 +42,129 @@ final class EventTap { /// Shared logger for event taps. private static let logger = Logger(category: "EventTap") - /// Top level concurrent queue to run the shared event tap callback. - private static let concurrentQueue = DispatchQueue.targetingGlobal( - label: "EventTap.concurrentQueue", + /// Top level concurrent queue for efficient performance in + /// the shared callback. + private static let callbackQueue = DispatchQueue( + label: "EventTap.callbackQueue", qos: .userInteractive, attributes: .concurrent ) - /// The shared event tap callback. - private static let eventTapCallback: CGEventTapCallBack = { _, type, event, refcon in - concurrentQueue.asyncAndWait { - guard let refcon else { - return Unmanaged.passUnretained(event) - } - let tap: EventTap = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - return tap.callbackQueue.asyncAndWait(flags: .barrier) { - if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { - tap.enable() - return nil - } - guard tap.isEnabled else { - return Unmanaged.passUnretained(event) - } - return tap.callback(tap, event).map { eventFromCallback in - Unmanaged.passUnretained(eventFromCallback) - } - } + /// Shared callback for all event taps. + private static let sharedCallback: CGEventTapCallBack = { _, type, event, refcon in + guard let refcon else { + return Unmanaged.passUnretained(event) + } + let tap: EventTap = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + let retained = Unmanaged.passRetained(tap) + if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { + retained.takeRetainedValue().enable() + return nil + } + guard tap.isEnabled else { + return Unmanaged.passUnretained(event) + } + return tap.callback(retained.takeRetainedValue(), event).map { eventFromCallback in + Unmanaged.passUnretained(eventFromCallback) } } +// private static let sharedCallback: CGEventTapCallBack = { _, type, event, refcon in +// guard let refcon else { +// return Unmanaged.passUnretained(event) +// } +// let tap: EventTap = Unmanaged.fromOpaque(refcon).takeUnretainedValue() +// return callbackQueue.sync { [weak tap] in +// guard let queue = tap?.queue else { +// return Unmanaged.passUnretained(event) +// } +// return queue.sync { [weak tap] in +// guard let tap else { +// return Unmanaged.passUnretained(event) +// } +// let retained = Unmanaged.passRetained(tap) +// if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { +// retained.takeRetainedValue().enable() +// return nil +// } +// guard tap.isEnabled else { +// return Unmanaged.passUnretained(event) +// } +// return tap.callback(retained.takeRetainedValue(), event).map { eventFromCallback in +// Unmanaged.passUnretained(eventFromCallback) +// } +// } +// } +// } + private var machPort: CFMachPort? private var source: CFRunLoopSource? private let runLoop: CFRunLoop - private let callbackQueue: DispatchQueue + private let queue: DispatchQueue private let callback: (EventTap, CGEvent) -> CGEvent? - /// The label associated with the event tap. + /// A string label that identifies the tap. let label: String - /// A Boolean value that indicates whether the event tap is actively + /// A Boolean value that indicates whether the tap is actively /// listening for events. var isEnabled: Bool { guard let machPort else { return false } return CGEvent.tapIsEnabled(tap: machPort) } - /// A Boolean value that indicates whether the event tap is valid and + /// A Boolean value that indicates whether the tap is valid and /// able to receive events. var isValid: Bool { guard let machPort else { return false } return CFMachPortIsValid(machPort) } - /// Creates a new event tap for the given event types. + /// Creates a new event tap for the specified event types. + /// + /// If the tap is an active filter, the callback can return one + /// of the following: + /// - The (possibly modified) received event to pass back to + /// the event stream. + /// - A new event to pass to the event stream in place of the + /// received event. + /// - `nil` to remove the received event from the event stream. + /// + /// If the tap is a passive listener, the callback's return value + /// does not affect the event stream. /// /// - Parameters: - /// - label: The label associated with the tap. - /// - options: A constant that specifies whether the tap is an active - /// filter or a passive listener. - /// - location: The location in the event stream to insert the tap. + /// - label: A string label that identifies the tap in logging + /// and debugging contexts. + /// - types: The types of the events received by the tap. + /// - location: The point in the event stream to insert the tap. /// - placement: The tap's placement relative to other active taps. - /// - types: Specifies the types of the events received by the tap. - /// - callbackQueue: A dispatch queue that performs the tap's callback. - /// - callback: A callback function to perform when events are received. + /// - option: An option that specifies whether the tap is an + /// active filter or a passive listener. + /// - queue: An optional target queue on which to execute the + /// tap's callback. + /// - callback: A closure for the tap to perform when events are + /// received. init( label: String = #function, - options: CGEventTapOptions, + types: [CGEventType], location: Location, placement: CGEventTapPlacement, - types: [CGEventType], - callbackQueue: DispatchQueue? = nil, + option: CGEventTapOptions, + queue: DispatchQueue? = nil, callback: @escaping (_ tap: EventTap, _ event: CGEvent) -> CGEvent? ) { self.label = label self.callback = callback - self.runLoop = RunLoop.current.getCFRunLoop() - self.callbackQueue = callbackQueue ?? DispatchQueue(label: label) + self.runLoop = RunLoop.main.getCFRunLoop() + self.queue = DispatchQueue(label: label, target: queue) guard - let machPort = EventTap.createMachPort( + let machPort = createMachPort( + types: types, location: location, placement: placement, - options: options, - eventMask: types.reduce(0) { $0 | (1 << $1.rawValue) }, - userInfo: Unmanaged.passUnretained(self).toOpaque() + option: option ), let source = CFMachPortCreateRunLoopSource(nil, machPort, 0) else { @@ -135,33 +176,47 @@ final class EventTap { self.source = source } - /// Creates a new event tap for a single event type. + /// Creates a new event tap for the specified event type. + /// + /// If the tap is an active filter, the callback can return one + /// of the following: + /// - The (possibly modified) received event to pass back to + /// the event stream. + /// - A new event to pass to the event stream in place of the + /// received event. + /// - `nil` to remove the received event from the event stream. + /// + /// If the tap is a passive listener, the callback's return value + /// does not affect the event stream. /// /// - Parameters: - /// - label: The label associated with the tap. - /// - options: A constant that specifies whether the tap is an active - /// filter or a passive listener. - /// - location: The location in the event stream to insert the tap. + /// - label: A string label that identifies the tap in logging + /// and debugging contexts. + /// - type: The type of the events received by the tap. + /// - location: The point in the event stream to insert the tap. /// - placement: The tap's placement relative to other active taps. - /// - type: Specifies the type of the events received by the tap. - /// - callbackQueue: A dispatch queue that performs the tap's callback. - /// - callback: A callback function to perform when events are received. + /// - option: An option that specifies whether the tap is an + /// active filter or a passive listener. + /// - queue: An optional target queue on which to execute the + /// tap's callback. + /// - callback: A closure for the tap to perform when events are + /// received. convenience init( label: String = #function, - options: CGEventTapOptions, + type: CGEventType, location: Location, placement: CGEventTapPlacement, - type: CGEventType, - callbackQueue: DispatchQueue? = nil, + option: CGEventTapOptions, + queue: DispatchQueue? = nil, callback: @escaping (_ tap: EventTap, _ event: CGEvent) -> CGEvent? ) { self.init( label: label, - options: options, + types: [type], location: location, placement: placement, - types: [type], - callbackQueue: callbackQueue, + option: option, + queue: queue, callback: callback ) } @@ -176,64 +231,65 @@ final class EventTap { } } - private static func createMachPort( + private func createMachPort( + types: [CGEventType], location: Location, placement: CGEventTapPlacement, - options: CGEventTapOptions, - eventMask: CGEventMask, - userInfo: UnsafeMutableRawPointer + option: CGEventTapOptions ) -> CFMachPort? { - func createMachPort(location: CGEventTapLocation) -> CFMachPort? { + func createEventMask() -> CGEventMask { + types.reduce(0) { $0 | (1 << $1.rawValue) } + } + + func createUserInfo() -> UnsafeMutableRawPointer { + Unmanaged.passUnretained(self).toOpaque() + } + + func createMachPort(at tapLocation: CGEventTapLocation) -> CFMachPort? { CGEvent.tapCreate( - tap: location, + tap: tapLocation, place: placement, - options: options, - eventsOfInterest: eventMask, - callback: eventTapCallback, - userInfo: userInfo + options: option, + eventsOfInterest: createEventMask(), + callback: EventTap.sharedCallback, + userInfo: createUserInfo() ) } - func createMachPort(pid: pid_t) -> CFMachPort? { + func createMachPort(for pid: pid_t) -> CFMachPort? { CGEvent.tapCreateForPid( pid: pid, place: placement, - options: options, - eventsOfInterest: eventMask, - callback: eventTapCallback, - userInfo: userInfo + options: option, + eventsOfInterest: createEventMask(), + callback: EventTap.sharedCallback, + userInfo: createUserInfo() ) } switch location { case .hidEventTap: - return createMachPort(location: .cghidEventTap) + return createMachPort(at: .cghidEventTap) case .sessionEventTap: - return createMachPort(location: .cgSessionEventTap) + return createMachPort(at: .cgSessionEventTap) case .annotatedSessionEventTap: - return createMachPort(location: .cgAnnotatedSessionEventTap) + return createMachPort(at: .cgAnnotatedSessionEventTap) case .pid(let pid): - return createMachPort(pid: pid) + return createMachPort(for: pid) } } /// Enables the event tap. func enable() { - if let source { - CFRunLoopAddSource(runLoop, source, .commonModes) - } - if let machPort { - CGEvent.tapEnable(tap: machPort, enable: true) - } + guard let source, let machPort else { return } + CGEvent.tapEnable(tap: machPort, enable: true) + CFRunLoopAddSource(runLoop, source, .commonModes) } /// Disables the event tap. func disable() { - if let source { - CFRunLoopRemoveSource(runLoop, source, .commonModes) - } - if let machPort { - CGEvent.tapEnable(tap: machPort, enable: false) - } + guard let source, let machPort else { return } + CFRunLoopRemoveSource(runLoop, source, .commonModes) + CGEvent.tapEnable(tap: machPort, enable: false) } } diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index 65febd10d..3000836f3 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -26,12 +26,6 @@ final class MenuBarItemManager: ObservableObject { /// The manager's menu bar item cache. @Published private(set) var itemCache = ItemCache(displayID: nil) - /// Serial queue for posting events directly to menu bar items. - private let scrombleQueue = DispatchQueue.targetingGlobal( - label: "MenuBarItemManager.scrombleQueue", - qos: .userInteractive - ) - /// An actor that manages menu bar item cache operations. private let cacheActor = CacheActor() @@ -482,43 +476,43 @@ extension MenuBarItemManager { struct EventError: Error, CustomStringConvertible, LocalizedError { /// Error codes within the domain of menu bar item event errors. enum ErrorCode: Int, CustomStringConvertible { - /// A menu bar item bounds check timed out. - case boundsCheckTimeout - /// An operation could not be completed. - case couldNotComplete - /// The creation of a menu bar item event failed. + /// A generic indication of a failure. + case cannotComplete + /// A failure during the creation of an event. case eventCreationFailure - /// A menu bar item event operation timed out. + /// A failure during an event operation. + case eventOperationFailure + /// A timeout during an event operation. case eventOperationTimeout - /// The shared app state is invalid or could not be found. - case invalidAppState - /// An event source could not be created or is otherwise invalid. + /// A menu bar item has an incorrect position after being moved. + case incorrectPositionAfterMove + /// An event source cannot be created or is otherwise invalid. case invalidEventSource /// A menu bar item is invalid. case invalidItem - /// A menu bar item's current bounds could not be found. - case missingCurrentBounds - /// The location of the mouse could not be found. + /// A menu bar item is not movable. + case itemNotMovable + /// A timeout waiting for a menu bar item to respond to an event. + case itemResponseTimeout + /// A menu bar item's bounds cannot be found. + case missingItemBounds + /// The location of the mouse cannot be found. case missingMouseLocation - /// A menu bar item cannot be moved. - case notMovable - /// An operation timed out. - case otherTimeout /// Description of the code for debugging purposes. var description: String { switch self { - case .boundsCheckTimeout: "boundsCheckTimeout" - case .couldNotComplete: "couldNotComplete" + case .cannotComplete: "cannotComplete" case .eventCreationFailure: "eventCreationFailure" + case .eventOperationFailure: "eventOperationFailure" case .eventOperationTimeout: "eventOperationTimeout" - case .invalidAppState: "invalidAppState" + case .incorrectPositionAfterMove: "incorrectPositionAfterMove" case .invalidEventSource: "invalidEventSource" case .invalidItem: "invalidItem" - case .missingCurrentBounds: "missingCurrentBounds" + case .itemNotMovable: "itemNotMovable" + case .itemResponseTimeout: "itemResponseTimeout" + case .missingItemBounds: "missingItemBounds" case .missingMouseLocation: "missingMouseLocation" - case .notMovable: "notMovable" - case .otherTimeout: "otherTimeout" } } @@ -537,28 +531,28 @@ extension MenuBarItemManager { /// The message associated with this error. var message: String { switch code { - case .boundsCheckTimeout: - #"Bounds check timed out for "\#(item.displayName)""# - case .couldNotComplete: - #"Could not complete event operation for "\#(item.displayName)""# + case .cannotComplete: + #"Operation could not be completed for "\#(item.displayName)""# case .eventCreationFailure: #"Failed to create event for "\#(item.displayName)""# + case .eventOperationFailure: + #"Event operation failed for "\#(item.displayName)""# case .eventOperationTimeout: #"Event operation timed out for "\#(item.displayName)""# - case .invalidAppState: - #"Invalid app state for "\#(item.displayName)""# + case .incorrectPositionAfterMove: + #""\#(item.displayName)" has an incorrect position after being moved"# case .invalidEventSource: #"Invalid event source for "\#(item.displayName)""# case .invalidItem: #""\#(item.displayName)" is invalid"# - case .missingCurrentBounds: - #"Missing current bounds for "\#(item.displayName)""# + case .itemNotMovable: + #""\#(item.displayName)" is not movable"# + case .itemResponseTimeout: + #"Timeout waiting for response from "\#(item.displayName)""# + case .missingItemBounds: + #"Missing screen bounds for "\#(item.displayName)""# case .missingMouseLocation: #"Missing mouse location for "\#(item.displayName)""# - case .notMovable: - #""\#(item.displayName)" is not movable"# - case .otherTimeout: - #"Operation timed out for "\#(item.displayName)""# } } @@ -581,62 +575,109 @@ extension MenuBarItemManager { } } - /// Waits for the given duration. Use this to pad out event - /// operations when needed. - /// - /// - Parameter duration: The duration to wait. - private func eventSleep(for duration: Duration = .milliseconds(20)) async { + /// Waits for the given duration, without throwing an error if cancelled. + private nonisolated func eventSleep(for duration: Duration = .milliseconds(25)) async { try? await Task.sleep(for: duration) } /// Returns the current bounds for the given item. - private func getCurrentBounds(for item: MenuBarItem) throws -> CGRect { - guard let bounds = MenuBarItem.currentBounds(for: item) else { - throw EventError(code: .missingCurrentBounds, item: item) + private nonisolated func getCurrentBounds(for item: MenuBarItem) async throws -> CGRect { + let task = Task.detached(priority: .userInitiated) { + guard let bounds = MenuBarItem.currentBounds(for: item) else { + throw EventError(code: .missingItemBounds, item: item) + } + return bounds + } + return try await task.value + } + + /// Returns the current mouse location. + private nonisolated func getMouseLocation(item: MenuBarItem) throws -> CGPoint { + guard let location = MouseHelpers.locationCoreGraphics else { + throw EventError(code: .missingMouseLocation, item: item) } - return bounds + return location + } + + /// Returns the process identifier that can be used to create + /// and post a menu bar item event. + private nonisolated func getEventPID(for item: MenuBarItem) -> pid_t { + item.sourcePID ?? item.ownerPID } - /// Returns the event source for moving a menu bar item. - private func getEventSource(item: MenuBarItem) throws -> CGEventSource { + /// Returns an event source for a menu bar item event operation. + private nonisolated func getEventSource( + with stateID: CGEventSourceStateID = .hidSystemState, + for item: MenuBarItem + ) throws -> CGEventSource { enum Context { - static var source: CGEventSource? + static var cache = [CGEventSourceStateID: CGEventSource]() } - if let source = Context.source { + if let source = Context.cache[stateID] { return source } - guard let source = CGEventSource(stateID: .hidSystemState) else { + guard let source = CGEventSource(stateID: stateID) else { throw EventError(code: .invalidEventSource, item: item) } - Context.source = source + Context.cache[stateID] = source return source } - /// Returns the current mouse location. - private func getMouseLocation(item: MenuBarItem) throws -> CGPoint { - guard let location = MouseHelpers.locationCoreGraphics else { - throw EventError(code: .missingMouseLocation, item: item) - } - return location - } - /// Permits all events for an event source during the given suppression /// states, suppressing local events for the given interval. - private func permitAllEvents( + private nonisolated func permitAllEvents( for stateID: CGEventSourceStateID, during states: [CGEventSuppressionState], suppressionInterval: TimeInterval, item: MenuBarItem ) throws { - guard let source = CGEventSource(stateID: stateID) else { - throw EventError(code: .invalidEventSource, item: item) - } + let source = try getEventSource(with: stateID, for: item) for state in states { source.setLocalEventsFilterDuringSuppressionState(.permitAllEvents, state: state) } source.localEventsSuppressionInterval = suppressionInterval } + /// Waits for a menu bar item's bounds to change in reaction to + /// a series of received events. + /// + /// - Parameters: + /// - item: The item to check for bounds changes. + /// - initialBounds: The bounds of the item before any events + /// were sent to it. + /// - timeout: The duration to wait before throwing an error. + private nonisolated func waitForResponse( + from item: MenuBarItem, + initialBounds: CGRect, + timeout: Duration + ) async throws -> CGRect { + let boundsCheckTask = Task.detached(timeout: timeout) { + while true { + try Task.checkCancellation() + let bounds = try await self.getCurrentBounds(for: item) + if bounds != initialBounds { + return bounds + } + } + } + do { + let bounds = try await boundsCheckTask.value + logger.debug( + """ + Bounds for \(item.logString, privacy: .public) changed \ + to \(NSStringFromRect(bounds), privacy: .public) + """ + ) + return bounds + } catch let error as EventError { + throw error + } catch is TaskTimeoutError { + throw EventError(code: .itemResponseTimeout, item: item) + } catch { + throw EventError(code: .cannotComplete, item: item) + } + } + /// Posts an event to the given event tap location and waits /// until it is received before returning. /// @@ -645,34 +686,39 @@ extension MenuBarItemManager { /// - location: The event tap location to post the event to. /// - item: The menu bar item that the event targets. /// - timeout: The duration to wait before throwing an error. - private func postEventRoundtrip( + private nonisolated func postEventRoundtrip( _ event: CGEvent, to location: EventTap.Location, item: MenuBarItem, timeout: Duration ) async throws { + var eventTaps = [EventTap]() let timeoutTask = Task(timeout: timeout) { - var eventTap: EventTap? - - defer { - eventTap?.disable() - } - - await withCheckedContinuation { continuation in - eventTap = EventTap( - options: .listenOnly, + try await withCheckedThrowingContinuation { continuation in + let eventTap = EventTap( + type: event.type, location: location, placement: .tailAppendEventTap, - type: event.type, - callbackQueue: scrombleQueue - ) { _, rEvent in + option: .listenOnly + ) { tap, rEvent in if rEvent.matches(event, by: CGEventField.menuBarItemEventFields) { + tap.disable() continuation.resume() } return rEvent } - eventTap?.enable() - event.post(to: location) + + eventTaps.append(eventTap) + + Task { + await withTaskCancellationHandler { + eventTap.enable() + event.post(to: location) + } onCancel: { + eventTap.disable() + continuation.resume(throwing: CancellationError()) + } + } } } do { @@ -680,7 +726,7 @@ extension MenuBarItemManager { } catch is TaskTimeoutError { throw EventError(code: .eventOperationTimeout, item: item) } catch { - throw EventError(code: .couldNotComplete, item: item) + throw EventError(code: .cannotComplete, item: item) } } @@ -692,7 +738,7 @@ extension MenuBarItemManager { /// - secondTapLocation: The second event tap location to post the event. /// - item: The menu bar item that the event targets. /// - timeout: The duration to wait before throwing an error. - private func scrombleEvent( + private nonisolated func scrombleEvent( _ event: CGEvent, from firstTapLocation: EventTap.Location, to secondTapLocation: EventTap.Location, @@ -706,32 +752,26 @@ extension MenuBarItemManager { throw EventError(code: .eventCreationFailure, item: item) } - let timeoutTask = Task(timeout: timeout) { - var eventTap1: EventTap? - var eventTap2: EventTap? - - defer { - eventTap1?.disable() - eventTap2?.disable() - } + var eventTaps = [EventTap]() - await withCheckedContinuation { continuation in + let timeoutTask = Task(timeout: timeout) { + try await withCheckedThrowingContinuation { continuation in // Create a tap for the entry and exit events at the first location. // This tap is responsible for posting the actual event to the second // location and resuming the continuation. - eventTap1 = EventTap( + let eventTap1 = EventTap( label: "EventTap 1", - options: .defaultTap, + type: .null, location: firstTapLocation, placement: .headInsertEventTap, - type: .null, - callbackQueue: scrombleQueue - ) { _, rEvent in + option: .defaultTap + ) { tap, rEvent in if rEvent.matches(entryEvent, by: [.eventSourceUserData]) { event.post(to: secondTapLocation) return nil } if rEvent.matches(exitEvent, by: [.eventSourceUserData]) { + tap.disable() continuation.resume() return nil } @@ -740,25 +780,34 @@ extension MenuBarItemManager { // Create a tap for the actual event at the second location. This tap // is responsible for posting the exit event to the first location. - eventTap2 = EventTap( + let eventTap2 = EventTap( label: "EventTap 2", - options: .listenOnly, + type: event.type, location: secondTapLocation, placement: .tailAppendEventTap, - type: event.type, - callbackQueue: scrombleQueue - ) { _, rEvent in + option: .listenOnly + ) { tap, rEvent in if rEvent.matches(event, by: CGEventField.menuBarItemEventFields) { + tap.disable() exitEvent.post(to: firstTapLocation) } return rEvent } - eventTap1?.enable() - eventTap2?.enable() + eventTaps.append(eventTap1) + eventTaps.append(eventTap2) - // Post the entry event to the first location to start the event chain. - entryEvent.post(to: firstTapLocation) + Task { + await withTaskCancellationHandler { + eventTap1.enable() + eventTap2.enable() + entryEvent.post(to: firstTapLocation) + } onCancel: { + eventTap1.disable() + eventTap2.disable() + continuation.resume(throwing: CancellationError()) + } + } } } do { @@ -766,59 +815,41 @@ extension MenuBarItemManager { } catch is TaskTimeoutError { throw EventError(code: .eventOperationTimeout, item: item) } catch { - throw EventError(code: .couldNotComplete, item: item) + throw EventError(code: .cannotComplete, item: item) } } - /// Does a lot of weird magic to make a menu bar item receive an event, - /// then waits for the item to respond. - /// - /// - Parameters: - /// - event: The event to post. - /// - firstTapLocation: The first event tap location to post the event. - /// - secondTapLocation: The second event tap location to post the event. - /// - item: The menu bar item that the event targets. - /// - screen: A screen whose refresh rate determines the duration between - /// each response check. - /// - timeout: The duration to wait before throwing an error. - private func scrombleEvent( - _ event: CGEvent, - from firstTapLocation: EventTap.Location, - to secondTapLocation: EventTap.Location, - untilItemResponds item: MenuBarItem, - screen: NSScreen, - timeout: Duration - ) async throws { - let duration = getSleepDurationFromScreenRefreshRate(screen: screen) - let initialBounds = try getCurrentBounds(for: item) - try await scrombleEvent(event, from: firstTapLocation, to: secondTapLocation, item: item, timeout: timeout) - let boundsCheckTask = Task(timeout: timeout) { - while true { - try Task.checkCancellation() - let currentBounds = try getCurrentBounds(for: item) - guard currentBounds != initialBounds else { - try await Task.sleep(for: duration) - continue - } - logger.debug( - """ - Bounds for \(item.logString, privacy: .public) changed \ - to \(NSStringFromRect(currentBounds), privacy: .public) - """ - ) - return - } - } - do { - try await boundsCheckTask.value - } catch let error as EventError { - throw error - } catch is TaskTimeoutError { - throw EventError(code: .boundsCheckTimeout, item: item) - } catch { - throw EventError(code: .couldNotComplete, item: item) - } - } +// /// Does a lot of weird magic to make a menu bar item receive an event, +// /// then waits for the item to respond. +// /// +// /// - Parameters: +// /// - event: The event to post. +// /// - firstTapLocation: The first event tap location to post the event. +// /// - secondTapLocation: The second event tap location to post the event. +// /// - item: The menu bar item that the event targets. +// /// - timeout: The duration for individual operations to wait before +// /// throwing an error. +// private nonisolated func scrombleEvent( +// _ event: CGEvent, +// from firstTapLocation: EventTap.Location, +// to secondTapLocation: EventTap.Location, +// waitingForResponseFrom item: MenuBarItem, +// timeout: Duration +// ) async throws { +// let initialBounds = try await getCurrentBounds(for: item) +// try await self.scrombleEvent( +// event, +// from: firstTapLocation, +// to: secondTapLocation, +// item: item, +// timeout: timeout +// ) +// try await self.waitForResponse( +// from: item, +// initialBounds: initialBounds, +// timeout: timeout +// ) +// } } // MARK: - Move Operations @@ -847,9 +878,9 @@ extension MenuBarItemManager { } } - /// Returns the end location for moving an item to the given destination. - private func getEndLocation(for destination: MoveDestination) throws -> CGPoint { - let bounds = try getCurrentBounds(for: destination.targetItem) + /// Returns the point for moving an item to the given destination. + private nonisolated func getTargetPoint(for destination: MoveDestination) async throws -> CGPoint { + let bounds = try await getCurrentBounds(for: destination.targetItem) return switch destination { case .leftOfItem: CGPoint(x: bounds.minX, y: bounds.minY) case .rightOfItem: CGPoint(x: bounds.maxX, y: bounds.minY) @@ -858,54 +889,80 @@ extension MenuBarItemManager { /// Returns a Boolean value that indicates whether the given item is /// in the correct position for the given destination. - private func itemHasCorrectPosition(item: MenuBarItem, for destination: MoveDestination) throws -> Bool { - let itemBounds = try getCurrentBounds(for: item) - let targetBounds = try getCurrentBounds(for: destination.targetItem) + private nonisolated func itemHasCorrectPosition(item: MenuBarItem, for destination: MoveDestination) async throws -> Bool { + let itemBounds = try await getCurrentBounds(for: item) + let targetBounds = try await getCurrentBounds(for: destination.targetItem) return switch destination { case .leftOfItem: itemBounds.maxX == targetBounds.minX case .rightOfItem: itemBounds.minX == targetBounds.maxX } } - /// Attempts to move a menu bar item to the given destination. + private nonisolated func getPreflightEndPoint(beforeMoving item: MenuBarItem, to destination: MoveDestination) async throws -> CGPoint { + let itemBounds = try await getCurrentBounds(for: item) + let targetBounds = try await getCurrentBounds(for: destination.targetItem) + if itemBounds.maxX <= targetBounds.minX { + switch destination { + case .leftOfItem: + return CGPoint(x: targetBounds.minX - itemBounds.width, y: itemBounds.minY) + case .rightOfItem: + return CGPoint(x: itemBounds.minX + targetBounds.width, y: itemBounds.minY) + } + } else { + switch destination { + case .leftOfItem: + return CGPoint(x: targetBounds.minX, y: itemBounds.minY) + case .rightOfItem: + return CGPoint(x: targetBounds.maxX, y: itemBounds.minY) + } + } + } + + private nonisolated func validatePosition(afterMoving item: MenuBarItem, preflightPoint: CGPoint) async throws { + let itemBounds = try await getCurrentBounds(for: item) + if itemBounds.origin.distance(to: preflightPoint) > 1 { + throw EventError(code: .incorrectPositionAfterMove, item: item) + } + } + + /// Creates and posts a series of events to move a menu bar item to + /// the given destination. /// /// - Parameters: /// - item: The menu bar item to move. /// - destination: The destination to move the menu bar item. - /// - source: The event source used to create the events that - /// move the item. - /// - timeout: The duration to wait before throwing an error. - private func performMoveOperation( + /// - source: The event source used to create the events. + /// - timeout: The duration for each individual operation to wait + /// before throwing an error. + private nonisolated func postMoveEvents( item: MenuBarItem, destination: MoveDestination, source: CGEventSource, - screen: NSScreen, timeout: Duration ) async throws { - let itemBounds = try getCurrentBounds(for: item) - let endLocation = try getEndLocation(for: destination) - let fallbackLocation = CGPoint(x: itemBounds.midX, y: itemBounds.minY) - let pid = item.sourcePID ?? item.ownerPID + var itemBounds = try await getCurrentBounds(for: item) + let targetPoint = try await getTargetPoint(for: destination) + let pid = getEventPID(for: item) guard let moveEvent1 = CGEvent.menuBarItemEvent( source: source, type: .move(.mouseDown), - location: endLocation, + location: targetPoint, item: item, pid: pid ), let moveEvent2 = CGEvent.menuBarItemEvent( source: source, type: .move(.mouseUp), - location: endLocation, + location: targetPoint, item: destination.targetItem, pid: pid ), let fallbackEvent = CGEvent.menuBarItemEvent( source: source, type: .move(.mouseUp), - location: fallbackLocation, + location: targetPoint, item: item, pid: pid ) @@ -913,24 +970,45 @@ extension MenuBarItemManager { throw EventError(code: .eventCreationFailure, item: item) } - latestMoveOperationTimestamp = .now + await MainActor.run { + latestMoveOperationTimestamp = .now + } do { try await scrombleEvent( moveEvent1, from: .pid(pid), to: .sessionEventTap, - untilItemResponds: item, - screen: screen, + item: item, timeout: timeout ) - try await scrombleEvent( - moveEvent2, - from: .pid(pid), - to: .sessionEventTap, - item: item, + itemBounds = try await waitForResponse( + from: item, + initialBounds: itemBounds, timeout: timeout ) + try await withThrowingTaskGroup { group in + group.addTask { + while !Task.isCancelled { + try await self.scrombleEvent( + moveEvent2, + from: .pid(pid), + to: .sessionEventTap, + item: item, + timeout: timeout + ) + } + } + group.addTask { + itemBounds = try await self.waitForResponse( + from: item, + initialBounds: itemBounds, + timeout: timeout + ) + } + try await group.next() + group.cancelAll() + } } catch { logger.warning("Move events failed. Posting fallback.") @@ -959,20 +1037,69 @@ extension MenuBarItemManager { /// - Parameters: /// - item: The menu bar item to move. /// - destination: The destination to move the menu bar item. - /// - timeout: The duration to wait before throwing an error. - func move( + /// - source: The event source used to create the events that move + /// the item. + /// - timeout: The duration for each individual operation to wait + /// before throwing an error. + private func performMoveOperation( item: MenuBarItem, - to destination: MoveDestination, - timeout: Duration = .milliseconds(250) + destination: MoveDestination, + source: CGEventSource, + timeout: Duration ) async throws { + let preflightPoint = try await getPreflightEndPoint(beforeMoving: item, to: destination) + let mouseLocation = try getMouseLocation(item: item) + + // Move operations can occasionally fail. Retry up to a total + // of 5 attempts, throwing the last attempt's error if it fails. + for n in 1...5 { + try Task.checkCancellation() + do { + MouseHelpers.hideCursor() + + defer { + MouseHelpers.warpCursor(to: mouseLocation) + MouseHelpers.showCursor() + } + + try await postMoveEvents( + item: item, + destination: destination, + source: source, + timeout: timeout + ) + return try await validatePosition( + afterMoving: item, + preflightPoint: preflightPoint + ) + } catch where n < 5 { + logger.debug( + """ + Move attempt \(n, privacy: .public) failed with error: \ + \(error, privacy: .public) + """ + ) + } + } + } + + /// Moves a menu bar item to the given destination and waits until + /// the move is finished before returning. + /// + /// - Parameters: + /// - item: The menu bar item to move. + /// - destination: The destination to move the menu bar item. + /// - timeout: The duration for each individual operation to wait + /// before throwing an error. + func move(item: MenuBarItem, to destination: MoveDestination, timeout: Duration = .milliseconds(50)) async throws { guard item.isMovable else { - throw EventError(code: .notMovable, item: item) + throw EventError(code: .itemNotMovable, item: item) } guard let appState else { - throw EventError(code: .invalidAppState, item: item) + throw EventError(code: .cannotComplete, item: item) } - guard try !itemHasCorrectPosition(item: item, for: destination) else { + guard try await !itemHasCorrectPosition(item: item, for: destination) else { logger.debug("\(item.logString, privacy: .public) already has correct position") return } @@ -992,11 +1119,10 @@ extension MenuBarItemManager { try await waitForAllMouseButtonsUp() try await waitForAllModifierKeysUp() } catch { - throw EventError(code: .couldNotComplete, item: item) + throw EventError(code: .cannotComplete, item: item) } - let source = try getEventSource(item: item) - let mouseLocation = try getMouseLocation(item: item) + let source = try getEventSource(for: item) try permitAllEvents( for: .combinedSessionState, @@ -1008,25 +1134,11 @@ extension MenuBarItemManager { item: item ) - guard - let displayID = Bridging.getActiveMenuBarDisplayID(), - let screen = NSScreen.screens.first(where: { $0.displayID == displayID }) - else { - throw EventError(code: .couldNotComplete, item: item) - } - appState.eventManager.stopAll() defer { appState.eventManager.startAll() } - MouseHelpers.hideCursor() - - defer { - MouseHelpers.warpCursor(to: mouseLocation) - MouseHelpers.showCursor() - } - logger.debug( """ Moving \(item.logString, privacy: .public) to \ @@ -1044,7 +1156,6 @@ extension MenuBarItemManager { item: item, destination: destination, source: source, - screen: screen, timeout: timeout ) } catch where n < 5 { @@ -1064,7 +1175,7 @@ extension MenuBarItemManager { } catch let error as EventError { throw error } catch { - throw EventError(code: .couldNotComplete, item: item) + throw EventError(code: .cannotComplete, item: item) } } } @@ -1084,36 +1195,36 @@ extension MenuBarItemManager { timeout: Duration = .milliseconds(250) ) async throws { guard let appState else { - throw EventError(code: .invalidAppState, item: item) + throw EventError(code: .cannotComplete, item: item) } - let source = try getEventSource(item: item) + let source = try getEventSource(for: item) let mouseLocation = try getMouseLocation(item: item) - let itemBounds = try getCurrentBounds(for: item) + let itemBounds = try await getCurrentBounds(for: item) + let pid = getEventPID(for: item) let mouseStates = mouseButton.mouseStates - let clickLocation = itemBounds.center - let pid = item.sourcePID ?? item.ownerPID + let clickPoint = itemBounds.center guard let clickEvent1 = CGEvent.menuBarItemEvent( source: source, type: .click(mouseStates.down), - location: clickLocation, + location: clickPoint, item: item, pid: pid ), let clickEvent2 = CGEvent.menuBarItemEvent( source: source, type: .click(mouseStates.up), - location: clickLocation, + location: clickPoint, item: item, pid: pid ), let fallbackEvent = CGEvent.menuBarItemEvent( source: source, type: .click(mouseStates.up), - location: clickLocation, + location: clickPoint, item: item, pid: pid ) @@ -1158,7 +1269,6 @@ extension MenuBarItemManager { item: item, timeout: timeout ) - await eventSleep() try await scrombleEvent( clickEvent2, from: .pid(pid), @@ -1166,7 +1276,6 @@ extension MenuBarItemManager { item: item, timeout: timeout ) - await eventSleep() logger.debug("Successfully clicked item") } catch { logger.warning("Click events failed. Posting fallback.") @@ -1314,12 +1423,16 @@ extension MenuBarItemManager { items.trimPrefix { !$0.isOnScreen } } - let maxX = if let rightArea = screen.auxiliaryTopRightArea { - max(rightArea.minX + 20, applicationMenuFrame.maxX) + var maxX = if let frameOfNotch = screen.frameOfNotch { + max(frameOfNotch.maxX + 20, applicationMenuFrame.maxX) } else { applicationMenuFrame.maxX } + if let item = items.first, item.tag == .audioVideoModule { + maxX += item.bounds.width + } + // Remove items until we have enough room to show this item. items.trimPrefix { $0.bounds.minX - item.bounds.width <= maxX } @@ -1348,7 +1461,7 @@ extension MenuBarItemManager { runRehideTimer() } - await eventSleep(for: .milliseconds(100)) + await eventSleep(for: .milliseconds(50)) let idsBeforeClick = Set(Bridging.getWindowList(option: .onScreen)) @@ -1389,7 +1502,7 @@ extension MenuBarItemManager { logger.debug("Rehiding temporarily shown items") while let context = tempShownItemContexts.popLast() { - guard let item = items.first(where: { $0.tag == context.tag }) else { + guard let item = items.first(matching: context.tag) else { continue } do { @@ -1711,8 +1824,10 @@ private extension CGEvent { private func setWindowID(_ windowID: CGWindowID, for type: MenuBarItemEventType) { let windowID = Int64(windowID) - setIntegerValueField(.mouseEventWindowUnderMousePointer, value: windowID) - setIntegerValueField(.mouseEventWindowUnderMousePointerThatCanHandleThisEvent, value: windowID) + if case .click = type { + setIntegerValueField(.mouseEventWindowUnderMousePointer, value: windowID) + setIntegerValueField(.mouseEventWindowUnderMousePointerThatCanHandleThisEvent, value: windowID) + } if case .move = type { setIntegerValueField(.windowID, value: windowID) diff --git a/Shared/Bridging/Bridging.swift b/Shared/Bridging/Bridging.swift index b3365f561..922614c38 100644 --- a/Shared/Bridging/Bridging.swift +++ b/Shared/Bridging/Bridging.swift @@ -195,11 +195,11 @@ extension Bridging { /// Returns the bounds for the given window. /// /// - Parameter windowID: An identifier for a window. - static func getWindowBounds(for windowID: CGWindowID) -> CGRect? { + static nonisolated func getWindowBounds(for windowID: CGWindowID) -> CGRect? { var bounds = CGRect.zero - let result = CGSGetWindowBounds(mainConnectionID, windowID, &bounds) + let result = CGSGetScreenRectForWindow(CGSDefaultConnectionForThread(), windowID, &bounds) guard result == .success else { - logger.error("CGSGetWindowBounds failed with error \(result.logString, privacy: .public)") + logger.error("CGSGetScreenRectForWindow failed with error \(result.logString, privacy: .public)") return nil } return bounds diff --git a/Shared/Bridging/Shims.swift b/Shared/Bridging/Shims.swift index 59d79a655..4496c8499 100644 --- a/Shared/Bridging/Shims.swift +++ b/Shared/Bridging/Shims.swift @@ -37,6 +37,9 @@ struct CGSSpaceMask: OptionSet { @_silgen_name("CGSMainConnectionID") func CGSMainConnectionID() -> CGSConnectionID +@_silgen_name("CGSDefaultConnectionForThread") +func CGSDefaultConnectionForThread() -> CGSConnectionID + @_silgen_name("CGSCopyConnectionProperty") func CGSCopyConnectionProperty( _ cid: CGSConnectionID, @@ -139,6 +142,13 @@ func CGSGetProcessMenuBarWindowList( _ outCount: inout Int32 ) -> CGError +@_silgen_name("CGSGetScreenRectForWindow") +func CGSGetScreenRectForWindow( + _ cid: CGSConnectionID, + _ wid: CGWindowID, + _ outRect: inout CGRect +) -> CGError + @_silgen_name("CGSGetWindowBounds") func CGSGetWindowBounds( _ cid: CGSConnectionID, From 5dff4d4a8fa850efe2e3e06009383ebf1da15b0a Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 22 Aug 2025 09:31:02 -0600 Subject: [PATCH 49/80] Misc refactoring --- .../xcschemes/MenuBarItemService.xcscheme | 67 +++ Ice/Events/EventTap.swift | 63 +-- Ice/Main/AppDelegate.swift | 15 +- Ice/MenuBar/IceBar/IceBar.swift | 2 +- .../LayoutBar/LayoutBarPaddingView.swift | 4 +- .../MenuBarItems/MenuBarItemManager.swift | 459 +++++++----------- Ice/MenuBar/Search/MenuBarSearchPanel.swift | 2 +- .../UserNotificationManager.swift | 2 +- Ice/Utilities/Helpers.swift | 22 - Ice/Utilities/MouseHelpers.swift | 8 +- MenuBarItemService/Listener.swift | 12 +- MenuBarItemService/SourcePIDCache.swift | 4 +- Shared/Bridging/Bridging.swift | 85 ++-- Shared/Utilities/Logging.swift | 4 +- 14 files changed, 340 insertions(+), 409 deletions(-) create mode 100644 Ice.xcodeproj/xcshareddata/xcschemes/MenuBarItemService.xcscheme diff --git a/Ice.xcodeproj/xcshareddata/xcschemes/MenuBarItemService.xcscheme b/Ice.xcodeproj/xcshareddata/xcschemes/MenuBarItemService.xcscheme new file mode 100644 index 000000000..fe2c101dc --- /dev/null +++ b/Ice.xcodeproj/xcshareddata/xcschemes/MenuBarItemService.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Ice/Events/EventTap.swift b/Ice/Events/EventTap.swift index 19c5d59ce..eddca8bab 100644 --- a/Ice/Events/EventTap.swift +++ b/Ice/Events/EventTap.swift @@ -42,14 +42,6 @@ final class EventTap { /// Shared logger for event taps. private static let logger = Logger(category: "EventTap") - /// Top level concurrent queue for efficient performance in - /// the shared callback. - private static let callbackQueue = DispatchQueue( - label: "EventTap.callbackQueue", - qos: .userInteractive, - attributes: .concurrent - ) - /// Shared callback for all event taps. private static let sharedCallback: CGEventTapCallBack = { _, type, event, refcon in guard let refcon else { @@ -69,38 +61,9 @@ final class EventTap { } } -// private static let sharedCallback: CGEventTapCallBack = { _, type, event, refcon in -// guard let refcon else { -// return Unmanaged.passUnretained(event) -// } -// let tap: EventTap = Unmanaged.fromOpaque(refcon).takeUnretainedValue() -// return callbackQueue.sync { [weak tap] in -// guard let queue = tap?.queue else { -// return Unmanaged.passUnretained(event) -// } -// return queue.sync { [weak tap] in -// guard let tap else { -// return Unmanaged.passUnretained(event) -// } -// let retained = Unmanaged.passRetained(tap) -// if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { -// retained.takeRetainedValue().enable() -// return nil -// } -// guard tap.isEnabled else { -// return Unmanaged.passUnretained(event) -// } -// return tap.callback(retained.takeRetainedValue(), event).map { eventFromCallback in -// Unmanaged.passUnretained(eventFromCallback) -// } -// } -// } -// } - private var machPort: CFMachPort? private var source: CFRunLoopSource? private let runLoop: CFRunLoop - private let queue: DispatchQueue private let callback: (EventTap, CGEvent) -> CGEvent? /// A string label that identifies the tap. @@ -141,8 +104,6 @@ final class EventTap { /// - placement: The tap's placement relative to other active taps. /// - option: An option that specifies whether the tap is an /// active filter or a passive listener. - /// - queue: An optional target queue on which to execute the - /// tap's callback. /// - callback: A closure for the tap to perform when events are /// received. init( @@ -151,20 +112,19 @@ final class EventTap { location: Location, placement: CGEventTapPlacement, option: CGEventTapOptions, - queue: DispatchQueue? = nil, callback: @escaping (_ tap: EventTap, _ event: CGEvent) -> CGEvent? ) { self.label = label self.callback = callback - self.runLoop = RunLoop.main.getCFRunLoop() - self.queue = DispatchQueue(label: label, target: queue) + self.runLoop = CFRunLoopGetMain() guard - let machPort = createMachPort( + let machPort = EventTap.createMachPort( types: types, location: location, placement: placement, - option: option + option: option, + tap: self ), let source = CFMachPortCreateRunLoopSource(nil, machPort, 0) else { @@ -197,8 +157,6 @@ final class EventTap { /// - placement: The tap's placement relative to other active taps. /// - option: An option that specifies whether the tap is an /// active filter or a passive listener. - /// - queue: An optional target queue on which to execute the - /// tap's callback. /// - callback: A closure for the tap to perform when events are /// received. convenience init( @@ -207,7 +165,6 @@ final class EventTap { location: Location, placement: CGEventTapPlacement, option: CGEventTapOptions, - queue: DispatchQueue? = nil, callback: @escaping (_ tap: EventTap, _ event: CGEvent) -> CGEvent? ) { self.init( @@ -216,7 +173,6 @@ final class EventTap { location: location, placement: placement, option: option, - queue: queue, callback: callback ) } @@ -231,18 +187,19 @@ final class EventTap { } } - private func createMachPort( + private static func createMachPort( types: [CGEventType], location: Location, placement: CGEventTapPlacement, - option: CGEventTapOptions + option: CGEventTapOptions, + tap: EventTap ) -> CFMachPort? { func createEventMask() -> CGEventMask { types.reduce(0) { $0 | (1 << $1.rawValue) } } func createUserInfo() -> UnsafeMutableRawPointer { - Unmanaged.passUnretained(self).toOpaque() + Unmanaged.passUnretained(tap).toOpaque() } func createMachPort(at tapLocation: CGEventTapLocation) -> CFMachPort? { @@ -251,7 +208,7 @@ final class EventTap { place: placement, options: option, eventsOfInterest: createEventMask(), - callback: EventTap.sharedCallback, + callback: sharedCallback, userInfo: createUserInfo() ) } @@ -262,7 +219,7 @@ final class EventTap { place: placement, options: option, eventsOfInterest: createEventMask(), - callback: EventTap.sharedCallback, + callback: sharedCallback, userInfo: createUserInfo() ) } diff --git a/Ice/Main/AppDelegate.swift b/Ice/Main/AppDelegate.swift index b21aa5a3a..1d491be54 100644 --- a/Ice/Main/AppDelegate.swift +++ b/Ice/Main/AppDelegate.swift @@ -11,27 +11,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// The shared app state. let appState = AppState() - /// Logger for the app delegate. - let logger = Logger(category: "AppDelegate") - // MARK: NSApplicationDelegate Methods func applicationWillFinishLaunching(_ notification: Notification) { // Initial chore work. NSSplitViewItem.swizzle() MigrationManager(appState: appState).migrateAll() - Bridging.setConnectionProperty(true, forKey: "SetsCursorInBackground") NSColorPanel.shared.animationBehavior = .none NSColorPanel.shared.hidesOnDeactivate = false NSColorPanel.shared.styleMask.insert(.nonactivatingPanel) } func applicationDidFinishLaunching(_ notification: Notification) { - // Hide the main menu's items to make more room in the menu bar. + // Hide the main menu's items to add additional space to the + // menu bar when we are the focused app. for item in NSApp.mainMenu?.items ?? [] { item.isHidden = true } + // Allow hiding the mouse while the app is in the background + // to make menu bar item movement less jarring. + Bridging.setConnectionProperty(true, forKey: "SetsCursorInBackground") + #if DEBUG // Don't perform setup if running as a preview. if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { @@ -55,7 +56,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { - logger.debug("Handling reopen") + Logger.default.debug("Handling reopen") openSettingsWindow() return true } @@ -66,7 +67,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { sender.activationPolicy() != .accessory, appState.navigationState.isAppFrontmost { - logger.debug("All windows closed - deactivating with accessory activation policy") + Logger.default.debug("All windows closed - deactivating with accessory activation policy") appState.deactivate(withPolicy: .accessory) } return false diff --git a/Ice/MenuBar/IceBar/IceBar.swift b/Ice/MenuBar/IceBar/IceBar.swift index 9d5863c6a..aac9f5d12 100644 --- a/Ice/MenuBar/IceBar/IceBar.swift +++ b/Ice/MenuBar/IceBar/IceBar.swift @@ -176,7 +176,7 @@ final class IceBarPanel: NSPanel { do { try await cacheTask.value } catch { - Logger.general.error("Cache update failed when showing IceBarPanel - \(error)") + Logger.default.error("Cache update failed when showing IceBarPanel - \(error)") } contentView = IceBarHostingView( diff --git a/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift b/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift index 2ed73bb58..6bc449b4e 100644 --- a/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift +++ b/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift @@ -87,7 +87,7 @@ final class LayoutBarPaddingView: NSView { if let targetItem { move(item: draggingSource.item, to: .leftOfItem(targetItem)) } else { - Logger.general.error("No target item for layout bar drag") + Logger.default.error("No target item for layout bar drag") } } } else if arrangedViews.indices.contains(index + 1) { @@ -114,7 +114,7 @@ final class LayoutBarPaddingView: NSView { try await appState.itemManager.move(item: item, to: destination) appState.itemManager.removeTempShownItemFromCache(with: item.tag) } catch { - Logger.general.error("Error moving menu bar item: \(error, privacy: .public)") + Logger.default.error("Error moving menu bar item: \(error, privacy: .public)") let alert = NSAlert(error: error) alert.runModal() } diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index 3000836f3..106a01e64 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -10,38 +10,23 @@ import OSLog /// Manager for menu bar items. @MainActor final class MenuBarItemManager: ObservableObject { - /// An actor that manages menu bar item cache operations. - private final actor CacheActor { - private var cacheTask: Task? - - /// Runs the given async closure as a task and waits for it - /// to complete before returning. - func runCacheTask(_ operation: @escaping () async -> Void) async { - cacheTask?.cancel() - cacheTask = Task(operation: operation) - await cacheTask?.value - } - } - /// The manager's menu bar item cache. @Published private(set) var itemCache = ItemCache(displayID: nil) - /// An actor that manages menu bar item cache operations. + /// Actor for managing menu bar item cache operations. private let cacheActor = CacheActor() - /// Cached window identifiers for the most recent menu - /// bar items. + /// Window identifiers for the most recently cached menu bar items. private var cachedItemWindowIDs = [CGWindowID]() - /// Context values for the current temporarily shown menu - /// bar items. + /// Contexts for temporarily shown menu bar items. private var tempShownItemContexts = [TempShownItemContext]() /// A timer for rehiding temporarily shown menu bar items. private var rehideTimer: Timer? - /// A timestamp taken at the start of the latest menu bar - /// item movement operation. + /// A timestamp representing the start of the latest menu bar item + /// move operation. private var latestMoveOperationTimestamp: ContinuousClock.Instant? /// Storage for internal observers. @@ -93,7 +78,7 @@ final class MenuBarItemManager: ObservableObject { } /// Returns a Boolean value that indicates whether the latest menu bar - /// item movement operation was started within the given time duration. + /// item move operation was started within the given time duration. func latestMoveOperationStarted(within duration: Duration) -> Bool { guard let timestamp = latestMoveOperationTimestamp else { return false @@ -114,6 +99,19 @@ final class MenuBarItemManager: ObservableObject { // MARK: - Item Cache extension MenuBarItemManager { + /// An actor that manages menu bar item cache operations. + private final actor CacheActor { + private var cacheTask: Task? + + /// Runs the given async closure as a task and waits for it + /// to complete before returning. + func runCacheTask(_ operation: @escaping () async -> Void) async { + cacheTask?.cancel() + cacheTask = Task(operation: operation) + await cacheTask?.value + } + } + /// Cache for menu bar items. struct ItemCache: Hashable { /// All cached menu bar items, keyed by section. @@ -522,14 +520,22 @@ extension MenuBarItemManager { } } - /// The error code of this error. + /// The error code associated with the error. let code: ErrorCode - /// The error's menu bar item. + /// The menu bar item associated with the error. let item: MenuBarItem - /// The message associated with this error. - var message: String { + /// Description of the error for debugging purposes. + var description: String { + var parameters = [String]() + parameters.append("code: \(code.logString)") + parameters.append("item: \(item.logString)") + return "\(Self.self)(\(parameters.joined(separator: ", ")))" + } + + /// Description of the error for display purposes. + var errorDescription: String? { switch code { case .cannotComplete: #"Operation could not be completed for "\#(item.displayName)""# @@ -556,19 +562,6 @@ extension MenuBarItemManager { } } - /// Description of the error for debugging purposes. - var description: String { - var parameters = [String]() - parameters.append("code: \(code.logString)") - parameters.append("item: \(item.logString)") - return "\(Self.self)(\(parameters.joined(separator: ", ")))" - } - - /// Description of the error for display purposes. - var errorDescription: String? { - message - } - /// Suggestion for recovery from the error. var recoverySuggestion: String? { "Please try again. If the error persists, please file a bug report." @@ -638,46 +631,6 @@ extension MenuBarItemManager { source.localEventsSuppressionInterval = suppressionInterval } - /// Waits for a menu bar item's bounds to change in reaction to - /// a series of received events. - /// - /// - Parameters: - /// - item: The item to check for bounds changes. - /// - initialBounds: The bounds of the item before any events - /// were sent to it. - /// - timeout: The duration to wait before throwing an error. - private nonisolated func waitForResponse( - from item: MenuBarItem, - initialBounds: CGRect, - timeout: Duration - ) async throws -> CGRect { - let boundsCheckTask = Task.detached(timeout: timeout) { - while true { - try Task.checkCancellation() - let bounds = try await self.getCurrentBounds(for: item) - if bounds != initialBounds { - return bounds - } - } - } - do { - let bounds = try await boundsCheckTask.value - logger.debug( - """ - Bounds for \(item.logString, privacy: .public) changed \ - to \(NSStringFromRect(bounds), privacy: .public) - """ - ) - return bounds - } catch let error as EventError { - throw error - } catch is TaskTimeoutError { - throw EventError(code: .itemResponseTimeout, item: item) - } catch { - throw EventError(code: .cannotComplete, item: item) - } - } - /// Posts an event to the given event tap location and waits /// until it is received before returning. /// @@ -701,7 +654,7 @@ extension MenuBarItemManager { placement: .tailAppendEventTap, option: .listenOnly ) { tap, rEvent in - if rEvent.matches(event, by: CGEventField.menuBarItemEventFields) { + if rEvent.matches(event, byIntegerFields: CGEventField.menuBarItemEventFields) { tap.disable() continuation.resume() } @@ -766,11 +719,11 @@ extension MenuBarItemManager { placement: .headInsertEventTap, option: .defaultTap ) { tap, rEvent in - if rEvent.matches(entryEvent, by: [.eventSourceUserData]) { + if rEvent.matches(entryEvent, byIntegerFields: [.eventSourceUserData]) { event.post(to: secondTapLocation) return nil } - if rEvent.matches(exitEvent, by: [.eventSourceUserData]) { + if rEvent.matches(exitEvent, byIntegerFields: [.eventSourceUserData]) { tap.disable() continuation.resume() return nil @@ -787,13 +740,14 @@ extension MenuBarItemManager { placement: .tailAppendEventTap, option: .listenOnly ) { tap, rEvent in - if rEvent.matches(event, by: CGEventField.menuBarItemEventFields) { + if rEvent.matches(event, byIntegerFields: CGEventField.menuBarItemEventFields) { tap.disable() exitEvent.post(to: firstTapLocation) } return rEvent } + // Keep the taps alive. eventTaps.append(eventTap1) eventTaps.append(eventTap2) @@ -818,38 +772,6 @@ extension MenuBarItemManager { throw EventError(code: .cannotComplete, item: item) } } - -// /// Does a lot of weird magic to make a menu bar item receive an event, -// /// then waits for the item to respond. -// /// -// /// - Parameters: -// /// - event: The event to post. -// /// - firstTapLocation: The first event tap location to post the event. -// /// - secondTapLocation: The second event tap location to post the event. -// /// - item: The menu bar item that the event targets. -// /// - timeout: The duration for individual operations to wait before -// /// throwing an error. -// private nonisolated func scrombleEvent( -// _ event: CGEvent, -// from firstTapLocation: EventTap.Location, -// to secondTapLocation: EventTap.Location, -// waitingForResponseFrom item: MenuBarItem, -// timeout: Duration -// ) async throws { -// let initialBounds = try await getCurrentBounds(for: item) -// try await self.scrombleEvent( -// event, -// from: firstTapLocation, -// to: secondTapLocation, -// item: item, -// timeout: timeout -// ) -// try await self.waitForResponse( -// from: item, -// initialBounds: initialBounds, -// timeout: timeout -// ) -// } } // MARK: - Move Operations @@ -878,18 +800,38 @@ extension MenuBarItemManager { } } - /// Returns the point for moving an item to the given destination. - private nonisolated func getTargetPoint(for destination: MoveDestination) async throws -> CGPoint { - let bounds = try await getCurrentBounds(for: destination.targetItem) - return switch destination { - case .leftOfItem: CGPoint(x: bounds.minX, y: bounds.minY) - case .rightOfItem: CGPoint(x: bounds.maxX, y: bounds.minY) + /// Returns the target points for creating the events needed to move + /// a menu bar item to the given destination. + private nonisolated func getTargetPoints( + forMoving item: MenuBarItem, + to destination: MoveDestination + ) async throws -> (start: CGPoint, end: CGPoint) { + let itemBounds = try await getCurrentBounds(for: item) + let targetBounds = try await getCurrentBounds(for: destination.targetItem) + switch destination { + case .leftOfItem: + let start = CGPoint(x: targetBounds.minX, y: targetBounds.minY) + var end = start + if itemBounds.maxX <= targetBounds.minX { + end.x -= itemBounds.width + } + return (start, end) + case .rightOfItem: + let start = CGPoint(x: targetBounds.maxX, y: targetBounds.minY) + var end = start + if itemBounds.minX <= targetBounds.maxX { + end.x -= itemBounds.width + } + return (start, end) } } - /// Returns a Boolean value that indicates whether the given item is - /// in the correct position for the given destination. - private nonisolated func itemHasCorrectPosition(item: MenuBarItem, for destination: MoveDestination) async throws -> Bool { + /// Returns a Boolean value that indicates whether the given menu bar + /// item has the correct position, relative to the given destination. + private nonisolated func itemHasCorrectPosition( + item: MenuBarItem, + for destination: MoveDestination + ) async throws -> Bool { let itemBounds = try await getCurrentBounds(for: item) let targetBounds = try await getCurrentBounds(for: destination.targetItem) return switch destination { @@ -898,35 +840,47 @@ extension MenuBarItemManager { } } - private nonisolated func getPreflightEndPoint(beforeMoving item: MenuBarItem, to destination: MoveDestination) async throws -> CGPoint { - let itemBounds = try await getCurrentBounds(for: item) - let targetBounds = try await getCurrentBounds(for: destination.targetItem) - if itemBounds.maxX <= targetBounds.minX { - switch destination { - case .leftOfItem: - return CGPoint(x: targetBounds.minX - itemBounds.width, y: itemBounds.minY) - case .rightOfItem: - return CGPoint(x: itemBounds.minX + targetBounds.width, y: itemBounds.minY) - } - } else { - switch destination { - case .leftOfItem: - return CGPoint(x: targetBounds.minX, y: itemBounds.minY) - case .rightOfItem: - return CGPoint(x: targetBounds.maxX, y: itemBounds.minY) + /// Waits for a menu bar item's bounds to change in response to + /// a series of posted events. + /// + /// - Parameters: + /// - item: The item to check for bounds changes. + /// - initialBounds: The bounds of the item before the events were posted. + /// - timeout: The duration to wait before throwing an error. + private nonisolated func waitForResponse( + from item: MenuBarItem, + initialBounds: CGRect, + timeout: Duration + ) async throws -> CGRect { + let boundsCheckTask = Task.detached(timeout: timeout) { + while true { + try Task.checkCancellation() + let bounds = try await self.getCurrentBounds(for: item) + if bounds != initialBounds { + return bounds + } } } - } - - private nonisolated func validatePosition(afterMoving item: MenuBarItem, preflightPoint: CGPoint) async throws { - let itemBounds = try await getCurrentBounds(for: item) - if itemBounds.origin.distance(to: preflightPoint) > 1 { - throw EventError(code: .incorrectPositionAfterMove, item: item) + do { + let bounds = try await boundsCheckTask.value + logger.debug( + """ + Bounds for \(item.logString, privacy: .public) changed \ + to \(NSStringFromRect(bounds), privacy: .public) + """ + ) + return bounds + } catch let error as EventError { + throw error + } catch is TaskTimeoutError { + throw EventError(code: .itemResponseTimeout, item: item) + } catch { + throw EventError(code: .cannotComplete, item: item) } } - /// Creates and posts a series of events to move a menu bar item to - /// the given destination. + /// Creates and posts a series of events to move a menu bar item + /// to the given destination. /// /// - Parameters: /// - item: The menu bar item to move. @@ -941,28 +895,28 @@ extension MenuBarItemManager { timeout: Duration ) async throws { var itemBounds = try await getCurrentBounds(for: item) - let targetPoint = try await getTargetPoint(for: destination) + let targetPoints = try await getTargetPoints(forMoving: item, to: destination) let pid = getEventPID(for: item) guard let moveEvent1 = CGEvent.menuBarItemEvent( source: source, type: .move(.mouseDown), - location: targetPoint, + location: targetPoints.start, item: item, pid: pid ), let moveEvent2 = CGEvent.menuBarItemEvent( source: source, type: .move(.mouseUp), - location: targetPoint, + location: targetPoints.end, item: destination.targetItem, pid: pid ), let fallbackEvent = CGEvent.menuBarItemEvent( source: source, type: .move(.mouseUp), - location: targetPoint, + location: targetPoints.end, item: item, pid: pid ) @@ -1009,15 +963,22 @@ extension MenuBarItemManager { try await group.next() group.cancelAll() } + try await self.scrombleEvent( + moveEvent2, + from: .pid(pid), + to: .sessionEventTap, + item: item, + timeout: timeout + ) } catch { logger.warning("Move events failed. Posting fallback.") - // Pad with eventSleep calls to reduce the chance that - // events are still being processed somewhere. + // Pad with eventSleep calls to reduce the chance that events are + // still being processed somewhere. await eventSleep() do { - // Catch this for logging purposes only. We want to - // propagate the original error. + // Catch this for logging purposes only. We want to propagate + // the original error. try await postEventRoundtrip( fallbackEvent, to: .sessionEventTap, @@ -1028,58 +989,8 @@ extension MenuBarItemManager { logger.error("Fallback event failed with error: \(error, privacy: .public)") } await eventSleep() - throw error - } - } - - /// Moves a menu bar item to the given destination. - /// - /// - Parameters: - /// - item: The menu bar item to move. - /// - destination: The destination to move the menu bar item. - /// - source: The event source used to create the events that move - /// the item. - /// - timeout: The duration for each individual operation to wait - /// before throwing an error. - private func performMoveOperation( - item: MenuBarItem, - destination: MoveDestination, - source: CGEventSource, - timeout: Duration - ) async throws { - let preflightPoint = try await getPreflightEndPoint(beforeMoving: item, to: destination) - let mouseLocation = try getMouseLocation(item: item) - - // Move operations can occasionally fail. Retry up to a total - // of 5 attempts, throwing the last attempt's error if it fails. - for n in 1...5 { - try Task.checkCancellation() - do { - MouseHelpers.hideCursor() - defer { - MouseHelpers.warpCursor(to: mouseLocation) - MouseHelpers.showCursor() - } - - try await postMoveEvents( - item: item, - destination: destination, - source: source, - timeout: timeout - ) - return try await validatePosition( - afterMoving: item, - preflightPoint: preflightPoint - ) - } catch where n < 5 { - logger.debug( - """ - Move attempt \(n, privacy: .public) failed with error: \ - \(error, privacy: .public) - """ - ) - } + throw error } } @@ -1088,10 +999,8 @@ extension MenuBarItemManager { /// /// - Parameters: /// - item: The menu bar item to move. - /// - destination: The destination to move the menu bar item. - /// - timeout: The duration for each individual operation to wait - /// before throwing an error. - func move(item: MenuBarItem, to destination: MoveDestination, timeout: Duration = .milliseconds(50)) async throws { + /// - destination: The destination to move the item to. + func move(item: MenuBarItem, to destination: MoveDestination) async throws { guard item.isMovable else { throw EventError(code: .itemNotMovable, item: item) } @@ -1107,14 +1016,14 @@ extension MenuBarItemManager { do { // FIXME: Running these checks sequentially is prone to error. // - // Say, for example, the user is holding down a modifier key while - // dragging their mouse. It's reasonable that they could finish the - // drag and start a new one, all while still holding the modifier. - // Since the mouse movement and button checks would have finished at - // the end of the first drag, we would completely miss this. We'd - // have the same problem running the checks concurrently. + // Example: The user is holding a modifier key and dragging + // their mouse. It's reasonable that they could finish the drag + // and start a new one while still holding the modifier. This + // would cause both mouse checks to finish, causing the modifier + // check to be the only one still active. // // We need a way to cooperatively restart each check as needed. + // Task groups might be ideal for this. try await waitForMouseToStopMoving() try await waitForAllMouseButtonsUp() try await waitForAllModifierKeysUp() @@ -1122,8 +1031,6 @@ extension MenuBarItemManager { throw EventError(code: .cannotComplete, item: item) } - let source = try getEventSource(for: item) - try permitAllEvents( for: .combinedSessionState, during: [ @@ -1139,6 +1046,11 @@ extension MenuBarItemManager { appState.eventManager.startAll() } + let source = try getEventSource(for: item) + let mouseLocation = try getMouseLocation(item: item) + let timeout = Duration.milliseconds(50) + let maxAttempts = 10 + logger.debug( """ Moving \(item.logString, privacy: .public) to \ @@ -1146,36 +1058,35 @@ extension MenuBarItemManager { """ ) - let moveTask = Task { - // Move operations can occasionally fail. Retry up to a total - // of 5 attempts, throwing the last attempt's error if it fails. - for n in 1...5 { - try Task.checkCancellation() - do { - return try await performMoveOperation( - item: item, - destination: destination, - source: source, - timeout: timeout - ) - } catch where n < 5 { - logger.warning( - """ - Move attempt \(n, privacy: .public) failed with error: \ - \(error, privacy: .public) - """ - ) + for n in 1...maxAttempts { + guard !Task.isCancelled else { + throw EventError(code: .cannotComplete, item: item) + } + + do { + MouseHelpers.hideCursor() + defer { + MouseHelpers.warpCursor(to: mouseLocation) + MouseHelpers.showCursor() } + + try await postMoveEvents( + item: item, + destination: destination, + source: source, + timeout: timeout + ) + } catch where n < maxAttempts { + logger.debug("Attempt \(n, privacy: .public) failed: \(error, privacy: .public)") + continue + } catch let error as EventError { + throw error + } catch { + throw EventError(code: .cannotComplete, item: item) } - } - do { - try await moveTask.value - logger.debug("Successfully moved item") - } catch let error as EventError { - throw error - } catch { - throw EventError(code: .cannotComplete, item: item) + logger.debug("Attempt \(n, privacy: .public) succeeded") + return } } } @@ -1183,23 +1094,17 @@ extension MenuBarItemManager { // MARK: - Click Operations extension MenuBarItemManager { - /// Clicks the given menu bar item. + /// Clicks a menu bar item with the given mouse button. /// /// - Parameters: /// - item: The menu bar item to click. /// - mouseButton: The mouse button to click the item with. - /// - timeout: The duration to wait before throwing an error. - func click( - item: MenuBarItem, - with mouseButton: CGMouseButton, - timeout: Duration = .milliseconds(250) - ) async throws { + func click(item: MenuBarItem, with mouseButton: CGMouseButton) async throws { guard let appState else { throw EventError(code: .cannotComplete, item: item) } let source = try getEventSource(for: item) - let mouseLocation = try getMouseLocation(item: item) let itemBounds = try await getCurrentBounds(for: item) let pid = getEventPID(for: item) @@ -1247,12 +1152,8 @@ extension MenuBarItemManager { appState.eventManager.startAll() } - MouseHelpers.hideCursor() - - defer { - MouseHelpers.warpCursor(to: mouseLocation) - MouseHelpers.showCursor() - } + let mouseLocation = try getMouseLocation(item: item) + let timeout = Duration.milliseconds(250) logger.debug( """ @@ -1262,6 +1163,12 @@ extension MenuBarItemManager { ) do { + MouseHelpers.hideCursor() + defer { + MouseHelpers.warpCursor(to: mouseLocation) + MouseHelpers.showCursor() + } + try await scrombleEvent( clickEvent1, from: .pid(pid), @@ -1276,16 +1183,15 @@ extension MenuBarItemManager { item: item, timeout: timeout ) - logger.debug("Successfully clicked item") } catch { logger.warning("Click events failed. Posting fallback.") - // Pad with eventSleep calls to reduce the chance that - // events are still being processed somewhere. + // Pad with eventSleep calls to reduce the chance that events are + // still being processed somewhere. await eventSleep() do { - // Catch this for logging purposes only. We want to - // propagate the original error. + // Catch this for logging purposes only. We want to propagate + // the original error. try await postEventRoundtrip( fallbackEvent, to: .sessionEventTap, @@ -1296,8 +1202,11 @@ extension MenuBarItemManager { logger.error("Fallback event failed with error: \(error, privacy: .public)") } await eventSleep() + throw error } + + logger.debug("Successfully clicked \(item.logString, privacy: .public)") } } @@ -1579,7 +1488,7 @@ extension MenuBarItemManager { } } -// MARK: - Helper Types +// MARK: - Event Helpers /// Mouse states for menu bar item move events. private enum MenuBarItemMoveEventMouseState { @@ -1776,16 +1685,16 @@ private extension CGEvent { return event } - /// Returns a Boolean value that indicates whether the given fields on - /// this event are equivalent to the same fields on the given event. + /// Returns a Boolean value that indicates whether the given integer + /// fields on this event are equivalent to the same integer fields on + /// the given event. /// /// - Parameters: /// - other: The event to compare with this event. - /// - fields: The fields to check. - func matches(_ other: CGEvent, by fields: [CGEventField]) -> Bool { + /// - fields: The integer fields to check. + func matches(_ other: CGEvent, byIntegerFields fields: [CGEventField]) -> Bool { fields.allSatisfy { field in - getIntegerValueField(field) == other.getIntegerValueField(field) && - getDoubleValueField(field) == other.getDoubleValueField(field) + getIntegerValueField(field) == other.getIntegerValueField(field) } } @@ -1824,10 +1733,8 @@ private extension CGEvent { private func setWindowID(_ windowID: CGWindowID, for type: MenuBarItemEventType) { let windowID = Int64(windowID) - if case .click = type { - setIntegerValueField(.mouseEventWindowUnderMousePointer, value: windowID) - setIntegerValueField(.mouseEventWindowUnderMousePointerThatCanHandleThisEvent, value: windowID) - } + setIntegerValueField(.mouseEventWindowUnderMousePointer, value: windowID) + setIntegerValueField(.mouseEventWindowUnderMousePointerThatCanHandleThisEvent, value: windowID) if case .move = type { setIntegerValueField(.windowID, value: windowID) @@ -1842,6 +1749,8 @@ private extension CGEvent { } } +// MARK: - Logger + private extension Logger { /// Logger for the menu bar item manager. static let menuBarItemManager = Logger(category: "MenuBarItemManager") diff --git a/Ice/MenuBar/Search/MenuBarSearchPanel.swift b/Ice/MenuBar/Search/MenuBarSearchPanel.swift index e7ce1693d..34df36121 100644 --- a/Ice/MenuBar/Search/MenuBarSearchPanel.swift +++ b/Ice/MenuBar/Search/MenuBarSearchPanel.swift @@ -108,7 +108,7 @@ final class MenuBarSearchPanel: NSPanel { } guard let screen = screen ?? defaultScreen else { - Logger.general.error("Missing screen for search panel") + Logger.default.error("Missing screen for search panel") return } diff --git a/Ice/UserNotifications/UserNotificationManager.swift b/Ice/UserNotifications/UserNotificationManager.swift index 56c4b3d1d..948d74ab2 100644 --- a/Ice/UserNotifications/UserNotificationManager.swift +++ b/Ice/UserNotifications/UserNotificationManager.swift @@ -27,7 +27,7 @@ final class UserNotificationManager: NSObject { do { try await notificationCenter.requestAuthorization(options: [.badge, .alert, .sound]) } catch { - Logger.general.error("Failed to request notification authorization: \(error)") + Logger.default.error("Failed to request notification authorization: \(error)") } } } diff --git a/Ice/Utilities/Helpers.swift b/Ice/Utilities/Helpers.swift index d3eb3deb8..43de07693 100644 --- a/Ice/Utilities/Helpers.swift +++ b/Ice/Utilities/Helpers.swift @@ -3,28 +3,6 @@ // Ice // -// MARK: - Update - -/// Updates the given value in place using a closure. -/// -/// Use this function to group multiple updates under one mutation. -func update( - _ value: inout Value, - _ body: (inout Value) throws(E) -> Void -) throws(E) { - try body(&value) -} - -/// Updates the given value in place using a closure. -/// -/// Use this function to group multiple updates under one mutation. -func update( - _ value: inout Value, - _ body: (inout Value) async throws(E) -> Void -) async throws(E) { - try await body(&value) -} - // MARK: - With Mutable Copy /// Invokes the given closure with a mutable copy of the given value. diff --git a/Ice/Utilities/MouseHelpers.swift b/Ice/Utilities/MouseHelpers.swift index 004d283c0..46fdb171d 100644 --- a/Ice/Utilities/MouseHelpers.swift +++ b/Ice/Utilities/MouseHelpers.swift @@ -26,7 +26,7 @@ enum MouseHelpers { static func hideCursor() { let result = CGDisplayHideCursor(CGMainDisplayID()) if result != .success { - Logger.general.error("CGDisplayHideCursor failed with error \(result.logString, privacy: .public)") + Logger.default.error("CGDisplayHideCursor failed with error \(result.logString, privacy: .public)") } } @@ -35,7 +35,7 @@ enum MouseHelpers { static func showCursor() { let result = CGDisplayShowCursor(CGMainDisplayID()) if result != .success { - Logger.general.error("CGDisplayShowCursor failed with error \(result.logString, privacy: .public)") + Logger.default.error("CGDisplayShowCursor failed with error \(result.logString, privacy: .public)") } } @@ -47,7 +47,7 @@ enum MouseHelpers { static func warpCursor(to point: CGPoint) { let result = CGWarpMouseCursorPosition(point) if result != .success { - Logger.general.error("CGWarpMouseCursorPosition failed with error \(result.logString, privacy: .public)") + Logger.default.error("CGWarpMouseCursorPosition failed with error \(result.logString, privacy: .public)") } } @@ -58,7 +58,7 @@ enum MouseHelpers { static func associateMouseAndCursor(_ connected: Bool) { let result = CGAssociateMouseAndMouseCursorPosition(connected ? 1 : 0) if result != .success { - Logger.general.error("CGAssociateMouseAndMouseCursorPosition failed with error \(result.logString, privacy: .public)") + Logger.default.error("CGAssociateMouseAndMouseCursorPosition failed with error \(result.logString, privacy: .public)") } } diff --git a/MenuBarItemService/Listener.swift b/MenuBarItemService/Listener.swift index 8dc6a9601..15f100e4b 100644 --- a/MenuBarItemService/Listener.swift +++ b/MenuBarItemService/Listener.swift @@ -30,14 +30,14 @@ final class Listener { let request = try message.decode(as: MenuBarItemService.Request.self) switch request { case .start: - Logger.general.debug("Listener received start request") + Logger.default.debug("Listener received start request") return .start case .sourcePID(let window): let pid = SourcePIDCache.shared.pid(for: window) return .sourcePID(pid) } } catch { - Logger.general.error("Listener failed to handle message with error \(error)") + Logger.default.error("Listener failed to handle message with error \(error)") return nil } } @@ -66,11 +66,11 @@ final class Listener { /// Activates the listener. func activate() { guard listener == nil else { - Logger.general.notice("Listener is already active") + Logger.default.notice("Listener is already active") return } - Logger.general.debug("Activating listener") + Logger.default.debug("Activating listener") do { if #available(macOS 26.0, *) { @@ -79,13 +79,13 @@ final class Listener { try uncheckedActivate() } } catch { - Logger.general.error("Failed to activate listener with error \(error)") + Logger.default.error("Failed to activate listener with error \(error)") } } /// Cancels the listener. func cancel() { - Logger.general.debug("Canceling listener") + Logger.default.debug("Canceling listener") listener.take()?.cancel() } } diff --git a/MenuBarItemService/SourcePIDCache.swift b/MenuBarItemService/SourcePIDCache.swift index e1d7b7948..6aa272ce8 100644 --- a/MenuBarItemService/SourcePIDCache.swift +++ b/MenuBarItemService/SourcePIDCache.swift @@ -169,7 +169,7 @@ final class SourcePIDCache { return } - Logger.general.debug("Received new running applications") + Logger.default.debug("Received new running applications") let windowIDs = Bridging.getMenuBarWindowList(option: .itemsOnly) @@ -212,7 +212,7 @@ final class SourcePIDCache { /// Starts the observers for the cache. func start() { - Logger.general.debug("Starting observers for source PID cache") + Logger.default.debug("Starting observers for source PID cache") _ = cancellable } diff --git a/Shared/Bridging/Bridging.swift b/Shared/Bridging/Bridging.swift index 922614c38..1baed257c 100644 --- a/Shared/Bridging/Bridging.swift +++ b/Shared/Bridging/Bridging.swift @@ -8,24 +8,42 @@ import OSLog // MARK: - Bridging -/// A namespace for bridged APIs. +/// A namespace for bridged or wrapped APIs. enum Bridging { - private static let mainConnectionID = CGSMainConnectionID() - private static let nullConnectionID: CGSConnectionID = 0 private static let logger = Logger(category: "Bridging") } // MARK: - CGSConnection extension Bridging { - /// Returns the value for a property in the app's window server connection. + + // MARK: Private Connection Helpers + + /// The identifier for the `null` window server connection. + private static let nullConnection: CGSConnectionID = 0 + + /// Returns the identifier for the main window server connection. + private static func getMainConnection() -> CGSConnectionID { + CGSMainConnectionID() + } + + /// Returns the identifier for the window server connection + /// for the current thread. + private static func getConnectionForThread() -> CGSConnectionID { + CGSDefaultConnectionForThread() + } + + // MARK: Public Connection API + + /// Returns a value from the main window server connection. /// - /// - Parameter key: A key for a property in the app's window server connection. + /// - Parameter key: A key associated with a value in the main + /// window server connection. static func getConnectionProperty(forKey key: String) -> Any? { var value: Unmanaged? let result = CGSCopyConnectionProperty( - mainConnectionID, - mainConnectionID, + getMainConnection(), + getMainConnection(), key as CFString, &value ) @@ -35,15 +53,16 @@ extension Bridging { return value?.takeRetainedValue() } - /// Sets the value for a property in the app's window server connection. + /// Sets a value in the main window server connection. /// /// - Parameters: - /// - value: A value to set for `key`. - /// - key: A key for a property in the app's window server connection. + /// - value: A value to set. + /// - key: A key to associate with `value` as a property in the + /// main window server connection. static func setConnectionProperty(_ value: Any?, forKey key: String) { let result = CGSSetConnectionProperty( - mainConnectionID, - mainConnectionID, + getMainConnection(), + getMainConnection(), key as CFString, value as CFTypeRef ) @@ -53,7 +72,7 @@ extension Bridging { } } -// MARK: - Display +// MARK: - CGDisplay / CGSDisplay extension Bridging { @@ -94,7 +113,7 @@ extension Bridging { /// Returns the identifier of the display with the active menu bar. static func getActiveMenuBarDisplayID() -> CGDirectDisplayID? { - guard let string = CGSCopyActiveMenuBarDisplayIdentifier(mainConnectionID) else { + guard let string = CGSCopyActiveMenuBarDisplayIdentifier(getMainConnection()) else { logger.error("CGSCopyActiveMenuBarDisplayIdentifier returned nil") return nil } @@ -111,8 +130,8 @@ extension Bridging { // MARK: - CGSEvent extension Bridging { - /// Returns a Boolean value indicating whether the given process is - /// unresponsive. + /// Returns a Boolean value indicating whether the given process + /// is unresponsive. /// /// - Parameter pid: An identifier for a process. static func isProcessUnresponsive(_ pid: pid_t) -> Bool { @@ -122,14 +141,14 @@ extension Bridging { logger.error("GetProcessForPID failed with error \(result, privacy: .public)") return false } - return CGSEventIsAppUnresponsive(mainConnectionID, &psn) + return CGSEventIsAppUnresponsive(getMainConnection(), &psn) } /// Sets the timeout used to determine if a process is unresponsive. /// /// - Parameter timeout: An amount of time in seconds. static func setProcessUnresponsiveTimeout(_ timeout: TimeInterval) { - let result = CGSEventSetAppIsUnresponsiveNotificationTimeout(mainConnectionID, timeout) + let result = CGSEventSetAppIsUnresponsiveNotificationTimeout(getMainConnection(), timeout) if result != .success { logger.error("CGSEventSetAppIsUnresponsiveNotificationTimeout failed with error \(result.logString, privacy: .public)") } @@ -141,10 +160,11 @@ extension Bridging { extension Bridging { /// Returns the identifier for the active space. static func getActiveSpaceID() -> CGSSpaceID { - CGSGetActiveSpace(mainConnectionID) + CGSGetActiveSpace(getMainConnection()) } - /// Returns the identifier for the current space on the given display. + /// Returns the identifier for the current space on the given + /// display. /// /// - Parameter displayID: An identifier for a display. static func getCurrentSpaceID(for displayID: CGDirectDisplayID) -> CGSSpaceID? { @@ -155,7 +175,7 @@ extension Bridging { logger.error("CFUUIDCreateString returned nil for display \(displayID, privacy: .public)") return nil } - return CGSManagedDisplayGetCurrentSpace(mainConnectionID, uuidString) + return CGSManagedDisplayGetCurrentSpace(getMainConnection(), uuidString) } /// Returns a list of identifiers for the spaces that contain the @@ -165,10 +185,9 @@ extension Bridging { /// - windowID: An identifier for a window. /// - visibleSpacesOnly: A Boolean value that determines whether /// the returned list should only include visible spaces. - /// The default value is `false`. static func getSpaceList(for windowID: CGWindowID, visibleSpacesOnly: Bool = false) -> [CGSSpaceID] { let mask: CGSSpaceMask = visibleSpacesOnly ? .allVisibleSpacesMask : .allSpacesMask - guard let spaces = CGSCopySpacesForWindows(mainConnectionID, mask, [windowID] as CFArray) else { + guard let spaces = CGSCopySpacesForWindows(getMainConnection(), mask, [windowID] as CFArray) else { logger.error("CGSCopySpacesForWindows returned nil") return [] } @@ -184,7 +203,7 @@ extension Bridging { /// /// - Parameter spaceID: An identifier for a space. static func isSpaceFullscreen(_ spaceID: CGSSpaceID) -> Bool { - let type = CGSSpaceGetType(mainConnectionID, spaceID) + let type = CGSSpaceGetType(getMainConnection(), spaceID) return type == .fullscreen } } @@ -195,9 +214,9 @@ extension Bridging { /// Returns the bounds for the given window. /// /// - Parameter windowID: An identifier for a window. - static nonisolated func getWindowBounds(for windowID: CGWindowID) -> CGRect? { + static func getWindowBounds(for windowID: CGWindowID) -> CGRect? { var bounds = CGRect.zero - let result = CGSGetScreenRectForWindow(CGSDefaultConnectionForThread(), windowID, &bounds) + let result = CGSGetScreenRectForWindow(getConnectionForThread(), windowID, &bounds) guard result == .success else { logger.error("CGSGetScreenRectForWindow failed with error \(result.logString, privacy: .public)") return nil @@ -210,7 +229,7 @@ extension Bridging { /// - Parameter windowID: An identifier for a window. static func getWindowLevel(for windowID: CGWindowID) -> CGWindowLevel? { var level: CGWindowLevel = 0 - let result = CGSGetWindowLevel(mainConnectionID, windowID, &level) + let result = CGSGetWindowLevel(getMainConnection(), windowID, &level) guard result == .success else { logger.error("CGSGetWindowLevel failed with error \(result.logString, privacy: .public)") return nil @@ -257,7 +276,7 @@ extension Bridging { private static func getWindowCount() -> Int32? { var count: Int32 = 0 - let result = CGSGetWindowCount(mainConnectionID, nullConnectionID, &count) + let result = CGSGetWindowCount(getMainConnection(), nullConnection, &count) guard result == .success else { logger.error("CGSGetWindowCount failed with error \(result.logString, privacy: .public)") return nil @@ -267,7 +286,7 @@ extension Bridging { private static func getOnScreenWindowCount() -> Int32? { var count: Int32 = 0 - let result = CGSGetOnScreenWindowCount(mainConnectionID, nullConnectionID, &count) + let result = CGSGetOnScreenWindowCount(getMainConnection(), nullConnection, &count) guard result == .success else { logger.error("CGSGetOnScreenWindowCount failed with error \(result.logString, privacy: .public)") return nil @@ -280,7 +299,7 @@ extension Bridging { return [] } var list = [CGWindowID](repeating: 0, count: Int(count)) - let result = CGSGetWindowList(mainConnectionID, nullConnectionID, count, &list, &count) + let result = CGSGetWindowList(getMainConnection(), nullConnection, count, &list, &count) guard result == .success else { logger.error("CGSGetWindowList failed with error \(result.logString, privacy: .public)") return [] @@ -293,7 +312,7 @@ extension Bridging { return [] } var list = [CGWindowID](repeating: 0, count: Int(count)) - let result = CGSGetOnScreenWindowList(mainConnectionID, nullConnectionID, count, &list, &count) + let result = CGSGetOnScreenWindowList(getMainConnection(), nullConnection, count, &list, &count) guard result == .success else { logger.error("CGSGetOnScreenWindowList failed with error \(result.logString, privacy: .public)") return [] @@ -306,7 +325,7 @@ extension Bridging { return [] } var list = [CGWindowID](repeating: 0, count: Int(count)) - let result = CGSGetProcessMenuBarWindowList(mainConnectionID, nullConnectionID, count, &list, &count) + let result = CGSGetProcessMenuBarWindowList(getMainConnection(), nullConnection, count, &list, &count) guard result == .success else { logger.error("CGSGetProcessMenuBarWindowList failed with error \(result.logString, privacy: .public)") return [] @@ -395,7 +414,7 @@ extension Bridging { } } - // MARK: - CGWindowList Specific + // MARK: - CGWindowList Helpers /// Creates a `CFArray` containing the bit patterns of the given /// window list. diff --git a/Shared/Utilities/Logging.swift b/Shared/Utilities/Logging.swift index efb88ef54..8a33b8c6a 100644 --- a/Shared/Utilities/Logging.swift +++ b/Shared/Utilities/Logging.swift @@ -17,8 +17,8 @@ extension Logger { // MARK: - Shared Loggers extension Logger { - /// The general purpose logger. - static let general = Logger(category: "General") + /// The default logger. + static let `default` = Logger(.default) /// The logger for hotkey operations. static let hotkeys = Logger(category: "Hotkeys") From b0a19420e92f9c5ffff41a6af628a9f3857d5ba5 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 22 Aug 2025 09:36:56 -0600 Subject: [PATCH 50/80] Improve menu bar item handling --- Ice/Events/EventTap.swift | 88 ++- .../MenuBarItems/MenuBarItemManager.swift | 618 +++++++----------- Ice/Utilities/Extensions.swift | 9 + Ice/Utilities/MouseHelpers.swift | 13 +- 4 files changed, 298 insertions(+), 430 deletions(-) diff --git a/Ice/Events/EventTap.swift b/Ice/Events/EventTap.swift index eddca8bab..4c51e40d6 100644 --- a/Ice/Events/EventTap.swift +++ b/Ice/Events/EventTap.swift @@ -48,15 +48,14 @@ final class EventTap { return Unmanaged.passUnretained(event) } let tap: EventTap = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - let retained = Unmanaged.passRetained(tap) if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { - retained.takeRetainedValue().enable() + tap.enable() return nil } guard tap.isEnabled else { return Unmanaged.passUnretained(event) } - return tap.callback(retained.takeRetainedValue(), event).map { eventFromCallback in + return tap.callback(tap, event).map { eventFromCallback in Unmanaged.passUnretained(eventFromCallback) } } @@ -85,13 +84,14 @@ final class EventTap { /// Creates a new event tap for the specified event types. /// - /// If the tap is an active filter, the callback can return one - /// of the following: - /// - The (possibly modified) received event to pass back to + /// If the tap is an active filter, the callback can return + /// one of the following: + /// + /// * The (possibly modified) received event to pass back to /// the event stream. - /// - A new event to pass to the event stream in place of the + /// * A new event to pass to the event stream in place of the /// received event. - /// - `nil` to remove the received event from the event stream. + /// * `nil` to remove the received event from the event stream. /// /// If the tap is a passive listener, the callback's return value /// does not affect the event stream. @@ -99,13 +99,13 @@ final class EventTap { /// - Parameters: /// - label: A string label that identifies the tap in logging /// and debugging contexts. - /// - types: The types of the events received by the tap. + /// - types: The event types monitored by the tap. /// - location: The point in the event stream to insert the tap. - /// - placement: The tap's placement relative to other active taps. + /// - placement: The tap's placement, relative to existing taps + /// at `location`. /// - option: An option that specifies whether the tap is an /// active filter or a passive listener. - /// - callback: A closure for the tap to perform when events are - /// received. + /// - callback: A closure the tap calls to handle received events. init( label: String = #function, types: [CGEventType], @@ -120,11 +120,11 @@ final class EventTap { guard let machPort = EventTap.createMachPort( - types: types, + mask: types.reduce(0) { $0 | (1 << $1.rawValue) }, location: location, - placement: placement, - option: option, - tap: self + place: placement, + options: option, + userInfo: Unmanaged.passUnretained(self).toOpaque() ), let source = CFMachPortCreateRunLoopSource(nil, machPort, 0) else { @@ -138,13 +138,14 @@ final class EventTap { /// Creates a new event tap for the specified event type. /// - /// If the tap is an active filter, the callback can return one - /// of the following: - /// - The (possibly modified) received event to pass back to + /// If the tap is an active filter, the callback can return + /// one of the following: + /// + /// * The (possibly modified) received event to pass back to /// the event stream. - /// - A new event to pass to the event stream in place of the + /// * A new event to pass to the event stream in place of the /// received event. - /// - `nil` to remove the received event from the event stream. + /// * `nil` to remove the received event from the event stream. /// /// If the tap is a passive listener, the callback's return value /// does not affect the event stream. @@ -152,13 +153,13 @@ final class EventTap { /// - Parameters: /// - label: A string label that identifies the tap in logging /// and debugging contexts. - /// - type: The type of the events received by the tap. + /// - type: The event type monitored by the tap. /// - location: The point in the event stream to insert the tap. - /// - placement: The tap's placement relative to other active taps. + /// - placement: The tap's placement, relative to existing taps + /// at `location`. /// - option: An option that specifies whether the tap is an /// active filter or a passive listener. - /// - callback: A closure for the tap to perform when events are - /// received. + /// - callback: A closure the tap calls to handle received events. convenience init( label: String = #function, type: CGEventType, @@ -187,40 +188,33 @@ final class EventTap { } } + /// Creates an event tap mach port. private static func createMachPort( - types: [CGEventType], + mask: CGEventMask, location: Location, - placement: CGEventTapPlacement, - option: CGEventTapOptions, - tap: EventTap + place: CGEventTapPlacement, + options: CGEventTapOptions, + userInfo: UnsafeMutableRawPointer ) -> CFMachPort? { - func createEventMask() -> CGEventMask { - types.reduce(0) { $0 | (1 << $1.rawValue) } - } - - func createUserInfo() -> UnsafeMutableRawPointer { - Unmanaged.passUnretained(tap).toOpaque() - } - func createMachPort(at tapLocation: CGEventTapLocation) -> CFMachPort? { CGEvent.tapCreate( tap: tapLocation, - place: placement, - options: option, - eventsOfInterest: createEventMask(), + place: place, + options: options, + eventsOfInterest: mask, callback: sharedCallback, - userInfo: createUserInfo() + userInfo: userInfo ) } func createMachPort(for pid: pid_t) -> CFMachPort? { CGEvent.tapCreateForPid( pid: pid, - place: placement, - options: option, - eventsOfInterest: createEventMask(), + place: place, + options: options, + eventsOfInterest: mask, callback: sharedCallback, - userInfo: createUserInfo() + userInfo: userInfo ) } @@ -236,14 +230,14 @@ final class EventTap { } } - /// Enables the event tap. + /// Enables the tap. func enable() { guard let source, let machPort else { return } CGEvent.tapEnable(tap: machPort, enable: true) CFRunLoopAddSource(runLoop, source, .commonModes) } - /// Disables the event tap. + /// Disables the tap. func disable() { guard let source, let machPort else { return } CFRunLoopRemoveSource(runLoop, source, .commonModes) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index 106a01e64..98b3cc7d4 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -85,15 +85,6 @@ final class MenuBarItemManager: ObservableObject { } return timestamp.duration(to: .now) <= duration } - - /// Returns a duration derived from the refresh rate of the given screen. - /// - /// We use this method to avoid tight loops where traditional observation - /// isn't supported (sometimes the case with private APIs). Should really - /// only be used after exhausting all other options. - private func getSleepDurationFromScreenRefreshRate(screen: NSScreen) -> Duration { - Duration.seconds(screen.maximumRefreshInterval.clamped(to: 0.01...0.1)) - } } // MARK: - Item Cache @@ -103,8 +94,11 @@ extension MenuBarItemManager { private final actor CacheActor { private var cacheTask: Task? - /// Runs the given async closure as a task and waits for it - /// to complete before returning. + /// Runs the given async closure as a task and waits for it to + /// complete before returning. + /// + /// If a task from a previous call to this method is currently + /// running, that task is cancelled and replaced. func runCacheTask(_ operation: @escaping () async -> Void) async { cacheTask?.cancel() cacheTask = Task(operation: operation) @@ -114,7 +108,7 @@ extension MenuBarItemManager { /// Cache for menu bar items. struct ItemCache: Hashable { - /// All cached menu bar items, keyed by section. + /// Storage for cached menu bar items, keyed by section. private var storage = [MenuBarSection.Name: [MenuBarItem]]() /// The identifier of the display with the active menu bar at the @@ -124,7 +118,10 @@ extension MenuBarItemManager { /// The cached menu bar items as an array. var managedItems: [MenuBarItem] { MenuBarSection.Name.allCases.reduce(into: []) { result, section in - result.append(contentsOf: managedItems(for: section)) + guard let items = storage[section] else { + return + } + result.append(contentsOf: items) } } @@ -182,7 +179,7 @@ extension MenuBarItemManager { if case .rightOfItem = destination { let range = self[section].startIndex...self[section].endIndex - index = (index - 1).clamped(to: range) + index = (index + 1).clamped(to: range) } self[section].insert(item, at: index) @@ -231,8 +228,13 @@ extension MenuBarItemManager { } func isValidForCaching(_ item: MenuBarItem) -> Bool { - // Filter out non-hideable items and the two separator control items. - item.canBeHidden && (!item.isControlItem || item.tag == .visibleControlItem) + if !item.canBeHidden { + return false + } + if item.isControlItem, item.tag != .visibleControlItem { + return false + } + return true } mutating func findSection(for item: MenuBarItem) -> MenuBarSection.Name? { @@ -261,8 +263,8 @@ extension MenuBarItemManager { /// Caches the given menu bar items, without ensuring that the control /// items are in the correct order. - private func uncheckedCacheItems(items: [MenuBarItem], context: CacheContext) { - var context = context + private func uncheckedCacheItems(items: [MenuBarItem], controlItems: ControlItemPair, displayID: CGDirectDisplayID?) { + var context = CacheContext(controlItems: controlItems, displayID: displayID) for item in items where context.isValidForCaching(item) { if item.sourcePID == nil { @@ -321,7 +323,7 @@ extension MenuBarItemManager { } await enforceControlItemOrder(controlItems: controlItems) - uncheckedCacheItems(items: items, context: CacheContext(controlItems: controlItems, displayID: displayID)) + uncheckedCacheItems(items: items, controlItems: controlItems, displayID: displayID) } } @@ -343,127 +345,39 @@ extension MenuBarItemManager { } } -// MARK: - Async Waiters +// MARK: - User Input Checks extension MenuBarItemManager { - /// An error that can occur during an asynchronous wait operation. - private enum WaitOperationError: LocalizedError { - case timeout - case missingScreenWithMouse - case other(any Error) - - var errorDescription: String? { - switch self { - case .timeout: - "Wait operation timed out" - case .missingScreenWithMouse: - "Couldn't find screen with mouse" - case .other(let error): - "Wait operation failed with error: \(error.localizedDescription)" - } - } - } - - /// Waits asynchronously for the given operation to complete. + /// Returns a Boolean value that indicates whether the user has + /// paused input for at least the given duration. /// - /// - Parameters: - /// - timeout: Amount of time to wait before throwing an error. - /// - operation: The operation to perform. - private func performWaitOperation( - timeout: Duration?, - @_inheritActorContext @_implicitSelfCapture - operation: sending @escaping @isolated(any) () async throws -> Void - ) async throws { - let task = if let timeout { - Task(timeout: timeout, operation: operation) - } else { - Task(operation: operation) - } - do { - try await task.value - } catch let error as WaitOperationError { - throw error - } catch is TaskTimeoutError { - throw WaitOperationError.timeout - } catch { - throw WaitOperationError.other(error) - } + /// - Parameter duration: The duration that certain types of input + /// events must not have occured within in order to return `true`. + private nonisolated func hasUserPausedInput(for duration: Duration) -> Bool { + NSEvent.modifierFlags.isEmpty && + !MouseHelpers.lastMovementOccurred(within: duration) && + !MouseHelpers.lastScrollWheelOccurred(within: duration) && + !MouseHelpers.isButtonPressed() } - /// Waits asynchronously for the mouse to stop moving. + /// Waits asynchronously for the user to pause input. /// - /// - Parameter timeout: Amount of time to wait before throwing an error. - private func waitForMouseToStopMoving(timeout: Duration? = nil) async throws { - guard let screen = NSScreen.screenWithMouse else { - throw WaitOperationError.missingScreenWithMouse - } - let duration = getSleepDurationFromScreenRefreshRate(screen: screen) - guard MouseHelpers.lastMovementOccurred(within: duration) else { + /// - Parameter timeout: The duration to wait before throwing an error. + private nonisolated func waitForUserToPauseInput(timeout: Duration = .seconds(30)) async throws { + let duration = Duration.milliseconds(100) + if hasUserPausedInput(for: duration) { return } - try await performWaitOperation(timeout: timeout) { + let waitTask = Task(timeout: timeout) { while true { try Task.checkCancellation() - if !MouseHelpers.lastMovementOccurred(within: duration) { + if self.hasUserPausedInput(for: duration) { break } - try await Task.sleep(for: duration) - } - } - } - - /// Waits asynchronously until all mouse buttons are up. - /// - /// - Parameter timeout: Amount of time to wait before throwing an error. - private func waitForAllMouseButtonsUp(timeout: Duration? = nil) async throws { - guard MouseHelpers.isButtonPressed() else { - return - } - try await performWaitOperation(timeout: timeout) { - var cancellable: AnyCancellable? - - await withCheckedContinuation { continuation in - let mask: NSEvent.EventTypeMask = [.leftMouseUp, .rightMouseUp, .otherMouseUp] - cancellable = RunLoopLocalEventMonitor.publisher(for: mask, mode: .eventTracking) - .merge(with: EventMonitor.publish(events: mask, scope: .universal)) - .removeDuplicates() - .combineLatest(Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()) - .sink { _ in - if MouseHelpers.isButtonPressed() { - return - } - cancellable?.cancel() - continuation.resume() - } - } - } - } - - /// Waits asynchronously until all modifier keys are up. - /// - /// - Parameter timeout: Amount of time to wait before throwing an error. - private func waitForAllModifierKeysUp(timeout: Duration? = nil) async throws { - if NSEvent.modifierFlags.isEmpty { - return - } - try await performWaitOperation(timeout: timeout) { - var cancellable: AnyCancellable? - - await withCheckedContinuation { continuation in - let mask: NSEvent.EventTypeMask = .flagsChanged - cancellable = RunLoopLocalEventMonitor.publisher(for: mask, mode: .eventTracking) - .merge(with: EventMonitor.publish(events: mask, scope: .universal)) - .removeDuplicates() - .combineLatest(Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()) - .sink { _ in - guard NSEvent.modifierFlags.isEmpty else { - return - } - cancellable?.cancel() - continuation.resume() - } + try await Task.sleep(for: duration * 2) } } + try await waitTask.value } } @@ -631,72 +545,26 @@ extension MenuBarItemManager { source.localEventsSuppressionInterval = suppressionInterval } - /// Posts an event to the given event tap location and waits - /// until it is received before returning. - /// - /// - Parameters: - /// - event: The event to post. - /// - location: The event tap location to post the event to. - /// - item: The menu bar item that the event targets. - /// - timeout: The duration to wait before throwing an error. - private nonisolated func postEventRoundtrip( - _ event: CGEvent, - to location: EventTap.Location, - item: MenuBarItem, - timeout: Duration - ) async throws { - var eventTaps = [EventTap]() - let timeoutTask = Task(timeout: timeout) { - try await withCheckedThrowingContinuation { continuation in - let eventTap = EventTap( - type: event.type, - location: location, - placement: .tailAppendEventTap, - option: .listenOnly - ) { tap, rEvent in - if rEvent.matches(event, byIntegerFields: CGEventField.menuBarItemEventFields) { - tap.disable() - continuation.resume() - } - return rEvent - } - - eventTaps.append(eventTap) - - Task { - await withTaskCancellationHandler { - eventTap.enable() - event.post(to: location) - } onCancel: { - eventTap.disable() - continuation.resume(throwing: CancellationError()) - } - } - } - } - do { - try await timeoutTask.value - } catch is TaskTimeoutError { - throw EventError(code: .eventOperationTimeout, item: item) - } catch { - throw EventError(code: .cannotComplete, item: item) - } - } - /// Does a lot of weird magic to make a menu bar item receive an event. /// /// - Parameters: /// - event: The event to post. - /// - firstTapLocation: The first event tap location to post the event. - /// - secondTapLocation: The second event tap location to post the event. + /// - firstLocation: The first event tap location to post the event. + /// - secondLocation: The second event tap location to post the event. /// - item: The menu bar item that the event targets. - /// - timeout: The duration to wait before throwing an error. + /// - timeout: The base duration to wait before throwing an error. The + /// value of this parameter is multiplied by `count` to produce the + /// actual timeout duration. + /// - count: The number of times to repeat the operation. As it is + /// considerably more efficient, prefer increasing this value over + /// repeatedly calling `scrombleEvent`. private nonisolated func scrombleEvent( _ event: CGEvent, - from firstTapLocation: EventTap.Location, - to secondTapLocation: EventTap.Location, + from firstLocation: EventTap.Location, + to secondLocation: EventTap.Location, item: MenuBarItem, - timeout: Duration + timeout: Duration, + repeating count: Int = 1 ) async throws { guard let entryEvent = CGEvent.uniqueNullEvent(), @@ -705,22 +573,24 @@ extension MenuBarItemManager { throw EventError(code: .eventCreationFailure, item: item) } + var counter = count var eventTaps = [EventTap]() - let timeoutTask = Task(timeout: timeout) { + let timeoutTask = Task(timeout: timeout * count) { try await withCheckedThrowingContinuation { continuation in - // Create a tap for the entry and exit events at the first location. - // This tap is responsible for posting the actual event to the second - // location and resuming the continuation. + // Create a tap at the first location for the entry and exit events. + // On entry, decrement `counter` and forward the real event. On exit, + // resume the continuation. let eventTap1 = EventTap( label: "EventTap 1", type: .null, - location: firstTapLocation, + location: firstLocation, placement: .headInsertEventTap, option: .defaultTap ) { tap, rEvent in if rEvent.matches(entryEvent, byIntegerFields: [.eventSourceUserData]) { - event.post(to: secondTapLocation) + counter -= 1 + event.post(to: secondLocation) return nil } if rEvent.matches(exitEvent, byIntegerFields: [.eventSourceUserData]) { @@ -731,18 +601,24 @@ extension MenuBarItemManager { return rEvent } - // Create a tap for the actual event at the second location. This tap - // is responsible for posting the exit event to the first location. + // Create a tap for the real event at the second location. If `counter` + // has reached zero, post the exit event. Otherwise, repost the entry + // event to continue. let eventTap2 = EventTap( label: "EventTap 2", type: event.type, - location: secondTapLocation, + location: secondLocation, placement: .tailAppendEventTap, option: .listenOnly ) { tap, rEvent in - if rEvent.matches(event, byIntegerFields: CGEventField.menuBarItemEventFields) { + guard rEvent.matches(event, byIntegerFields: CGEventField.menuBarItemEventFields) else { + return rEvent + } + if counter <= 0 { tap.disable() - exitEvent.post(to: firstTapLocation) + exitEvent.post(to: firstLocation) + } else { + entryEvent.post(to: firstLocation) } return rEvent } @@ -755,7 +631,7 @@ extension MenuBarItemManager { await withTaskCancellationHandler { eventTap1.enable() eventTap2.enable() - entryEvent.post(to: firstTapLocation) + entryEvent.post(to: firstLocation) } onCancel: { eventTap1.disable() eventTap2.disable() @@ -774,7 +650,7 @@ extension MenuBarItemManager { } } -// MARK: - Move Operations +// MARK: - Moving Items extension MenuBarItemManager { /// Destinations for menu bar item move operations. @@ -810,17 +686,25 @@ extension MenuBarItemManager { let targetBounds = try await getCurrentBounds(for: destination.targetItem) switch destination { case .leftOfItem: - let start = CGPoint(x: targetBounds.minX, y: targetBounds.minY) + var start = CGPoint(x: targetBounds.minX, y: targetBounds.minY) var end = start if itemBounds.maxX <= targetBounds.minX { + // Direction of movement: -> end.x -= itemBounds.width + } else { + // Direction of movement: <- + start.x -= 1 } return (start, end) case .rightOfItem: - let start = CGPoint(x: targetBounds.maxX, y: targetBounds.minY) + var start = CGPoint(x: targetBounds.maxX, y: targetBounds.minY) var end = start if itemBounds.minX <= targetBounds.maxX { + // Direction of movement: -> end.x -= itemBounds.width + } else { + // Direction of movement: <- + start.x += 1 } return (start, end) } @@ -852,7 +736,7 @@ extension MenuBarItemManager { initialBounds: CGRect, timeout: Duration ) async throws -> CGRect { - let boundsCheckTask = Task.detached(timeout: timeout) { + let boundsTask = Task.detached(timeout: timeout) { while true { try Task.checkCancellation() let bounds = try await self.getCurrentBounds(for: item) @@ -862,11 +746,11 @@ extension MenuBarItemManager { } } do { - let bounds = try await boundsCheckTask.value + let bounds = try await boundsTask.value logger.debug( """ - Bounds for \(item.logString, privacy: .public) changed \ - to \(NSStringFromRect(bounds), privacy: .public) + Item responded with new bounds origin: \ + \(bounds.origin.logString, privacy: .public) """ ) return bounds @@ -912,13 +796,6 @@ extension MenuBarItemManager { location: targetPoints.end, item: destination.targetItem, pid: pid - ), - let fallbackEvent = CGEvent.menuBarItemEvent( - source: source, - type: .move(.mouseUp), - location: targetPoints.end, - item: item, - pid: pid ) else { throw EventError(code: .eventCreationFailure, item: item) @@ -929,6 +806,8 @@ extension MenuBarItemManager { } do { + logger.debug("Posting move events") + try await scrombleEvent( moveEvent1, from: .pid(pid), @@ -941,61 +820,42 @@ extension MenuBarItemManager { initialBounds: itemBounds, timeout: timeout ) - try await withThrowingTaskGroup { group in - group.addTask { - while !Task.isCancelled { - try await self.scrombleEvent( - moveEvent2, - from: .pid(pid), - to: .sessionEventTap, - item: item, - timeout: timeout - ) - } - } - group.addTask { - itemBounds = try await self.waitForResponse( - from: item, - initialBounds: itemBounds, - timeout: timeout - ) - } - try await group.next() - group.cancelAll() - } try await self.scrombleEvent( moveEvent2, from: .pid(pid), to: .sessionEventTap, item: item, + timeout: timeout, + repeating: 2 // Double mouse up prevents invalid item state. + ) + itemBounds = try await self.waitForResponse( + from: item, + initialBounds: itemBounds, timeout: timeout ) } catch { - logger.warning("Move events failed. Posting fallback.") - - // Pad with eventSleep calls to reduce the chance that events are - // still being processed somewhere. - await eventSleep() do { - // Catch this for logging purposes only. We want to propagate - // the original error. - try await postEventRoundtrip( - fallbackEvent, + logger.debug("Move events failed, posting fallback event") + + // Catch this for logging purposes only. We want to propagate the + // original error. + try await self.scrombleEvent( + moveEvent2, + from: .pid(pid), to: .sessionEventTap, item: item, - timeout: timeout + timeout: timeout, + repeating: 2 // Double mouse up prevents invalid item state. ) } catch { logger.error("Fallback event failed with error: \(error, privacy: .public)") } - await eventSleep() throw error } } - /// Moves a menu bar item to the given destination and waits until - /// the move is finished before returning. + /// Moves a menu bar item to the given destination. /// /// - Parameters: /// - item: The menu bar item to move. @@ -1008,25 +868,8 @@ extension MenuBarItemManager { throw EventError(code: .cannotComplete, item: item) } - guard try await !itemHasCorrectPosition(item: item, for: destination) else { - logger.debug("\(item.logString, privacy: .public) already has correct position") - return - } - do { - // FIXME: Running these checks sequentially is prone to error. - // - // Example: The user is holding a modifier key and dragging - // their mouse. It's reasonable that they could finish the drag - // and start a new one while still holding the modifier. This - // would cause both mouse checks to finish, causing the modifier - // check to be the only one still active. - // - // We need a way to cooperatively restart each check as needed. - // Task groups might be ideal for this. - try await waitForMouseToStopMoving() - try await waitForAllMouseButtonsUp() - try await waitForAllModifierKeysUp() + try await waitForUserToPauseInput() } catch { throw EventError(code: .cannotComplete, item: item) } @@ -1048,10 +891,16 @@ extension MenuBarItemManager { let source = try getEventSource(for: item) let mouseLocation = try getMouseLocation(item: item) - let timeout = Duration.milliseconds(50) + let timeout = Duration.milliseconds(25) let maxAttempts = 10 - logger.debug( + MouseHelpers.hideCursor() + defer { + MouseHelpers.warpCursor(to: mouseLocation) + MouseHelpers.showCursor() + } + + logger.log( """ Moving \(item.logString, privacy: .public) to \ \(destination.logString, privacy: .public) @@ -1063,13 +912,11 @@ extension MenuBarItemManager { throw EventError(code: .cannotComplete, item: item) } - do { - MouseHelpers.hideCursor() - defer { - MouseHelpers.warpCursor(to: mouseLocation) - MouseHelpers.showCursor() + attempt: do { + guard try await !itemHasCorrectPosition(item: item, for: destination) else { + logger.debug("Item has correct position") + break attempt } - try await postMoveEvents( item: item, destination: destination, @@ -1078,6 +925,7 @@ extension MenuBarItemManager { ) } catch where n < maxAttempts { logger.debug("Attempt \(n, privacy: .public) failed: \(error, privacy: .public)") + await eventSleep() continue } catch let error as EventError { throw error @@ -1086,12 +934,14 @@ extension MenuBarItemManager { } logger.debug("Attempt \(n, privacy: .public) succeeded") - return + break } + + logger.log("Successfully moved \(item.logString, privacy: .public)") } } -// MARK: - Click Operations +// MARK: - Clicking Items extension MenuBarItemManager { /// Clicks a menu bar item with the given mouse button. @@ -1104,31 +954,30 @@ extension MenuBarItemManager { throw EventError(code: .cannotComplete, item: item) } + do { + try await waitForUserToPauseInput() + } catch { + throw EventError(code: .cannotComplete, item: item) + } + let source = try getEventSource(for: item) let itemBounds = try await getCurrentBounds(for: item) let pid = getEventPID(for: item) - let mouseStates = mouseButton.mouseStates + let clickTypes = mouseButton.clickTypes let clickPoint = itemBounds.center guard let clickEvent1 = CGEvent.menuBarItemEvent( source: source, - type: .click(mouseStates.down), + type: .click(clickTypes.down), location: clickPoint, item: item, pid: pid ), let clickEvent2 = CGEvent.menuBarItemEvent( source: source, - type: .click(mouseStates.up), - location: clickPoint, - item: item, - pid: pid - ), - let fallbackEvent = CGEvent.menuBarItemEvent( - source: source, - type: .click(mouseStates.up), + type: .click(clickTypes.up), location: clickPoint, item: item, pid: pid @@ -1155,7 +1004,13 @@ extension MenuBarItemManager { let mouseLocation = try getMouseLocation(item: item) let timeout = Duration.milliseconds(250) - logger.debug( + MouseHelpers.hideCursor() + defer { + MouseHelpers.warpCursor(to: mouseLocation) + MouseHelpers.showCursor() + } + + logger.log( """ Clicking \(item.logString, privacy: .public) with \ \(mouseButton.logString, privacy: .public) @@ -1163,12 +1018,6 @@ extension MenuBarItemManager { ) do { - MouseHelpers.hideCursor() - defer { - MouseHelpers.warpCursor(to: mouseLocation) - MouseHelpers.showCursor() - } - try await scrombleEvent( clickEvent1, from: .pid(pid), @@ -1181,36 +1030,35 @@ extension MenuBarItemManager { from: .pid(pid), to: .sessionEventTap, item: item, - timeout: timeout + timeout: timeout, + repeating: 2 // Double mouse up prevents invalid item state. ) } catch { - logger.warning("Click events failed. Posting fallback.") - - // Pad with eventSleep calls to reduce the chance that events are - // still being processed somewhere. - await eventSleep() do { - // Catch this for logging purposes only. We want to propagate - // the original error. - try await postEventRoundtrip( - fallbackEvent, + logger.debug("Click events failed, posting fallback event") + + // Catch this for logging purposes only. We want to propagate the + // original error. + try await scrombleEvent( + clickEvent2, + from: .pid(pid), to: .sessionEventTap, item: item, - timeout: timeout + timeout: timeout, + repeating: 2 // Double mouse up prevents invalid item state. ) } catch { logger.error("Fallback event failed with error: \(error, privacy: .public)") } - await eventSleep() throw error } - logger.debug("Successfully clicked \(item.logString, privacy: .public)") + logger.log("Successfully clicked \(item.logString, privacy: .public)") } } -// MARK: - Temporarily Show +// MARK: - Temporarily Showing Items extension MenuBarItemManager { /// Context for a temporarily shown menu bar item. @@ -1255,12 +1103,14 @@ extension MenuBarItemManager { /// Gets the destination to return the given item to after it is /// temporarily shown. private func getReturnDestination(for item: MenuBarItem, in items: [MenuBarItem]) -> MoveDestination? { - if let index = items.firstIndex(matching: item.tag) { - if items.indices.contains(index + 1) { - return .leftOfItem(items[index + 1]) - } else if items.indices.contains(index - 1) { - return .rightOfItem(items[index - 1]) - } + guard let index = items.firstIndex(matching: item.tag) else { + return nil + } + if items.indices.contains(index + 1) { + return .leftOfItem(items[index + 1]) + } + if items.indices.contains(index - 1) { + return .rightOfItem(items[index - 1]) } return nil } @@ -1319,8 +1169,7 @@ extension MenuBarItemManager { items.trimPrefix { $0.tag != .hiddenControlItem } if !items.isEmpty { - // Remove the hidden control item. - items.removeFirst() + items.removeFirst() // Remove the hidden control item. } // Remove all offscreen items. @@ -1332,18 +1181,14 @@ extension MenuBarItemManager { items.trimPrefix { !$0.isOnScreen } } - var maxX = if let frameOfNotch = screen.frameOfNotch { - max(frameOfNotch.maxX + 20, applicationMenuFrame.maxX) + let maxX = if let frameOfNotch = screen.frameOfNotch { + max(frameOfNotch.maxX + 30, applicationMenuFrame.maxX) } else { applicationMenuFrame.maxX } - if let item = items.first, item.tag == .audioVideoModule { - maxX += item.bounds.width - } - // Remove items until we have enough room to show this item. - items.trimPrefix { $0.bounds.minX - item.bounds.width <= maxX } + items.trimPrefix { !$0.canBeHidden || $0.bounds.minX - item.bounds.width <= maxX } guard let targetItem = items.first else { logger.warning("Not enough room to show \(item.logString, privacy: .public)") @@ -1370,8 +1215,7 @@ extension MenuBarItemManager { runRehideTimer() } - await eventSleep(for: .milliseconds(50)) - + await eventSleep(for: .milliseconds(100)) let idsBeforeClick = Set(Bridging.getWindowList(option: .onScreen)) do { @@ -1381,8 +1225,7 @@ extension MenuBarItemManager { return } - await eventSleep(for: .milliseconds(500)) - + await eventSleep(for: .milliseconds(250)) let windowsAfterClick = WindowInfo.createWindows(option: .onScreen) context.shownInterfaceWindow = windowsAfterClick.first { window in @@ -1416,6 +1259,9 @@ extension MenuBarItemManager { } do { try await move(item: item, to: context.returnDestination) + if try await !itemHasCorrectPosition(item: item, for: context.returnDestination) { + throw EventError(code: .incorrectPositionAfterMove, item: item) + } } catch { context.rehideAttempts += 1 logger.warning( @@ -1428,9 +1274,8 @@ extension MenuBarItemManager { if context.rehideAttempts < 3 { tempShownItemContexts.append(context) // Try again. } else { - // Failed contexts are ultimately added back into the array - // of temp shown contexts and rehidden after a longer delay, - // so reset the attempt count. + // Failed contexts are ultimately added back to the array + // and rehidden after a longer delay, so reset the count. context.rehideAttempts = 0 failedContexts.append(context) } @@ -1438,11 +1283,7 @@ extension MenuBarItemManager { await eventSleep() } - if failedContexts.isEmpty { - rehideTimer?.invalidate() - rehideTimer = nil - } else { - failedContexts.reverse() // Reverse for correct order. + if !failedContexts.isEmpty { tempShownItemContexts = failedContexts logger.error( """ @@ -1463,7 +1304,7 @@ extension MenuBarItemManager { } } -// MARK: - Enforce Control Item Order +// MARK: - Control Item Order extension MenuBarItemManager { /// Enforces the order of the given control items, ensuring that the @@ -1488,75 +1329,88 @@ extension MenuBarItemManager { } } -// MARK: - Event Helpers +// MARK: - Event Types -/// Mouse states for menu bar item move events. -private enum MenuBarItemMoveEventMouseState { - case mouseDown - case mouseUp +/// Event types for menu bar item events. +private enum MenuBarItemEventType { + /// The event type for moving a menu bar item. + case move(MoveEventType) + /// The event type for clicking a menu bar item. + case click(ClickEventType) var cgEventType: CGEventType { switch self { - case .mouseDown: .leftMouseDown - case .mouseUp: .leftMouseUp + case .move(let subtype): subtype.cgEventType + case .click(let subtype): subtype.cgEventType } } -} -/// Mouse states for menu bar item click events. -private enum MenuBarItemClickEventMouseState { - case leftMouseDown - case leftMouseUp - case rightMouseDown - case rightMouseUp - case otherMouseDown - case otherMouseUp - - var cgEventType: CGEventType { + var cgEventFlags: CGEventFlags { switch self { - case .leftMouseDown: .leftMouseDown - case .leftMouseUp: .leftMouseUp - case .rightMouseDown: .rightMouseDown - case .rightMouseUp: .rightMouseUp - case .otherMouseDown: .otherMouseDown - case .otherMouseUp: .otherMouseUp + case .move(.mouseDown): .maskCommand + case .move, .click: [] } } var cgMouseButton: CGMouseButton { switch self { - case .leftMouseDown, .leftMouseUp: .left - case .rightMouseDown, .rightMouseUp: .right - case .otherMouseDown, .otherMouseUp: .center + case .move: .left + case .click(let subtype): subtype.cgMouseButton } } } -/// Event types for menu bar item events. -private enum MenuBarItemEventType { - /// The event type for moving a menu bar item. - case move(MenuBarItemMoveEventMouseState) - /// The event type for clicking a menu bar item. - case click(MenuBarItemClickEventMouseState) +// MARK: Move Subtype +extension MenuBarItemEventType { + /// Subtype for menu bar item move events. + enum MoveEventType { + case mouseDown + case mouseUp - var cgEventType: CGEventType { - switch self { - case .move(let state): state.cgEventType - case .click(let state): state.cgEventType + var cgEventType: CGEventType { + switch self { + case .mouseDown: .leftMouseDown + case .mouseUp: .leftMouseUp + } } } +} - var cgEventFlags: CGEventFlags { - switch self { - case .move(.mouseDown): .maskCommand - case .move, .click: [] +// MARK: Click Subtype +extension MenuBarItemEventType { + /// Subtype for menu bar item click events. + enum ClickEventType { + case leftMouseDown + case leftMouseUp + case rightMouseDown + case rightMouseUp + case otherMouseDown + case otherMouseUp + + var cgEventType: CGEventType { + switch self { + case .leftMouseDown: .leftMouseDown + case .leftMouseUp: .leftMouseUp + case .rightMouseDown: .rightMouseDown + case .rightMouseUp: .rightMouseUp + case .otherMouseDown: .otherMouseDown + case .otherMouseUp: .otherMouseUp + } } - } - var cgMouseButton: CGMouseButton { - switch self { - case .move: .left - case .click(let state): state.cgMouseButton + var cgMouseButton: CGMouseButton { + switch self { + case .leftMouseDown, .leftMouseUp: .left + case .rightMouseDown, .rightMouseUp: .right + case .otherMouseDown, .otherMouseUp: .center + } + } + + var clickState: Int64 { + switch self { + case .leftMouseDown, .rightMouseDown, .otherMouseDown: 1 + case .leftMouseUp, .rightMouseUp, .otherMouseUp: 0 + } } } } @@ -1630,8 +1484,8 @@ private extension CGMouseButton { } } - /// The equivalent down and up mouse states for menu bar item click events. - var mouseStates: (down: MenuBarItemClickEventMouseState, up: MenuBarItemClickEventMouseState) { + /// The equivalent down and up mouse types for menu bar item click events. + var clickTypes: (down: MenuBarItemEventType.ClickEventType, up: MenuBarItemEventType.ClickEventType) { switch self { case .left: (.leftMouseDown, .leftMouseUp) case .right: (.rightMouseDown, .rightMouseUp) @@ -1742,10 +1596,10 @@ private extension CGEvent { } private func setClickState(for type: MenuBarItemEventType) { - guard case .click = type else { + guard case .click(let subtype) = type else { return } - setIntegerValueField(.mouseEventClickState, value: 1) + setIntegerValueField(.mouseEventClickState, value: subtype.clickState) } } diff --git a/Ice/Utilities/Extensions.swift b/Ice/Utilities/Extensions.swift index c607df50f..12e1483eb 100644 --- a/Ice/Utilities/Extensions.swift +++ b/Ice/Utilities/Extensions.swift @@ -332,6 +332,15 @@ extension CGImage { } } +// MARK: - CGPoint + +extension CGPoint { + /// A string to use for logging purposes. + var logString: String { + String(describing: self) + } +} + // MARK: - Collection where Element == MenuBarItem extension Collection where Element == MenuBarItem { diff --git a/Ice/Utilities/MouseHelpers.swift b/Ice/Utilities/MouseHelpers.swift index 46fdb171d..357fcd3c3 100644 --- a/Ice/Utilities/MouseHelpers.swift +++ b/Ice/Utilities/MouseHelpers.swift @@ -87,11 +87,22 @@ enum MouseHelpers { /// Returns a Boolean value that indicates whether the last mouse /// movement event occurred within the given duration. /// - /// - Parameter interval: The duration within which the last mouse + /// - Parameter duration: The duration within which the last mouse /// movement event must have occurred in order to return `true`. static func lastMovementOccurred(within duration: Duration) -> Bool { let stateID = CGEventSourceStateID.combinedSessionState let seconds = CGEventSource.secondsSinceLastEventType(stateID, eventType: .mouseMoved) return .seconds(seconds) <= duration } + + /// Returns a Boolean value that indicates whether the last scroll + /// wheel event occurred within the given duration. + /// + /// - Parameter duration: The duration within which the last scroll + /// wheel event must have occurred in order to return `true`. + static func lastScrollWheelOccurred(within duration: Duration) -> Bool { + let stateID = CGEventSourceStateID.combinedSessionState + let seconds = CGEventSource.secondsSinceLastEventType(stateID, eventType: .scrollWheel) + return .seconds(seconds) <= duration + } } From 9a5802111b36cf318d10efe9c4b827f330a96c90 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Sun, 24 Aug 2025 05:00:32 -0600 Subject: [PATCH 51/80] Misc refactoring --- Ice/Main/AppState.swift | 5 +- .../Appearance/MenuBarAppearanceManager.swift | 2 +- Ice/MenuBar/IceBar/IceBar.swift | 56 ++-- .../LayoutBar/LayoutBarPaddingView.swift | 2 +- Ice/MenuBar/MenuBarItems/MenuBarItem.swift | 92 +++--- .../MenuBarItems/MenuBarItemImageCache.swift | 11 +- .../MenuBarItems/MenuBarItemManager.swift | 152 +++++---- .../MenuBarItemServiceConnection.swift | 2 +- Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift | 71 ++--- Ice/MenuBar/MenuBarManager.swift | 13 +- Ice/MenuBar/Search/MenuBarSearchPanel.swift | 208 ++++++------ Ice/Permissions/Permission.swift | 6 +- .../SettingsPanes/GeneralSettingsPane.swift | 247 +++++++------- Ice/UI/IceUI/IceColorPicker.swift | 17 +- Ice/UI/Utilities/IceGradient.swift | 2 +- Ice/Utilities/Defaults.swift | 23 +- Ice/Utilities/Extensions.swift | 300 ++++++++---------- MenuBarItemService/SourcePIDCache.swift | 18 +- .../Utilities}/AXHelpers.swift | 17 +- Shared/Utilities/SharedExtensions.swift | 2 +- Shared/Utilities/WindowInfo.swift | 12 +- 21 files changed, 606 insertions(+), 652 deletions(-) rename {MenuBarItemService => Shared/Utilities}/AXHelpers.swift (65%) diff --git a/Ice/Main/AppState.swift b/Ice/Main/AppState.swift index 9135ef97a..8b42667ef 100644 --- a/Ice/Main/AppState.swift +++ b/Ice/Main/AppState.swift @@ -137,9 +137,10 @@ final class AppState: ObservableObject { .store(in: &c) publisherForWindow(.settings) - .publisher(for: \.isVisible) + .removeNil() + .flatMap { $0.publisher(for: \.isVisible) } + .replaceEmpty(with: false) .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) - .replaceNil(with: false) .removeDuplicates() .sink { [weak self] isPresented in self?.navigationState.isSettingsPresented = isPresented diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift index e68482932..fd0ad43e3 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift @@ -32,7 +32,7 @@ final class MenuBarAppearanceManager: ObservableObject { private(set) var overlayPanels = Set() /// The amount to inset the menu bar if called for by the configuration. - let menuBarInsetAmount: CGFloat = 5 + let menuBarInsetAmount: CGFloat = 3.5 /// Performs initial setup of the manager. func performSetup(with appState: AppState) { diff --git a/Ice/MenuBar/IceBar/IceBar.swift b/Ice/MenuBar/IceBar/IceBar.swift index aac9f5d12..226311a95 100644 --- a/Ice/MenuBar/IceBar/IceBar.swift +++ b/Ice/MenuBar/IceBar/IceBar.swift @@ -279,11 +279,17 @@ private struct IceBarContentView: View { } private var horizontalPadding: CGFloat { - configuration.hasRoundedShape ? 7 : 5 + if #available(macOS 26.0, *) { + return 3 + } + return configuration.hasRoundedShape ? 7 : 5 } private var verticalPadding: CGFloat { - screen.hasNotch ? 0 : 2 + if #available(macOS 26.0, *) { + return screen.hasNotch && configuration.hasRoundedShape ? 2 : 0 + } + return screen.hasNotch ? 0 : 2 } private var contentHeight: CGFloat? { @@ -374,7 +380,7 @@ private struct IceBarContentView: View { itemManager: itemManager, menuBarManager: menuBarManager, item: item, - screen: screen, + displayID: screen.displayID, section: section ) } @@ -398,38 +404,38 @@ private struct IceBarItemView: View { @ObservedObject var menuBarManager: MenuBarManager let item: MenuBarItem - let screen: NSScreen + let displayID: CGDirectDisplayID let section: MenuBarSection.Name private var leftClickAction: () -> Void { - return { [weak itemManager] in - guard let itemManager else { + return { [weak itemManager, weak menuBarManager] in + guard let itemManager, let menuBarManager else { return } menuBarManager.section(withName: section)?.hide() Task { try await Task.sleep(for: .milliseconds(25)) - if Bridging.isWindowOnDisplay(item.windowID, screen.displayID) { + if Bridging.isWindowOnDisplay(item.windowID, displayID) { try await itemManager.click(item: item, with: .left) } else { - await itemManager.tempShow(item: item, clickingWith: .left) + await itemManager.temporarilyShow(item: item, clickingWith: .left) } } } } private var rightClickAction: () -> Void { - return { [weak itemManager] in - guard let itemManager else { + return { [weak itemManager, weak menuBarManager] in + guard let itemManager, let menuBarManager else { return } menuBarManager.section(withName: section)?.hide() Task { try await Task.sleep(for: .milliseconds(25)) - if Bridging.isWindowOnDisplay(item.windowID, screen.displayID) { + if Bridging.isWindowOnDisplay(item.windowID, displayID) { try await itemManager.click(item: item, with: .right) } else { - await itemManager.tempShow(item: item, clickingWith: .right) + await itemManager.temporarilyShow(item: item, clickingWith: .right) } } } @@ -447,7 +453,11 @@ private struct IceBarItemView: View { Image(nsImage: image) .contentShape(Rectangle()) .overlay { - IceBarItemClickView(item: item, leftClickAction: leftClickAction, rightClickAction: rightClickAction) + IceBarItemClickView( + item: item, + leftClickAction: leftClickAction, + rightClickAction: rightClickAction + ) } .accessibilityLabel(item.displayName) .accessibilityAction(named: "left click", leftClickAction) @@ -471,7 +481,11 @@ private struct IceBarItemClickView: NSViewRepresentable { private var lastLeftMouseDownLocation = CGPoint.zero private var lastRightMouseDownLocation = CGPoint.zero - init(item: MenuBarItem, leftClickAction: @escaping () -> Void, rightClickAction: @escaping () -> Void) { + init( + item: MenuBarItem, + leftClickAction: @escaping () -> Void, + rightClickAction: @escaping () -> Void + ) { self.item = item self.leftClickAction = leftClickAction self.rightClickAction = rightClickAction @@ -484,10 +498,6 @@ private struct IceBarItemClickView: NSViewRepresentable { fatalError("init(coder:) has not been implemented") } - private func absoluteDistance(_ p1: CGPoint, _ p2: CGPoint) -> CGFloat { - hypot(p1.x - p2.x, p1.y - p2.y).magnitude - } - override func mouseDown(with event: NSEvent) { super.mouseDown(with: event) lastLeftMouseDownDate = .now @@ -504,7 +514,7 @@ private struct IceBarItemClickView: NSViewRepresentable { super.mouseUp(with: event) guard Date.now.timeIntervalSince(lastLeftMouseDownDate) < 0.5, - absoluteDistance(lastLeftMouseDownLocation, NSEvent.mouseLocation) < 5 + lastLeftMouseDownLocation.distance(to: NSEvent.mouseLocation) < 5 else { return } @@ -515,7 +525,7 @@ private struct IceBarItemClickView: NSViewRepresentable { super.rightMouseUp(with: event) guard Date.now.timeIntervalSince(lastRightMouseDownDate) < 0.5, - absoluteDistance(lastRightMouseDownLocation, NSEvent.mouseLocation) < 5 + lastRightMouseDownLocation.distance(to: NSEvent.mouseLocation) < 5 else { return } @@ -529,7 +539,11 @@ private struct IceBarItemClickView: NSViewRepresentable { let rightClickAction: () -> Void func makeNSView(context: Context) -> NSView { - Represented(item: item, leftClickAction: leftClickAction, rightClickAction: rightClickAction) + Represented( + item: item, + leftClickAction: leftClickAction, + rightClickAction: rightClickAction + ) } func updateNSView(_ nsView: NSView, context: Context) { } diff --git a/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift b/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift index 6bc449b4e..eaee759d0 100644 --- a/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift +++ b/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift @@ -112,7 +112,7 @@ final class LayoutBarPaddingView: NSView { try await Task.sleep(for: .milliseconds(25)) do { try await appState.itemManager.move(item: item, to: destination) - appState.itemManager.removeTempShownItemFromCache(with: item.tag) + appState.itemManager.removeTemporarilyShownItemFromCache(with: item.tag) } catch { Logger.default.error("Error moving menu bar item: \(error, privacy: .public)") let alert = NSAlert(error: error) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift index d08c5bd97..8e3f1c5b8 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift @@ -4,11 +4,8 @@ // import Cocoa -import Combine -// MARK: - MenuBarItem - -/// A representation of an item in the menu bar. +/// A structural representation of a menu bar item. struct MenuBarItem: CustomStringConvertible { /// The tag associated with this item. let tag: MenuBarItemTag @@ -28,14 +25,8 @@ struct MenuBarItem: CustomStringConvertible { /// The item's window title. let title: String? - /// The name of the process that owns the item. - /// - /// This may have a value when ``owningApplication`` does not have - /// a localized name. - let ownerName: String? - - /// A Boolean value that indicates whether the item is on screen. - let isOnScreen: Bool + /// A Boolean value that indicates whether the item is onscreen. + let isOnscreen: Bool /// A Boolean value that indicates whether the item can be moved. var isMovable: Bool { @@ -53,16 +44,25 @@ struct MenuBarItem: CustomStringConvertible { tag.isControlItem } + /// A Boolean value that indicates whether the item is a "BentoBox" + /// item owned by the Control Center. + var isBentoBox: Bool { + tag.isBentoBox + } + /// The application that owns the item. /// - /// - Note: In macOS 26 Tahoe and later, this property always returns - /// the Control Center. To get the actual application that created - /// the item, use ``sourceApplication``. + /// - Note: In macOS 26 and later, this property always returns the + /// Control Center. To get the actual application that created the + /// item, use ``sourceApplication``. var owningApplication: NSRunningApplication? { NSRunningApplication(processIdentifier: ownerPID) } /// The application that created the item. + /// + /// - Note: Prior to macOS 26, this property and ``owningApplication`` + /// are functionally equivalent. var sourceApplication: NSRunningApplication? { guard let sourcePID else { return nil @@ -73,8 +73,11 @@ struct MenuBarItem: CustomStringConvertible { /// A name associated with the item that is suited for display. var displayName: String { /// Converts "UpperCamelCase" to "Title Case". + /// + /// Ignores cases where a single lowercase letter immediately + /// precedes an uppercase letter (i.e. "WiFi"). func toTitleCase(_ s: S) -> String { - String(s).replacing(/([a-z])([A-Z])/) { $0.output.1 + " " + $0.output.2 } + String(s).replacing(/([a-z]{2})([A-Z])/) { $0.output.1 + " " + $0.output.2 } } guard let sourceApplication else { @@ -95,38 +98,37 @@ struct MenuBarItem: CustomStringConvertible { return bestName } - // Most items will use their computed "best name", but we need to - // handle a few special cases for system items. - - if tag == .controlCenter { - return bestName + guard !isBentoBox else { + if tag == .controlCenter { + return bestName + } + return title } - return switch tag.namespace { + // Most items use their computed "best name", but we handle + // a few special cases for system items. + switch tag.namespace { case .passwords, .weather, .textInputMenuAgent: // "PasswordsMenuBarExtra" -> "Passwords" // "WeatherMenu" -> "Weather" // "TextInputMenuAgent" -> "Text Input" - toTitleCase(bestName.replacing(/Menu.*/, with: "")) - case .controlCenter where title.hasPrefix("BentoBox"): - toTitleCase(title.replacing(/-/, with: " ")) - case .controlCenter where title.hasPrefix("Hearing"): - // Changed to "Hearing_GlowE" in macOS 15.4. - toTitleCase(title.prefix { $0.isLetter || $0.isNumber }) - case .controlCenter where title == "WiFi": - title - case .systemUIServer where title.contains("TimeMachine"): + return toTitleCase(bestName.replacing(/Menu.*/, with: "")) + case .controlCenter: + guard let match = title.prefixMatch(of: /Hearing/) else { + return toTitleCase(title) + } + // Changed from "Hearing" to "Hearing_GlowE" in macOS 15.4 + return toTitleCase(match.output) + case .systemUIServer: + guard let match = title.firstMatch(of: /TimeMachine/) else { + return toTitleCase(title) + } // Sonoma: "TimeMachine.TMMenuExtraHost" // Sequoia: "TimeMachineMenuExtra.TMMenuExtraHost" // Tahoe: "com.apple.menuextra.TimeMachine" - "Time Machine" - case .controlCenter, .systemUIServer: - // Most system items are hosted by one of these two apps. They - // usually have descriptive, but unformatted titles, so we'll do - // some basic formatting ourselves. - toTitleCase(title.prefix { $0 != "." }) + return toTitleCase(match.output) default: - bestName + return bestName } } @@ -151,8 +153,7 @@ struct MenuBarItem: CustomStringConvertible { self.sourcePID = itemWindow.ownerPID self.bounds = itemWindow.bounds self.title = itemWindow.title - self.ownerName = itemWindow.ownerName - self.isOnScreen = itemWindow.isOnScreen + self.isOnscreen = itemWindow.isOnscreen } /// Creates a menu bar item without checks. @@ -168,8 +169,7 @@ struct MenuBarItem: CustomStringConvertible { self.sourcePID = sourcePID self.bounds = itemWindow.bounds self.title = itemWindow.title - self.ownerName = itemWindow.ownerName - self.isOnScreen = itemWindow.isOnScreen + self.isOnscreen = itemWindow.isOnscreen } /// Returns the current bounds for the given menu bar item. @@ -258,7 +258,7 @@ extension MenuBarItem { /// items across all available displays. /// - option: Options that filter the returned list. Pass an empty option set /// to return all available menu bar items. - static func getMenuBarItems(caller: String = #function, on display: CGDirectDisplayID? = nil, option: ListOption) async -> [MenuBarItem] { + static func getMenuBarItems(on display: CGDirectDisplayID? = nil, option: ListOption) async -> [MenuBarItem] { if #available(macOS 26.0, *) { await getMenuBarItemsExperimental(on: display, option: option) } else { @@ -276,8 +276,7 @@ extension MenuBarItem: Equatable { lhs.sourcePID == rhs.sourcePID && NSStringFromRect(lhs.bounds) == NSStringFromRect(rhs.bounds) && lhs.title == rhs.title && - lhs.ownerName == rhs.ownerName && - lhs.isOnScreen == rhs.isOnScreen + lhs.isOnscreen == rhs.isOnscreen } } @@ -290,8 +289,7 @@ extension MenuBarItem: Hashable { hasher.combine(sourcePID) hasher.combine(NSStringFromRect(bounds)) hasher.combine(title) - hasher.combine(ownerName) - hasher.combine(isOnScreen) + hasher.combine(isOnscreen) } } diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift index f2f8df151..759a4af27 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift @@ -152,7 +152,10 @@ final class MenuBarItemImageCache: ObservableObject { height: bounds.height * scale ) - guard let image = compositeImage.cropping(to: cropRect) else { + guard + let image = compositeImage.cropping(to: cropRect), + !image.isTransparent() + else { result.excluded.append(item) continue } @@ -184,11 +187,9 @@ final class MenuBarItemImageCache: ObservableObject { /// Captures the images of the given menu bar items and returns the result. private nonisolated func captureImages(of items: [MenuBarItem], scale: CGFloat, appState: AppState) async -> CaptureResult { - // This check may have already happened at a higher level, but let's check - // again with a more lenient duration. We want to use individual capture if - // there is any chance that items are still moving, since composite capture + // Use individual capture after a move operation, since composite capture // doesn't account for overlapping items. - if await appState.itemManager.latestMoveOperationStarted(within: .seconds(3)) { + if await appState.itemManager.latestMoveOperationStarted(within: .seconds(2)) { logger.debug("Capturing individually due to recent item movement") return individualCapture(items, scale: scale) } diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index 98b3cc7d4..99af24b9c 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -20,7 +20,7 @@ final class MenuBarItemManager: ObservableObject { private var cachedItemWindowIDs = [CGWindowID]() /// Contexts for temporarily shown menu bar items. - private var tempShownItemContexts = [TempShownItemContext]() + private var temporarilyShownItemContexts = [TemporarilyShownItemContext]() /// A timer for rehiding temporarily shown menu bar items. private var rehideTimer: Timer? @@ -212,7 +212,7 @@ extension MenuBarItemManager { let controlItems: ControlItemPair var cache: ItemCache - var tempShownItems = [(MenuBarItem, MoveDestination)]() + var temporarilyShownItems = [(MenuBarItem, MoveDestination)]() var shouldClearCachedItemWindowIDs = false private(set) lazy var hiddenControlItemBounds = bestBounds(for: controlItems.hidden) @@ -272,11 +272,11 @@ extension MenuBarItemManager { context.shouldClearCachedItemWindowIDs = true } - if let temp = tempShownItemContexts.first(where: { $0.tag == item.tag }) { + if let temp = temporarilyShownItemContexts.first(where: { $0.tag == item.tag }) { // Cache temporarily shown items as if they were in their original locations. // Keep track of them separately and use their return destinations to insert // them into the cache once all other items have been handled. - context.tempShownItems.append((item, temp.returnDestination)) + context.temporarilyShownItems.append((item, temp.returnDestination)) continue } @@ -289,7 +289,7 @@ extension MenuBarItemManager { context.shouldClearCachedItemWindowIDs = true } - for (item, destination) in context.tempShownItems { + for (item, destination) in context.temporarilyShownItems { context.cache.insert(item, at: destination) } @@ -676,6 +676,16 @@ extension MenuBarItemManager { } } + /// Returns the timeout duration for moving the given item. + private nonisolated func getTimeout(forMoving item: MenuBarItem) -> Duration { + if item.isBentoBox { + // Bento Boxes (i.e. Control Center groups) take a little + // longer to respond. + return .milliseconds(100) + } + return .milliseconds(25) + } + /// Returns the target points for creating the events needed to move /// a menu bar item to the given destination. private nonisolated func getTargetPoints( @@ -750,7 +760,7 @@ extension MenuBarItemManager { logger.debug( """ Item responded with new bounds origin: \ - \(bounds.origin.logString, privacy: .public) + \(String(describing: bounds.origin), privacy: .public) """ ) return bounds @@ -805,6 +815,14 @@ extension MenuBarItemManager { latestMoveOperationTimestamp = .now } + let mouseLocation = try getMouseLocation(item: item) + + MouseHelpers.hideCursor() + defer { + MouseHelpers.warpCursor(to: mouseLocation) + MouseHelpers.showCursor() + } + do { logger.debug("Posting move events") @@ -890,15 +908,6 @@ extension MenuBarItemManager { } let source = try getEventSource(for: item) - let mouseLocation = try getMouseLocation(item: item) - let timeout = Duration.milliseconds(25) - let maxAttempts = 10 - - MouseHelpers.hideCursor() - defer { - MouseHelpers.warpCursor(to: mouseLocation) - MouseHelpers.showCursor() - } logger.log( """ @@ -907,13 +916,16 @@ extension MenuBarItemManager { """ ) - for n in 1...maxAttempts { + let maxAttempts = 10 + let timeout = getTimeout(forMoving: item) + + moveLoop: for n in 1...maxAttempts { guard !Task.isCancelled else { throw EventError(code: .cannotComplete, item: item) } attempt: do { - guard try await !itemHasCorrectPosition(item: item, for: destination) else { + if try await itemHasCorrectPosition(item: item, for: destination) { logger.debug("Item has correct position") break attempt } @@ -926,7 +938,7 @@ extension MenuBarItemManager { } catch where n < maxAttempts { logger.debug("Attempt \(n, privacy: .public) failed: \(error, privacy: .public)") await eventSleep() - continue + continue moveLoop } catch let error as EventError { throw error } catch { @@ -934,7 +946,7 @@ extension MenuBarItemManager { } logger.debug("Attempt \(n, privacy: .public) succeeded") - break + break moveLoop } logger.log("Successfully moved \(item.logString, privacy: .public)") @@ -1062,7 +1074,7 @@ extension MenuBarItemManager { extension MenuBarItemManager { /// Context for a temporarily shown menu bar item. - private final class TempShownItemContext { + private final class TemporarilyShownItemContext { /// The tag associated with the item. let tag: MenuBarItemTag @@ -1079,19 +1091,21 @@ extension MenuBarItemManager { /// interface is showing. var isShowingInterface: Bool { guard - let shownInterfaceWindow, - let currentWindow = WindowInfo(windowID: shownInterfaceWindow.windowID) + let window = shownInterfaceWindow, + let current = WindowInfo(windowID: window.windowID) else { + // Window no longer exists, so assume closed. return false } + print(current.layer) if - currentWindow.layer != CGWindowLevelForKey(.popUpMenuWindow), - let owningApplication = currentWindow.owningApplication + current.layer != CGWindowLevelForKey(.popUpMenuWindow), + current.layer != CGWindowLevelForKey(.statusWindow), + let app = current.owningApplication { - return owningApplication.isActive && currentWindow.isOnScreen - } else { - return currentWindow.isOnScreen + return app.isActive && current.isOnscreen } + return current.isOnscreen } init(tag: MenuBarItemTag, returnDestination: MoveDestination) { @@ -1131,7 +1145,7 @@ extension MenuBarItemManager { } logger.debug("Rehide timer fired") Task { - await self.rehideTempShownItems() + await self.rehideTemporarilyShownItems() } } } @@ -1144,12 +1158,9 @@ extension MenuBarItemManager { /// - Parameters: /// - item: The item to temporarily show. /// - mouseButton: The mouse button to click the item with. - func tempShow(item: MenuBarItem, clickingWith mouseButton: CGMouseButton) async { - guard - let displayID = Bridging.getActiveMenuBarDisplayID(), - let screen = NSScreen.screens.first(where: { $0.displayID == displayID }) - else { - logger.error("No active menu bar display, so not showing \(item.logString, privacy: .public)") + func temporarilyShow(item: MenuBarItem, clickingWith mouseButton: CGMouseButton) async { + guard let screen = NSScreen.screenWithActiveMenuBar else { + logger.error("No active menu bar screen, so not showing \(item.logString, privacy: .public)") return } @@ -1165,30 +1176,26 @@ extension MenuBarItemManager { return } - // Remove all items up to the hidden control item. - items.trimPrefix { $0.tag != .hiddenControlItem } - - if !items.isEmpty { - items.removeFirst() // Remove the hidden control item. + // Remove all items up to and including the hidden control item. + if let index = items.firstIndex(matching: .hiddenControlItem) { + items.removeSubrange(...index) } - // Remove all offscreen items. - if #available(macOS 26.0, *) { - // MenuBarItem.isOnScreen doesn't work properly as of macOS 26. - // TODO: Revert this if and when it works again. - items.trimPrefix { !Bridging.isWindowOnDisplay($0.windowID, displayID) } - } else { - items.trimPrefix { !$0.isOnScreen } - } - - let maxX = if let frameOfNotch = screen.frameOfNotch { - max(frameOfNotch.maxX + 30, applicationMenuFrame.maxX) - } else { - applicationMenuFrame.maxX - } + let maxX: CGFloat = { + var maxX = applicationMenuFrame.maxX + if let frameOfNotch = screen.frameOfNotch { + maxX = max(maxX, frameOfNotch.maxX + 30) + } + return maxX + item.bounds.width + }() // Remove items until we have enough room to show this item. - items.trimPrefix { !$0.canBeHidden || $0.bounds.minX - item.bounds.width <= maxX } + items.trimPrefix { item in + if item.isOnscreen && item.canBeHidden { + return item.bounds.minX <= maxX + } + return true + } guard let targetItem = items.first else { logger.warning("Not enough room to show \(item.logString, privacy: .public)") @@ -1207,8 +1214,8 @@ extension MenuBarItemManager { return } - let context = TempShownItemContext(tag: item.tag, returnDestination: destination) - tempShownItemContexts.append(context) + let context = TemporarilyShownItemContext(tag: item.tag, returnDestination: destination) + temporarilyShownItemContexts.append(context) rehideTimer?.invalidate() defer { @@ -1237,23 +1244,26 @@ extension MenuBarItemManager { /// /// If an item is currently showing its interface, this method waits /// for the interface to close before hiding the items. - func rehideTempShownItems() async { - guard !tempShownItemContexts.isEmpty else { + func rehideTemporarilyShownItems() async { + guard !temporarilyShownItemContexts.isEmpty else { return } - guard !tempShownItemContexts.contains(where: { $0.isShowingInterface }) else { + guard !temporarilyShownItemContexts.contains(where: { $0.isShowingInterface }) else { logger.debug("Menu bar item interface is shown, so waiting to rehide") runRehideTimer(for: 3) return } + var currentContexts = temporarilyShownItemContexts + temporarilyShownItemContexts.removeAll() + let items = await MenuBarItem.getMenuBarItems(option: .activeSpace) - var failedContexts = [TempShownItemContext]() + var failedContexts = [TemporarilyShownItemContext]() logger.debug("Rehiding temporarily shown items") - while let context = tempShownItemContexts.popLast() { + while let context = currentContexts.popLast() { guard let item = items.first(matching: context.tag) else { continue } @@ -1272,7 +1282,7 @@ extension MenuBarItemManager { """ ) if context.rehideAttempts < 3 { - tempShownItemContexts.append(context) // Try again. + currentContexts.append(context) // Try again. } else { // Failed contexts are ultimately added back to the array // and rehidden after a longer delay, so reset the count. @@ -1283,14 +1293,16 @@ extension MenuBarItemManager { await eventSleep() } - if !failedContexts.isEmpty { - tempShownItemContexts = failedContexts + if failedContexts.isEmpty { + logger.debug("All items were successfully rehidden") + } else { logger.error( """ Some items failed to rehide: \ \(failedContexts.map { $0.tag }, privacy: .public) """ ) + temporarilyShownItemContexts.append(contentsOf: failedContexts.reversed()) runRehideTimer(for: 3) } } @@ -1299,8 +1311,16 @@ extension MenuBarItemManager { /// /// This ensures that the item will _not_ be returned to its /// previous location. - func removeTempShownItemFromCache(with tag: MenuBarItemTag) { - tempShownItemContexts.removeAll { $0.tag == tag } + func removeTemporarilyShownItemFromCache(with tag: MenuBarItemTag) { + while let index = temporarilyShownItemContexts.firstIndex(where: { $0.tag == tag }) { + logger.debug( + """ + Removing temporarily shown item from cache: \ + \(tag, privacy: .public) + """ + ) + temporarilyShownItemContexts.remove(at: index) + } } } diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift index 8102a7b1a..7c17a5c13 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift @@ -26,7 +26,7 @@ extension MenuBarItemService { /// Creates a new connection. private init() { - let queue = DispatchQueue.targetingGlobal(label: "MenuBarItemService.Connection.queue", qos: .userInteractive) + let queue = DispatchQueue.targetingGlobal(label: "MenuBarItemService.Connection.queue", qos: .utility) let logger = Logger(category: "MenuBarItemService.Connection") self.session = Session(queue: queue, logger: logger) self.queue = queue diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift index f930233d4..b4f3456a2 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift @@ -34,6 +34,12 @@ struct MenuBarItemTag: Hashable, CustomStringConvertible { MenuBarItemTag.controlItems.contains(self) } + /// A Boolean value that indicates whether the item identified + /// by this tag is a "BentoBox" item owned by Control Center. + var isBentoBox: Bool { + namespace == .controlCenter && title.hasPrefix("BentoBox") + } + /// A string representation of the tag. var stringValue: String { var result = namespace.stringValue @@ -112,7 +118,11 @@ extension MenuBarItemTag { /// The tag for Ice's control item for the "Always-Hidden" section. static let alwaysHiddenControlItem = MenuBarItemTag(controlItem: .alwaysHidden) - // MARK: Other System Items + // MARK: Other Special Items + + /// The tag for the system item that appears in the menu bar + /// during screen or audio capture. + static let audioVideoModule = MenuBarItemTag(namespace: .controlCenter, title: "AudioVideoModule") /// The tag for the system "Clock" item. static let clock = MenuBarItemTag(namespace: .controlCenter, title: "Clock") @@ -124,57 +134,27 @@ extension MenuBarItemTag { MenuBarItemTag(namespace: .controlCenter, title: "BentoBox") } - /// The tag for the system "Siri" item. - static let siri = MenuBarItemTag(namespace: .systemUIServer, title: "Siri") - - /// The tag for the system "Spotlight" item. - static let spotlight = MenuBarItemTag(namespace: .spotlight, title: "Item-0") - - /// The tag for the system "WiFi" item. - static let wifi = MenuBarItemTag(namespace: .controlCenter, title: "WiFi") - - /// The tag for the system "Bluetooth" item. - static let bluetooth = MenuBarItemTag(namespace: .controlCenter, title: "Bluetooth") - - /// The tag for the system "Battery" item. - static let battery = MenuBarItemTag(namespace: .controlCenter, title: "Battery") - - /// The tag for the system "Focus Modes" item. - static let focusModes = MenuBarItemTag(namespace: .controlCenter, title: "FocusModes") - - /// The tag for the system "Screen Mirroring" item. - static let screenMirroring = MenuBarItemTag(namespace: .controlCenter, title: "ScreenMirroring") + /// The tag for the system "FaceTime" item. + static let faceTime = MenuBarItemTag(namespace: .controlCenter, title: "FaceTime") - /// The tag for the system "Display" item. - static let display = MenuBarItemTag(namespace: .controlCenter, title: "Display") + /// The tag for the system "Music Recognition" item. + static let musicRecognition = MenuBarItemTag(namespace: .controlCenter, title: "MusicRecognition") - /// The tag for the system "Sound" item. - static let sound = MenuBarItemTag(namespace: .controlCenter, title: "Sound") + /// The tag for the system item that appears in the menu bar + /// during recordings started by the macOS "Screenshot" tool. + static let screenCaptureUI = MenuBarItemTag(namespace: .screenCaptureUI, title: "Item-0") - /// The tag for the system "Now Playing" item. - static let nowPlaying = MenuBarItemTag(namespace: .controlCenter, title: "NowPlaying") + /// The tag for the system "Siri" item. + static let siri = MenuBarItemTag(namespace: .systemUIServer, title: "Siri") - /// The tag for the system "TimeMachine" item. - static let timeMachine = if #available(macOS 15.0, *) { + /// The tag for the system "Time Machine" item. + static let timeMachine = if #available(macOS 26.0, *) { + MenuBarItemTag(namespace: .systemUIServer, title: "com.apple.menuextra.TimeMachine") + } else if #available(macOS 15.0, *) { MenuBarItemTag(namespace: .systemUIServer, title: "TimeMachineMenuExtra.TMMenuExtraHost") } else { MenuBarItemTag(namespace: .systemUIServer, title: "TimeMachine.TMMenuExtraHost") } - - /// The tag for the item that appears in the menu bar while the screen - /// or system audio is being recorded. - static let audioVideoModule = MenuBarItemTag(namespace: .controlCenter, title: "AudioVideoModule") - - /// The tag for the system "FaceTime" item. - static let faceTime = MenuBarItemTag(namespace: .controlCenter, title: "FaceTime") - - /// The tag for the system "MusicRecognition" item. - static let musicRecognition = MenuBarItemTag(namespace: .controlCenter, title: "MusicRecognition") - - // TODO: How do we reference this item in macOS 26? - /// The tag for the "stop recording" item that appears in the menu bar - /// during screen recordings started by the macOS "Screenshot" tool. - static let screenCaptureUI = MenuBarItemTag(namespace: .screenCaptureUI, title: "Item-0") } // MARK: - MenuBarItemTag.Namespace @@ -229,9 +209,6 @@ extension MenuBarItemTag.Namespace { /// The namespace for the "screencaptureui" process. static let screenCaptureUI = string("com.apple.screencaptureui") - /// The namespace for the "Spotlight" process. - static let spotlight = string("com.apple.Spotlight") - /// The namespace for the "SystemUIServer" process. static let systemUIServer = string("com.apple.systemuiserver") diff --git a/Ice/MenuBar/MenuBarManager.swift b/Ice/MenuBar/MenuBarManager.swift index 37ea8d644..e0186e7ad 100644 --- a/Ice/MenuBar/MenuBarManager.swift +++ b/Ice/MenuBar/MenuBarManager.swift @@ -3,7 +3,6 @@ // Ice // -import AXSwift import Combine import OSLog import SwiftUI @@ -274,15 +273,13 @@ final class MenuBarManager: ObservableObject { /// Returns a Boolean value that indicates whether the given display /// has a valid menu bar. func hasValidMenuBar(in windows: [WindowInfo], for display: CGDirectDisplayID) -> Bool { - guard let window = WindowInfo.menuBarWindow(from: windows, for: display) else { - return false - } - do { - let uiElement = try systemWideElement.elementAtPosition(window.bounds.origin) - return try uiElement?.role() == .menuBar - } catch { + guard + let window = WindowInfo.menuBarWindow(from: windows, for: display), + let element = AXHelpers.element(at: window.bounds.origin) + else { return false } + return AXHelpers.role(for: element) == .menuBar } /// Shows the secondary context menu. diff --git a/Ice/MenuBar/Search/MenuBarSearchPanel.swift b/Ice/MenuBar/Search/MenuBarSearchPanel.swift index 34df36121..af9a0f0b2 100644 --- a/Ice/MenuBar/Search/MenuBarSearchPanel.swift +++ b/Ice/MenuBar/Search/MenuBarSearchPanel.swift @@ -165,15 +165,12 @@ private final class MenuBarSearchHostingView: NSHostingView { panel: MenuBarSearchPanel ) { super.init( - rootView: MenuBarSearchContentView( - displayID: displayID, - closePanel: { [weak panel] in panel?.close() } - ) - .environmentObject(appState) - .environmentObject(appState.itemManager) - .environmentObject(appState.imageCache) - .environmentObject(model) - .erasedToAnyView() + rootView: MenuBarSearchContentView { [weak panel] in panel?.close() } + .environmentObject(appState) + .environmentObject(appState.itemManager) + .environmentObject(appState.imageCache) + .environmentObject(model) + .erasedToAnyView() ) } @@ -195,7 +192,6 @@ private struct MenuBarSearchContentView: View { @EnvironmentObject var model: MenuBarSearchModel @FocusState private var searchFieldIsFocused: Bool - let displayID: CGDirectDisplayID let closePanel: () -> Void private var hasItems: Bool { @@ -203,11 +199,7 @@ private struct MenuBarSearchContentView: View { } private var bottomBarPadding: CGFloat { - if #available(macOS 26.0, *) { - return 7 - } else { - return 5 - } + if #available(macOS 26.0, *) { 7 } else { 5 } } var body: some View { @@ -288,7 +280,7 @@ private struct MenuBarSearchContentView: View { let selection = model.selection, let item = menuBarItem(for: selection) { - ShowItemButton(item: item, displayID: displayID) { + ShowItemButton(item: item) { performAction(for: item) } } @@ -306,67 +298,78 @@ private struct MenuBarSearchContentView: View { } private func updateDisplayedItems() { - let searchItems: [(listItem: ListItem, title: String)] = MenuBarSection.Name.allCases.reduce(into: []) { items, section in - if itemManager.appState?.menuBarManager.section(withName: section)?.isEnabled == false { - return - } + typealias SearchItem = (listItem: ListItem, title: String) + typealias ScoredItem = (listItem: ListItem, score: Double) + + let searchItems: [SearchItem] = MenuBarSection.Name.allCases + .reduce(into: []) { items, name in + if + let appState = itemManager.appState, + let section = appState.menuBarManager.section(withName: name), + !section.isEnabled + { + return + } - let headerItem = ListItem.header(id: .header(section)) { - Text(section.displayString) - .fontWeight(.semibold) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 10) - } - items.append((headerItem, section.displayString)) + let headerItem = ListItem.header(id: .header(name)) { + Text(name.displayString) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 10) + } + items.append(SearchItem(headerItem, name.displayString)) - for item in itemManager.itemCache.managedItems(for: section).reversed() { - let listItem = ListItem.item(id: .item(item.tag)) { - performAction(for: item) - } content: { - MenuBarSearchItemView(item: item) + for item in itemManager.itemCache.managedItems(for: name).reversed() { + let listItem = ListItem.item(id: .item(item.tag)) { + performAction(for: item) + } content: { + MenuBarSearchItemView(item: item) + } + items.append(SearchItem(listItem, item.displayName)) } - items.append((listItem, item.displayName)) } - } - - let searchText = model.searchText - if searchText.isEmpty { + if model.searchText.isEmpty { model.displayedItems = searchItems.map { $0.listItem } } else { - let selectableItems = searchItems.compactMap { searchItem in - if searchItem.listItem.isSelectable { - return searchItem - } - return nil - } - - let fuseResults = model.fuse.searchSync(searchText, in: selectableItems.map { $0.title }) + let selectableItems = searchItems.filter { $0.listItem.isSelectable } + let fuseResults = model.fuse.searchSync( + model.searchText, + in: selectableItems.map { $0.title } + ) let maxFuseScore = Double(fuseResults.count) - let scoredItems: [(listItem: ListItem, score: Double)] = fuseResults.enumerated().map { index, result in - let searchItem = selectableItems[result.index] - let fuseScore = maxFuseScore - Double(index) - - guard let match = bestMatch(query: searchText, input: searchItem.title, boundaryBonus: 16, camelCaseBonus: 16) else { - return (searchItem.listItem, fuseScore) - } - - let matchScore = Double(match.score.value) - let averageScore = (matchScore + fuseScore) / 2 + model.displayedItems = fuseResults.enumerated() + .map { index, result in + let fuseScore = maxFuseScore - Double(index) + let (listItem, title) = selectableItems[result.index] + + guard let match = bestMatch( + query: model.searchText, + input: title, + boundaryBonus: 16, + camelCaseBonus: 16 + ) else { + return ScoredItem(listItem, fuseScore) + } - return (searchItem.listItem, averageScore) - } + let matchScore = Double(match.score.value) + let averageScore = (matchScore + fuseScore) / 2 - model.displayedItems = scoredItems.lazy.sorted { $0.score > $1.score }.map { $0.listItem } + return ScoredItem(listItem, averageScore) + } + .sorted { $0.score > $1.score } + .map { $0.listItem } } } private func menuBarItem(for selection: MenuBarSearchModel.ItemID) -> MenuBarItem? { switch selection { - case .item(let tag): itemManager.itemCache.managedItems.first(matching: tag) - case .header: nil + case .item(let tag): + return itemManager.itemCache.managedItems.first(matching: tag) + case .header: + return nil } } @@ -374,10 +377,10 @@ private struct MenuBarSearchContentView: View { closePanel() Task { try await Task.sleep(for: .milliseconds(25)) - if Bridging.isWindowOnDisplay(item.windowID, displayID) { + if item.isOnscreen { try await itemManager.click(item: item, with: .left) } else { - await itemManager.tempShow(item: item, clickingWith: .left) + await itemManager.temporarilyShow(item: item, clickingWith: .left) } } } @@ -399,7 +402,6 @@ private struct SettingsButton: View { private struct ShowItemButton: View { let item: MenuBarItem - let displayID: CGDirectDisplayID let action: () -> Void private var backgroundShape: some InsettableShape { @@ -410,14 +412,10 @@ private struct ShowItemButton: View { } } - private var isOnDisplay: Bool { - Bridging.isWindowOnDisplay(item.windowID, displayID) - } - var body: some View { Button(action: action) { HStack { - Text("\(isOnDisplay ? "Click" : "Show") Item") + Text("\(item.isOnscreen ? "Click" : "Show") Item") .padding(.leading, 5) Image(systemName: "return") @@ -470,7 +468,10 @@ private struct BottomBarButtonStyle: ButtonStyle { @MainActor private let controlCenterIcon: NSImage? = { - guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.controlcenter").first else { + guard let app = NSRunningApplication + .runningApplications(withBundleIdentifier: "com.apple.controlcenter") + .first + else { return nil } return app.icon @@ -483,29 +484,29 @@ private struct MenuBarSearchItemView: View { let item: MenuBarItem - private var image: NSImage { + private var itemImage: NSImage { guard - let cachedImage = imageCache.images[item.tag], - let trimmedImage = cachedImage.cgImage.trimmingTransparentPixels(around: [.minXEdge, .maxXEdge]) + let cached = imageCache.images[item.tag], + let trimmed = cached.cgImage.trimmingTransparency(around: [.minXEdge, .maxXEdge]) else { return NSImage() } let size = CGSize( - width: CGFloat(trimmedImage.width) / cachedImage.scale, - height: CGFloat(trimmedImage.height) / cachedImage.scale + width: CGFloat(trimmed.width) / cached.scale, + height: CGFloat(trimmed.height) / cached.scale ) - return NSImage(cgImage: trimmedImage, size: size) + return NSImage(cgImage: trimmed, size: size) } private var appIcon: NSImage? { - guard let sourceApplication = item.sourceApplication else { + guard let app = item.sourceApplication else { return nil } switch item.tag.namespace { case .controlCenter, .systemUIServer, .textInputMenuAgent: return controlCenterIcon default: - return sourceApplication.icon + return app.icon } } @@ -517,44 +518,39 @@ private struct MenuBarSearchItemView: View { } } - private var size: CGFloat { - if #available(macOS 26.0, *) { - return 26 - } else { - return 24 - } + private var dimension: CGFloat { + if #available(macOS 26.0, *) { 26 } else { 24 } } private var padding: CGFloat { - if #available(macOS 26.0, *) { - return 6 - } else { - return 8 - } + if #available(macOS 26.0, *) { 6 } else { 8 } } var body: some View { HStack { - iconViewWithFrame - Text(item.displayName) + Label { + labelText + } icon: { + labelIcon + } Spacer() - imageViewWithBackground + itemView } .padding(padding) } @ViewBuilder - private var iconViewWithFrame: some View { - iconView - .frame(width: size, height: size) + private var labelText: some View { + Text(item.displayName) } @ViewBuilder - private var iconView: some View { + private var labelIcon: some View { if let appIcon { Image(nsImage: appIcon) .resizable() .aspectRatio(contentMode: .fit) + .frame(width: dimension, height: dimension) } else { RoundedRectangle(cornerRadius: 5) .fill(Color.accentColor.gradient) @@ -569,23 +565,25 @@ private struct MenuBarSearchItemView: View { } .padding(2.5) .shadow(color: .black.opacity(0.1), radius: 2) + .frame(width: dimension, height: dimension) } } @ViewBuilder - private var imageViewWithBackground: some View { - imageView - .menuBarItemContainer(appState: appState, colorInfo: model.averageColorInfo) + private var itemView: some View { + Image(nsImage: itemImage) + .frame( + width: item.bounds.width, + height: dimension + ) + .menuBarItemContainer( + appState: appState, + colorInfo: model.averageColorInfo + ) .clipShape(backgroundShape) .overlay { backgroundShape .strokeBorder(.quaternary) } } - - @ViewBuilder - private var imageView: some View { - Image(nsImage: image) - .frame(width: item.bounds.width, height: size) - } } diff --git a/Ice/Permissions/Permission.swift b/Ice/Permissions/Permission.swift index 3b8b0c22e..01a5542e6 100644 --- a/Ice/Permissions/Permission.swift +++ b/Ice/Permissions/Permission.swift @@ -3,10 +3,8 @@ // Ice // -import AXSwift import Combine import Cocoa -import ScreenCaptureKit // MARK: - Permission @@ -131,10 +129,10 @@ final class AccessibilityPermission: Permission { isRequired: true, settingsURL: nil, check: { - checkIsProcessTrusted() + AXHelpers.isProcessTrusted() }, request: { - checkIsProcessTrusted(prompt: true) + AXHelpers.isProcessTrusted(prompt: true) } ) } diff --git a/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift b/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift index 8669f102a..4ec9527a4 100644 --- a/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift @@ -12,23 +12,15 @@ struct GeneralSettingsPane: View { @State private var isImportingCustomIceIcon = false @State private var isPresentingError = false @State private var presentedError: LocalizedErrorWrapper? - @State private var isApplyingOffset = false + @State private var isApplyingItemSpacingOffset = false @State private var tempItemSpacingOffset: CGFloat = 0 - private var itemSpacingOffset: LocalizedStringKey { - localizedOffsetString(for: settings.itemSpacingOffset) - } - - private func localizedOffsetString(for offset: CGFloat) -> LocalizedStringKey { - switch offset { - case -16: - return LocalizedStringKey("none") - case 0: - return LocalizedStringKey("default") - case 16: - return LocalizedStringKey("max") - default: - return LocalizedStringKey(offset.formatted()) + private var itemSpacingOffsetKey: LocalizedStringKey { + switch tempItemSpacingOffset { + case -16: "none" + case 0: "default" + case 16: "max" + default: LocalizedStringKey(tempItemSpacingOffset.formatted()) } } @@ -41,14 +33,6 @@ struct GeneralSettingsPane: View { } } - private var hasSpacingSliderValueChanged: Bool { - tempItemSpacingOffset != settings.itemSpacingOffset - } - - private var isActualOffsetDifferentFromDefault: Bool { - settings.itemSpacingOffset != 0 - } - var body: some View { IceForm { IceSection { @@ -108,62 +92,82 @@ struct GeneralSettingsPane: View { @ViewBuilder private var iceIconOptions: some View { + showIceIcon + if settings.showIceIcon { + iceIconPicker + } + } + + @ViewBuilder + private var showIceIcon: some View { Toggle("Show Ice icon", isOn: $settings.showIceIcon) .annotation("Click to show hidden menu bar items. Right-click to access Ice's settings.") + } - if settings.showIceIcon { - IceMenu("Ice icon") { - Picker("Ice icon", selection: $settings.iceIcon) { - ForEach(ControlItemImageSet.userSelectableIceIcons) { imageSet in - Button { - settings.iceIcon = imageSet - } label: { - menuItem(for: imageSet) - } - .tag(imageSet) + @ViewBuilder + private var iceIconPicker: some View { + let labelKey = LocalizedStringKey("Ice icon") + + IceMenu(labelKey) { + Picker(labelKey, selection: $settings.iceIcon) { + ForEach(ControlItemImageSet.userSelectableIceIcons) { imageSet in + Button { + settings.iceIcon = imageSet + } label: { + menuItem(for: imageSet) } - if let lastCustomIceIcon = settings.lastCustomIceIcon { - Button { - settings.iceIcon = lastCustomIceIcon - } label: { - menuItem(for: lastCustomIceIcon) - } - .tag(lastCustomIceIcon) + .tag(imageSet) + } + if let lastCustomIceIcon = settings.lastCustomIceIcon { + Button { + settings.iceIcon = lastCustomIceIcon + } label: { + menuItem(for: lastCustomIceIcon) } + .tag(lastCustomIceIcon) } - .pickerStyle(.inline) - .labelsHidden() + } + .pickerStyle(.inline) + .labelsHidden() - Divider() + Divider() - Button("Choose image…") { - isImportingCustomIceIcon = true - } - } title: { - menuItem(for: settings.iceIcon) + Button("Choose image…") { + isImportingCustomIceIcon = true } - .annotation("Choose a custom icon to show in the menu bar.") - .fileImporter( - isPresented: $isImportingCustomIceIcon, - allowedContentTypes: [.image] - ) { result in - do { - let url = try result.get() - if url.startAccessingSecurityScopedResource() { - defer { url.stopAccessingSecurityScopedResource() } - let data = try Data(contentsOf: url) - settings.iceIcon = ControlItemImageSet(name: .custom, image: .data(data)) - } - } catch { - presentedError = LocalizedErrorWrapper(error) - isPresentingError = true + } title: { + menuItem(for: settings.iceIcon) + } + .annotation("Choose a custom icon to show in the menu bar.") + .fileImporter( + isPresented: $isImportingCustomIceIcon, + allowedContentTypes: [.image] + ) { result in + do { + let url = try result.get() + if url.startAccessingSecurityScopedResource() { + defer { url.stopAccessingSecurityScopedResource() } + let data = try Data(contentsOf: url) + settings.iceIcon = ControlItemImageSet(name: .custom, image: .data(data)) } + } catch { + presentedError = LocalizedErrorWrapper(error) + isPresentingError = true } + } - if case .custom = settings.iceIcon.name { - Toggle("Apply system theme to icon", isOn: $settings.customIceIconIsTemplate) - .annotation("Display the icon as a monochrome image matching the system appearance.") - } + if case .custom = settings.iceIcon.name { + Toggle("Custom icon uses system theme", isOn: $settings.customIceIconIsTemplate) + .annotation { + Text( + """ + Display the icon as a monochrome image that dynamically adjusts to match \ + the menu bar's appearance. This setting removes all color from the icon, \ + but ensures consistent rendering against both light and dark backgrounds. + """ + ) + .padding(.trailing, 50) + } } } @@ -215,41 +219,81 @@ struct GeneralSettingsPane: View { @ViewBuilder private var showOnScroll: some View { Toggle("Show on scroll", isOn: $settings.showOnScroll) - .annotation("Scroll or swipe in the menu bar to toggle hidden menu bar items.") + .annotation("Scroll or swipe in the menu bar to show hidden menu bar items.") + } + + @ViewBuilder + private var rehideStrategyPicker: some View { + IcePicker("Strategy", selection: $settings.rehideStrategy) { + ForEach(RehideStrategy.allCases) { strategy in + Text(strategy.localized).tag(strategy) + } + } + .annotation { + switch settings.rehideStrategy { + case .smart: + Text("Menu bar items are rehidden using a smart algorithm.") + case .timed: + Text("Menu bar items are rehidden after a fixed amount of time.") + case .focusedApp: + Text("Menu bar items are rehidden when the focused app changes.") + } + } + } + + @ViewBuilder + private var autoRehideOptions: some View { + Toggle("Automatically rehide", isOn: $settings.autoRehide) + if settings.autoRehide { + if case .timed = settings.rehideStrategy { + VStack { + rehideStrategyPicker + IceSlider( + rehideIntervalKey, + value: $settings.rehideInterval, + in: 0...30, + step: 1 + ) + } + } else { + rehideStrategyPicker + } + } } @ViewBuilder private var spacingOptions: some View { LabeledContent { IceSlider( - localizedOffsetString(for: tempItemSpacingOffset), + itemSpacingOffsetKey, value: $tempItemSpacingOffset, in: -16...16, step: 2 ) - .disabled(isApplyingOffset) + .disabled(isApplyingItemSpacingOffset) } label: { LabeledContent { Button("Apply") { - applyOffset() + applyTempItemSpacingOffset() } .help("Apply the current spacing") - .disabled(isApplyingOffset || !hasSpacingSliderValueChanged) + .disabled(isApplyingItemSpacingOffset || tempItemSpacingOffset == settings.itemSpacingOffset) - if isApplyingOffset { + if isApplyingItemSpacingOffset { ProgressView() .progressViewStyle(.circular) .scaleEffect(0.5) .frame(width: 15, height: 15) } else { Button { - resetOffsetToDefault() + tempItemSpacingOffset = 0 + applyTempItemSpacingOffset() } label: { Image(systemName: "arrow.counterclockwise.circle.fill") } .buttonStyle(.borderless) .help("Reset to the default spacing") - .disabled(isApplyingOffset || !isActualOffsetDifferentFromDefault) + .disabled(isApplyingItemSpacingOffset || settings.itemSpacingOffset == 0) } } label: { HStack { @@ -273,48 +317,8 @@ struct GeneralSettingsPane: View { } } - @ViewBuilder - private var rehideStrategyPicker: some View { - IcePicker("Strategy", selection: $settings.rehideStrategy) { - ForEach(RehideStrategy.allCases) { strategy in - Text(strategy.localized).tag(strategy) - } - } - .annotation { - switch settings.rehideStrategy { - case .smart: - Text("Menu bar items are rehidden using a smart algorithm.") - case .timed: - Text("Menu bar items are rehidden after a fixed amount of time.") - case .focusedApp: - Text("Menu bar items are rehidden when the focused app changes.") - } - } - } - - @ViewBuilder - private var autoRehideOptions: some View { - Toggle("Automatically rehide", isOn: $settings.autoRehide) - if settings.autoRehide { - if case .timed = settings.rehideStrategy { - VStack { - rehideStrategyPicker - IceSlider( - rehideIntervalKey, - value: $settings.rehideInterval, - in: 0...30, - step: 1 - ) - } - } else { - rehideStrategyPicker - } - } - } - - /// Apply menu bar spacing offset. - private func applyOffset() { - isApplyingOffset = true + private func applyTempItemSpacingOffset() { + isApplyingItemSpacingOffset = true settings.itemSpacingOffset = tempItemSpacingOffset Task { do { @@ -323,14 +327,7 @@ struct GeneralSettingsPane: View { let alert = NSAlert(error: error) alert.runModal() } - isApplyingOffset = false + isApplyingItemSpacingOffset = false } } - - /// Reset menu bar spacing offset to default. - private func resetOffsetToDefault() { - tempItemSpacingOffset = 0 - settings.itemSpacingOffset = tempItemSpacingOffset - applyOffset() - } } diff --git a/Ice/UI/IceUI/IceColorPicker.swift b/Ice/UI/IceUI/IceColorPicker.swift index dc76e047c..4b4032c56 100644 --- a/Ice/UI/IceUI/IceColorPicker.swift +++ b/Ice/UI/IceUI/IceColorPicker.swift @@ -128,20 +128,22 @@ private final class IceColorPickerCoordinator { func configure(with colorWell: NSColorWell) { var c = Set() - colorWell.publisher(for: \.color).removeDuplicates() + colorWell.publisher(for: \.color) + .removeDuplicates() + .map { $0.cgColor } .receive(on: DispatchQueue.main) - .sink { [weak self] color in + .sink { [weak self] selection in guard let self else { return } - let selection = color.cgColor if self.selection != selection { self.selection = selection } } .store(in: &c) - colorWell.publisher(for: \.isActive).removeDuplicates() + colorWell.publisher(for: \.isActive) + .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self] isActive in guard let self else { @@ -153,8 +155,11 @@ private final class IceColorPickerCoordinator { } .store(in: &c) - colorWell.publisher(for: \.window).publisher(for: \.isVisible) - .replaceNil(with: false).removeDuplicates() + colorWell.publisher(for: \.window) + .removeNil() + .flatMap { $0.publisher(for: \.isVisible) } + .replaceEmpty(with: false) + .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self] isVisible in guard let self else { diff --git a/Ice/UI/Utilities/IceGradient.swift b/Ice/UI/Utilities/IceGradient.swift index bd37ab9f0..2fdc4d042 100644 --- a/Ice/UI/Utilities/IceGradient.swift +++ b/Ice/UI/Utilities/IceGradient.swift @@ -116,7 +116,7 @@ struct IceGradient: Codable, Hashable { /// be an RGB color space, or this parameter is ignored. Pass `nil` /// to let the method decide the color space. /// - option: Options for computing the color. - func averageColor(using colorSpace: CGColorSpace? = nil, option: CGImage.ColorAverageOption = []) -> CGColor? { + func averageColor(using colorSpace: CGColorSpace? = nil, option: CGImage.ColorAveragingOption = []) -> CGColor? { guard !stops.isEmpty else { return nil } diff --git a/Ice/Utilities/Defaults.swift b/Ice/Utilities/Defaults.swift index f74926d14..e0d7f562f 100644 --- a/Ice/Utilities/Defaults.swift +++ b/Ice/Utilities/Defaults.swift @@ -137,27 +137,24 @@ enum Defaults { extension Defaults { enum Key: String { - // MARK: General Settings - case showIceIcon = "ShowIceIcon" case iceIcon = "IceIcon" case customIceIconIsTemplate = "CustomIceIconIsTemplate" case useIceBar = "UseIceBar" + case iceBarLocation = "IceBarLocation" case showOnClick = "ShowOnClick" case showOnHover = "ShowOnHover" case showOnScroll = "ShowOnScroll" - case itemSpacingOffset = "ItemSpacingOffset" case autoRehide = "AutoRehide" case rehideStrategy = "RehideStrategy" case rehideInterval = "RehideInterval" + case itemSpacingOffset = "ItemSpacingOffset" - // MARK: Hotkey Settings - + // MARK: Hotkeys Settings case hotkeys = "Hotkeys" // MARK: Advanced Settings - case enableAlwaysHiddenSection = "EnableAlwaysHiddenSection" case showAllSectionsOnUserDrag = "ShowAllSectionsOnUserDrag" case sectionDividerStyle = "SectionDividerStyle" @@ -166,17 +163,10 @@ extension Defaults { case showOnHoverDelay = "ShowOnHoverDelay" case tempShowInterval = "TempShowInterval" - // MARK: Menu Bar Appearance Settings - + // MARK: Appearance Settings case menuBarAppearanceConfigurationV2 = "MenuBarAppearanceConfigurationV2" - // MARK: Ice Bar Settings - - case iceBarLocation = "IceBarLocation" - case iceBarPinnedLocation = "IceBarPinnedLocation" - // MARK: Migration - case hasMigrated0_8_0 = "hasMigrated0_8_0" case hasMigrated0_10_0 = "hasMigrated0_10_0" case hasMigrated0_10_1 = "hasMigrated0_10_1" @@ -184,7 +174,7 @@ extension Defaults { case hasMigrated0_11_13 = "hasMigrated0_11_13" case hasMigrated0_11_13_1 = "hasMigrated0_11_13_1" - // MARK: Deprecated (Menu Bar Appearance) + // MARK: Deprecated (Appearance Settings) case menuBarHasBorder = "MenuBarHasBorder" case menuBarBorderColor = "MenuBarBorderColor" case menuBarBorderWidth = "MenuBarBorderWidth" @@ -197,12 +187,11 @@ extension Defaults { case menuBarSplitShapeInfo = "MenuBarSplitShapeInfo" case menuBarAppearanceConfiguration = "MenuBarAppearanceConfiguration" - // MARK: Deprecated (Advanced) + // MARK: Deprecated (Advanced Settings) case showSectionDividers = "ShowSectionDividers" case canToggleAlwaysHiddenSection = "CanToggleAlwaysHiddenSection" // MARK: Deprecated (Other) - case sections = "Sections" } } diff --git a/Ice/Utilities/Extensions.swift b/Ice/Utilities/Extensions.swift index 12e1483eb..b1589d45c 100644 --- a/Ice/Utilities/Extensions.swift +++ b/Ice/Utilities/Extensions.swift @@ -3,7 +3,6 @@ // Ice // -import AXSwift import Combine import SwiftUI @@ -69,15 +68,15 @@ extension CGColor { extension CGImage { - // MARK: Average Color + // MARK: Color Averaging /// Options that effect how colors are processed when computing /// an average color. - struct ColorAverageOption: OptionSet { + struct ColorAveragingOption: OptionSet { let rawValue: Int /// Includes the alpha component in the resulting average. - static let ignoreAlpha = ColorAverageOption(rawValue: 1 << 0) + static let ignoreAlpha = ColorAveragingOption(rawValue: 1 << 0) } /// Computes and returns the average color of the image. @@ -90,7 +89,7 @@ extension CGImage { /// Pixels with an alpha component greater than or equal to this value /// contribute to the average. /// - option: Options for computing the color. - func averageColor(using colorSpace: CGColorSpace? = nil, alphaThreshold: CGFloat = 0.5, option: ColorAverageOption = []) -> CGColor? { + func averageColor(using colorSpace: CGColorSpace? = nil, alphaThreshold: CGFloat = 0.5, option: ColorAveragingOption = []) -> CGColor? { func createPixelData(width: Int, height: Int, colorSpace: CGColorSpace) -> [UInt32]? { guard width > 0 && height > 0 else { return nil @@ -177,24 +176,28 @@ extension CGImage { return CGColor(colorSpace: colorSpace, components: &components) } - // MARK: Trim Transparent Pixels + // MARK: Transparency Trimming /// A context for handling transparency data in an image. private struct TransparencyContext: ~Copyable { private let image: CGImage - private let maxAlpha: UInt8 + private let alphaThreshold: CGFloat private let cgContext: CGContext + private let data: UnsafeMutableRawPointer private let zeroByteBlock: UnsafeMutableRawPointer - private let rowRange: LazySequence> - private let columnRange: LazySequence> + private let rowRange: Range + private let columnRange: Range /// Creates a context with the given image and alpha threshold. /// /// - Parameters: /// - image: The image to form a context around. - /// - maxAlpha: The maximum alpha value to consider transparent. - init?(image: CGImage, maxAlpha: UInt8) { + /// - alphaThreshold: The maximum alpha value to consider transparent. + init?(image: CGImage, alphaThreshold: CGFloat) { guard + image.width > 0, + image.height > 0, + alphaThreshold < 1, let cgContext = CGContext( data: nil, width: image.width, @@ -202,102 +205,115 @@ extension CGImage { bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceGray(), - bitmapInfo: CGImageAlphaInfo.alphaOnly.rawValue + bitmapInfo: CGBitmapInfo(alpha: .alphaOnly) ), - cgContext.data != nil, + let data = cgContext.data, let zeroByteBlock = calloc(image.width, MemoryLayout.size) else { return nil } - cgContext.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + let size = CGSize(width: image.width, height: image.height) + cgContext.draw(image, in: CGRect(origin: .zero, size: size)) self.image = image - self.maxAlpha = maxAlpha + self.alphaThreshold = alphaThreshold self.cgContext = cgContext + self.data = data self.zeroByteBlock = zeroByteBlock - self.rowRange = (0..) -> CGImage? { - guard - maxAlpha < 255, - !edges.isEmpty - else { + /// Returns an image derived from the context's image that has been + /// trimmed of transparency around the given edges. + func trim(around edges: Set) -> CGImage? { + guard !edges.isEmpty else { return image // Nothing to trim. } guard - let minYInset = inset(for: .minYEdge, in: edges), - let maxYInset = inset(for: .maxYEdge, in: edges), let minXInset = inset(for: .minXEdge, in: edges), - let maxXInset = inset(for: .maxXEdge, in: edges) + let minYInset = inset(for: .minYEdge, in: edges), + let maxXInset = inset(for: .maxXEdge, in: edges), + let maxYInset = inset(for: .maxYEdge, in: edges) else { return nil } - guard (minYInset, maxYInset, minXInset, maxXInset) != (0, 0, 0, 0) else { + guard (minXInset, minYInset, maxXInset, maxYInset) != (0, 0, 0, 0) else { return image // Already trimmed. } let insetRect = CGRect( x: minXInset, - y: maxYInset, - width: image.width - (minXInset + maxXInset), - height: image.height - (minYInset + maxYInset) + y: minYInset, + width: max(image.width - (minXInset + maxXInset), 0), + height: max(image.height - (minYInset + maxYInset), 0) ) return image.cropping(to: insetRect) } + /// Returns a Boolean value that indicates whether the context's + /// image is transparent. + func isTransparent() -> Bool { + rowRange.allSatisfy { row in + isRowTransparent(row: row) + } + } + private func inset(for edge: CGRectEdge, in edges: Set) -> Int? { guard edges.contains(edge) else { return 0 } return switch edge { - case .maxYEdge: - firstOpaqueRow(in: rowRange) - case .minYEdge: - firstOpaqueRow(in: rowRange.reversed()).map { (image.height - 1) - $0 } case .minXEdge: firstOpaqueColumn(in: columnRange) + case .minYEdge: + firstOpaqueRow(in: rowRange) case .maxXEdge: firstOpaqueColumn(in: columnRange.reversed()).map { (image.width - 1) - $0 } + case .maxYEdge: + firstOpaqueRow(in: rowRange.reversed()).map { (image.height - 1) - $0 } } } private func isPixelOpaque(row: Int, column: Int) -> Bool { - guard let bitmapData = cgContext.data else { + let rawAlpha = data.load( + fromByteOffset: (row * cgContext.bytesPerRow) + column, + as: UInt8.self + ) + let convertedAlpha = CGFloat(rawAlpha) / 255 + return convertedAlpha > alphaThreshold + } + + private func isRowTransparent(row: Int) -> Bool { + // Use memcmp to efficiently check the entire row for zeroed out alpha. + if memcmp(data + (row * cgContext.bytesPerRow), zeroByteBlock, image.width) == 0 { + return true + } + // Avoid checking individual pixels if we can. + if alphaThreshold == 0 { return false } - let rawAlpha = bitmapData.load(fromByteOffset: (row * cgContext.bytesPerRow) + column, as: UInt8.self) - return rawAlpha > maxAlpha + // Check each pixel in the row until we find one that is opaque. + return !columnRange.contains { column in + isPixelOpaque(row: row, column: column) + } } - private func firstOpaqueRow(in rowRange: S) -> Int? where S.Element == Int { - guard let bitmapData = cgContext.data else { - return nil - } - return rowRange.first { row in - // Use memcmp to efficiently check the entire row for zeroed out alpha. - let rowByteBlock = bitmapData + (row * cgContext.bytesPerRow) - if memcmp(rowByteBlock, zeroByteBlock, image.width) == 0 { - return true - } - // We found a non-zero row. Check each pixel until we find one that is opaque. - return columnRange.contains { column in - isPixelOpaque(row: row, column: column) - } + private func firstOpaqueRow(in rowRange: some Sequence) -> Int? { + rowRange.first { row in + !isRowTransparent(row: row) } } - private func firstOpaqueColumn(in columnRange: S) -> Int? where S.Element == Int { + private func firstOpaqueColumn(in columnRange: some Sequence) -> Int? { columnRange.first { column in rowRange.contains { row in isPixelOpaque(row: row, column: column) @@ -306,38 +322,33 @@ extension CGImage { } } - /// Returns an image that has been trimmed of transparency around the given edges. + /// Returns an image that has been trimmed of transparency around the + /// given edges. + /// + /// Each edge is trimmed up to the first row or column containing pixels + /// with an alpha component above the specified threshold. /// /// - Parameters: - /// - edges: The edges to trim from around the image. - /// - maxAlpha: The maximum alpha value to consider transparent. Pixels with alpha - /// values above this value will be considered opaque, and will therefore remain - /// in the image. - func trimmingTransparentPixels( - around edges: Set = [.minXEdge, .maxXEdge, .minYEdge, .maxYEdge], - maxAlpha: CGFloat = 0 + /// - edges: A set of edges to trim from around the image. + /// - alphaThreshold: The maximum alpha value to consider transparent. + func trimmingTransparency( + around edges: Set = [.minXEdge, .minYEdge, .maxXEdge, .maxYEdge], + alphaThreshold: CGFloat = 0 ) -> CGImage? { - let maxAlpha = UInt8(maxAlpha.clamped(to: 0...1) * 255) - let context = TransparencyContext(image: self, maxAlpha: maxAlpha) - return context?.trim(edges: edges) + guard let context = TransparencyContext(image: self, alphaThreshold: alphaThreshold) else { + return self + } + return context.trim(around: edges) } /// Returns a Boolean value that indicates whether the image is transparent. /// - /// - Parameter maxAlpha: The maximum alpha value to consider transparent. - /// Pixels with alpha values above this value will be considered opaque. - func isTransparent(maxAlpha: CGFloat = 0) -> Bool { - // FIXME: This needs a dedicated implementation instead of relying on `trimmingTransparentPixels` - trimmingTransparentPixels(maxAlpha: maxAlpha) == nil - } -} - -// MARK: - CGPoint - -extension CGPoint { - /// A string to use for logging purposes. - var logString: String { - String(describing: self) + /// - Parameter alphaThreshold: The maximum alpha value to consider transparent. + func isTransparent(alphaThreshold: CGFloat = 0) -> Bool { + guard let context = TransparencyContext(image: self, alphaThreshold: alphaThreshold) else { + return false + } + return context.isTransparent() } } @@ -354,19 +365,11 @@ extension Collection where Element == MenuBarItem { // MARK: - Comparable extension Comparable { - /// Clamps this value to the given limiting range. - /// - /// - Parameter limits: A range of values to clamp this value to. - mutating func clamp(to limits: ClosedRange) { - self = min(max(self, limits.lowerBound), limits.upperBound) - } - - /// Returns a copy of this value, clamped to the given limiting - /// range. + /// Returns a copy of this value, clamped to the given limiting range. /// /// - Parameter limits: A range of values to clamp the copy to. func clamped(to limits: ClosedRange) -> Self { - withMutableCopy(of: self) { $0.clamp(to: limits) } + min(max(self, limits.lowerBound), limits.upperBound) } } @@ -469,6 +472,14 @@ extension NSScreen { screens.first { $0.frame.contains(NSEvent.mouseLocation) } } + /// The screen with the active menu bar. + static var screenWithActiveMenuBar: NSScreen? { + guard let displayID = Bridging.getActiveMenuBarDisplayID() else { + return nil + } + return screens.first { $0.displayID == displayID } + } + /// The display identifier of the screen. var displayID: CGDirectDisplayID { // Value and type are guaranteed here, so force casting is okay. @@ -508,27 +519,28 @@ extension NSScreen { let displayBounds = CGDisplayBounds(displayID) guard - let menuBar = try? systemWideElement.elementAtPosition(displayBounds.origin), - let role = try? menuBar.role(), - role == .menuBar + let menuBar = AXHelpers.element(at: displayBounds.origin), + AXHelpers.role(for: menuBar) == .menuBar else { return nil } - let applicationMenuFrame = menuBar.children.reduce(CGRect.null) { result, child in - guard child.isEnabled, let childFrame = child.frame else { - return result + let applicationMenuFrame = AXHelpers.children(for: menuBar).reduce(into: CGRect.null) { result, child in + if AXHelpers.isEnabled(child), let childFrame = AXHelpers.frame(for: child) { + result = result.union(childFrame) } - return result.union(childFrame) } - if applicationMenuFrame.width <= 0 { + if applicationMenuFrame.width <= 0 || applicationMenuFrame.isNull { return nil } - // The Accessibility API returns the menu bar for the active screen, regardless of the - // display origin used. This workaround prevents an incorrect frame from being returned - // for inactive displays in multi-display setups where one display has a notch. + // FIXME: The Accessibility API always returns the menu bar for the main screen. + // This can cause issues if one of the screens has a notch, since long app menus + // can display items the trailing side of the notch. This causes the frame to be + // invalid for all other screens. For now, we're working around this by checking + // the app menu's frame on inactive screens, and returning `nil` if it overlaps + // with the notch. if let mainScreen = NSScreen.main, self != mainScreen, @@ -594,47 +606,6 @@ extension Publisher { mergeReplace(other, with: ()) } - func removeDuplicates() -> Publishers.RemoveDuplicates where Output == (repeat each T) { - removeDuplicates { lhs, rhs in - for (left, right) in repeat (each lhs, each rhs) { - guard left == right else { return false } - } - return true - } - } -} - -extension Publisher { - func publisher( - for keyPath: KeyPath, - options: NSKeyValueObservingOptions = [.initial, .new] - ) -> some Publisher where Output: NSObject { - flatMap { $0.publisher(for: keyPath, options: options) } - } - - func publisher( - for keyPath: KeyPath, - options: NSKeyValueObservingOptions = [.initial, .new] - ) -> some Publisher where Output == Wrapped? { - flatMap { $0.publisher } - .flatMap { $0.publisher(for: keyPath, options: options) } - .map { $0 as Value? } - .replaceEmpty(with: nil) - } - - func publisher( - for keyPath: KeyPath, - options: NSKeyValueObservingOptions = [.initial, .new] - ) -> some Publisher where Output == Wrapped? { - flatMap { $0.publisher } - .flatMap { $0.publisher(for: keyPath, options: options) } - .replaceEmpty(with: nil) - } -} - -// MARK: - Publisher where Output: Sequence, Failure == Never - -extension Publisher where Output: Sequence, Failure == Never { /// Transforms the elements of the upstream sequence into publishers and /// merges the results. /// @@ -643,11 +614,23 @@ extension Publisher where Output: Sequence, Failure == Never { /// /// - Returns: A publisher that emits an event when any upstream publisher /// emits an event. - func mergeMap(_ transform: @escaping (Output.Element) -> P) -> some Publisher { + func mergeMap( + _ transform: @escaping (Output.Element) -> P + ) -> some Publisher where Output: Sequence, Failure == Never { flatMap { sequence in Publishers.MergeMany(sequence.map(transform)) } } + + /// Publishes only elements that don't match the previous element. + func removeDuplicates() -> Publishers.RemoveDuplicates where Output == (repeat each T) { + removeDuplicates { lhs, rhs in + for (left, right) in repeat (each lhs, each rhs) { + guard left == right else { return false } + } + return true + } + } } // MARK: - RangeReplaceableCollection where Element: Hashable @@ -658,18 +641,13 @@ extension RangeReplaceableCollection where Element: Hashable { var seen = Set() return filter { seen.insert($0).inserted } } - - /// Removes duplicate values from the collection. - mutating func removeDuplicates() { - self = self.removingDuplicates() - } } // MARK: - RangeReplaceableCollection where Element == MenuBarItem extension RangeReplaceableCollection where Element == MenuBarItem { - /// Removes and returns the first menu bar item that matches the - /// specified tag. + /// Removes and returns the first menu bar item that matches + /// the specified tag. mutating func removeFirst(matching tag: MenuBarItemTag) -> MenuBarItem? { guard let index = firstIndex(matching: tag) else { return nil @@ -686,31 +664,3 @@ extension Sequence where Element == MenuBarItem { first { $0.tag == tag } } } - -// MARK: - SystemWideElement - -extension SystemWideElement { - /// Returns the element at the specified top-down coordinates. - func elementAtPosition(_ point: CGPoint) throws -> UIElement? { - try elementAtPosition(Float(point.x), Float(point.y)) - } -} - -// MARK: - UIElement - -extension UIElement { - /// The element's child elements. - var children: [UIElement] { - (try? arrayAttribute(.children)) ?? [] - } - - /// The element's frame. - var frame: CGRect? { - try? attribute(.frame) - } - - /// A Boolean value that indicates whether the element is enabled. - var isEnabled: Bool { - (try? attribute(.enabled)) == true - } -} diff --git a/MenuBarItemService/SourcePIDCache.swift b/MenuBarItemService/SourcePIDCache.swift index 6aa272ce8..6203181f6 100644 --- a/MenuBarItemService/SourcePIDCache.swift +++ b/MenuBarItemService/SourcePIDCache.swift @@ -11,16 +11,16 @@ import os /// A cache for the source process identifiers for menu bar item windows. /// /// We use the term "source process" to refer to the process that created -/// a given menu bar item. Originally, we could use the CGWindowList API, -/// as the item window's `kCGWindowOwnerPID` was always equivalent to the -/// source process identifier. However, as of macOS 26, all item windows -/// are owned by the Control Center. +/// a menu bar item. Originally, we used the CGWindowList API to get the +/// window's owning process (`kCGWindowOwnerPID`), which was always the +/// source process. However, as of macOS 26, item windows are owned by +/// the Control Center. /// -/// We can still what we need using the Accessibility API, but doing it -/// efficiently ends up being fairly complex. It doesn't help that calls -/// to Accessibility are thread blocking. We resolve this by doing most -/// of the heavy lifting in a dedicated XPC service, which we then call -/// asynchronously from the main app. +/// We can find what we need using the Accessibility API, but doing it +/// efficiently ends up being a fairly complex process. Since calls to +/// Accessibility are thread blocking, we do most of the heavy lifting +/// in a dedicated XPC service, which we then call asynchronously from +/// the main app. final class SourcePIDCache { /// An object that contains a running application and provides an /// interface to access relevant information, such as its process diff --git a/MenuBarItemService/AXHelpers.swift b/Shared/Utilities/AXHelpers.swift similarity index 65% rename from MenuBarItemService/AXHelpers.swift rename to Shared/Utilities/AXHelpers.swift index ba63e0378..d97c26a13 100644 --- a/MenuBarItemService/AXHelpers.swift +++ b/Shared/Utilities/AXHelpers.swift @@ -1,6 +1,6 @@ // // AXHelpers.swift -// MenuBarItemService +// Shared // import AXSwift @@ -9,12 +9,17 @@ import Cocoa enum AXHelpers { private static let queue = DispatchQueue.targetingGlobal( label: "AXHelpers.queue", - qos: .utility, + qos: .userInteractive, attributes: .concurrent ) - static func isProcessTrusted() -> Bool { - queue.sync { checkIsProcessTrusted(prompt: false) } + @discardableResult + static func isProcessTrusted(prompt: Bool = false) -> Bool { + queue.sync { checkIsProcessTrusted(prompt: prompt) } + } + + static func element(at point: CGPoint) -> UIElement? { + queue.sync { try? systemWideElement.elementAtPosition(Float(point.x), Float(point.y)) } } static func application(for runningApp: NSRunningApplication) -> Application? { @@ -36,4 +41,8 @@ enum AXHelpers { static func frame(for element: UIElement) -> CGRect? { queue.sync { try? element.attribute(.frame) } } + + static func role(for element: UIElement) -> Role? { + queue.sync { try? element.role() } + } } diff --git a/Shared/Utilities/SharedExtensions.swift b/Shared/Utilities/SharedExtensions.swift index 7f0affdcb..f52bd021a 100644 --- a/Shared/Utilities/SharedExtensions.swift +++ b/Shared/Utilities/SharedExtensions.swift @@ -53,7 +53,7 @@ extension DispatchQueue { /// system queue with the specified quality-of-service class. static func targetingGlobal( label: String, - qos: DispatchQoS.QoSClass, + qos: DispatchQoS.QoSClass = .default, attributes: Attributes = [] ) -> DispatchQueue { let target = DispatchQueue.global(qos: qos) diff --git a/Shared/Utilities/WindowInfo.swift b/Shared/Utilities/WindowInfo.swift index 9b68fa333..434faf02c 100644 --- a/Shared/Utilities/WindowInfo.swift +++ b/Shared/Utilities/WindowInfo.swift @@ -28,8 +28,8 @@ struct WindowInfo { /// a localized name. let ownerName: String? - /// A Boolean value that indicates whether the window is on screen. - let isOnScreen: Bool + /// A Boolean value that indicates whether the window is onscreen. + let isOnscreen: Bool /// The application that owns the window. var owningApplication: NSRunningApplication? { @@ -60,7 +60,7 @@ struct WindowInfo { self.layer = layer self.title = info[kCGWindowName] as? String self.ownerName = info[kCGWindowOwnerName] as? String - self.isOnScreen = info[kCGWindowIsOnscreen] as? Bool ?? false + self.isOnscreen = info[kCGWindowIsOnscreen] as? Bool ?? false } /// Creates a window with the given window identifier. @@ -142,7 +142,7 @@ extension WindowInfo { return windows.first { window in // Menu bar window belongs to the WindowServer process. window.isWindowServerWindow && - window.isOnScreen && + window.isOnscreen && window.layer == kCGMainMenuWindowLevel && window.title == "Menubar" && displayBounds.contains(window.bounds) @@ -167,7 +167,7 @@ extension WindowInfo: Equatable { lhs.layer == rhs.layer && lhs.title == rhs.title && lhs.ownerName == rhs.ownerName && - lhs.isOnScreen == rhs.isOnScreen + lhs.isOnscreen == rhs.isOnscreen } } @@ -180,6 +180,6 @@ extension WindowInfo: Hashable { hasher.combine(layer) hasher.combine(title) hasher.combine(ownerName) - hasher.combine(isOnScreen) + hasher.combine(isOnscreen) } } From 4b7d748692a38314f4f49f6ef8776e81c1275293 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Sun, 31 Aug 2025 16:34:27 -0600 Subject: [PATCH 52/80] Improve menu bar item handling --- .../MenuBarItems/MenuBarItemManager.swift | 321 ++++++++++-------- 1 file changed, 174 insertions(+), 147 deletions(-) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index 99af24b9c..1e544c94a 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -10,7 +10,7 @@ import OSLog /// Manager for menu bar items. @MainActor final class MenuBarItemManager: ObservableObject { - /// The manager's menu bar item cache. + /// The current cache of menu bar items. @Published private(set) var itemCache = ItemCache(displayID: nil) /// Actor for managing menu bar item cache operations. @@ -25,8 +25,8 @@ final class MenuBarItemManager: ObservableObject { /// A timer for rehiding temporarily shown menu bar items. private var rehideTimer: Timer? - /// A timestamp representing the start of the latest menu bar item - /// move operation. + /// A timestamp representing the start of the most recent menu bar + /// item move operation. private var latestMoveOperationTimestamp: ContinuousClock.Instant? /// Storage for internal observers. @@ -111,8 +111,8 @@ extension MenuBarItemManager { /// Storage for cached menu bar items, keyed by section. private var storage = [MenuBarSection.Name: [MenuBarItem]]() - /// The identifier of the display with the active menu bar at the - /// time this cache was created. + /// The identifier of the display with the active menu bar at + /// the time this cache was created. let displayID: CGDirectDisplayID? /// The cached menu bar items as an array. @@ -261,9 +261,13 @@ extension MenuBarItemManager { } } - /// Caches the given menu bar items, without ensuring that the control - /// items are in the correct order. - private func uncheckedCacheItems(items: [MenuBarItem], controlItems: ControlItemPair, displayID: CGDirectDisplayID?) { + /// Caches the given menu bar items, without ensuring that the provided + /// control items are correctly ordered. + private func uncheckedCacheItems( + items: [MenuBarItem], + controlItems: ControlItemPair, + displayID: CGDirectDisplayID? + ) { var context = CacheContext(controlItems: controlItems, displayID: displayID) for item in items where context.isValidForCaching(item) { @@ -302,8 +306,8 @@ extension MenuBarItemManager { logger.debug("Updated menu bar item cache") } - /// Caches the current menu bar items, regardless of the current item - /// state, ensuring that the control items are in the correct order. + /// Caches the current menu bar items regardless of the current item + /// state, ensuring that the control items are correctly ordered. func cacheItemsRegardless(_ currentItemWindowIDs: [CGWindowID]? = nil) async { await cacheActor.runCacheTask { [weak self] in guard let self else { @@ -328,7 +332,7 @@ extension MenuBarItemManager { } /// Caches the current menu bar items if needed, ensuring that the - /// control items are in the correct order. + /// control items are correctly ordered. func cacheItemsIfNeeded() async { guard !latestMoveOperationStarted(within: .seconds(1)) else { logger.debug("Skipping menu bar item cache due to recent item movement") @@ -482,9 +486,15 @@ extension MenuBarItemManager { } } - /// Waits for the given duration, without throwing an error if cancelled. + /// Waits for the given duration between event operations. + /// + /// Since most event operations must perform cleanup or otherwise + /// run to completion, this method ignores task cancellation. private nonisolated func eventSleep(for duration: Duration = .milliseconds(25)) async { - try? await Task.sleep(for: duration) + let task = Task { + try? await Task.sleep(for: duration) + } + await task.value } /// Returns the current bounds for the given item. @@ -545,7 +555,7 @@ extension MenuBarItemManager { source.localEventsSuppressionInterval = suppressionInterval } - /// Does a lot of weird magic to make a menu bar item receive an event. + /// Casts forbidden magic to make a menu bar item receive an event. /// /// - Parameters: /// - event: The event to post. @@ -578,9 +588,9 @@ extension MenuBarItemManager { let timeoutTask = Task(timeout: timeout * count) { try await withCheckedThrowingContinuation { continuation in - // Create a tap at the first location for the entry and exit events. - // On entry, decrement `counter` and forward the real event. On exit, - // resume the continuation. + // Create a tap for the entry and exit events at the first location. + // On entry, decrement the count and post the real event. + // On exit, resume the continuation. let eventTap1 = EventTap( label: "EventTap 1", type: .null, @@ -601,9 +611,9 @@ extension MenuBarItemManager { return rEvent } - // Create a tap for the real event at the second location. If `counter` - // has reached zero, post the exit event. Otherwise, repost the entry - // event to continue. + // Create a tap for the real event at the second location. If the + // count has reached zero, post the exit event. Otherwise, repost + // the entry event to go around again. let eventTap2 = EventTap( label: "EventTap 2", type: event.type, @@ -679,15 +689,15 @@ extension MenuBarItemManager { /// Returns the timeout duration for moving the given item. private nonisolated func getTimeout(forMoving item: MenuBarItem) -> Duration { if item.isBentoBox { - // Bento Boxes (i.e. Control Center groups) take a little - // longer to respond. + // Bento Boxes (i.e. Control Center groups) generally take + // a little longer to respond. return .milliseconds(100) } return .milliseconds(25) } - /// Returns the target points for creating the events needed to move - /// a menu bar item to the given destination. + /// Returns the target points for creating the events needed to + /// move a menu bar item to the given destination. private nonisolated func getTargetPoints( forMoving item: MenuBarItem, to destination: MoveDestination @@ -734,36 +744,36 @@ extension MenuBarItemManager { } } - /// Waits for a menu bar item's bounds to change in response to - /// a series of posted events. + /// Waits for a menu bar item to respond to a series of previously + /// posted move events. /// /// - Parameters: - /// - item: The item to check for bounds changes. - /// - initialBounds: The bounds of the item before the events were posted. + /// - item: The item to check for a response. + /// - initialOrigin: The origin of the item before the events were posted. /// - timeout: The duration to wait before throwing an error. - private nonisolated func waitForResponse( + private nonisolated func waitForMoveEventResponse( from item: MenuBarItem, - initialBounds: CGRect, + initialOrigin: CGPoint, timeout: Duration - ) async throws -> CGRect { - let boundsTask = Task.detached(timeout: timeout) { + ) async throws -> CGPoint { + let responseTask = Task.detached(timeout: timeout) { while true { try Task.checkCancellation() - let bounds = try await self.getCurrentBounds(for: item) - if bounds != initialBounds { - return bounds + let origin = try await self.getCurrentBounds(for: item).origin + if origin != initialOrigin { + return origin } } } do { - let bounds = try await boundsTask.value + let origin = try await responseTask.value logger.debug( """ - Item responded with new bounds origin: \ - \(String(describing: bounds.origin), privacy: .public) + Item responded to events with new origin: \ + \(String(describing: origin), privacy: .public) """ ) - return bounds + return origin } catch let error as EventError { throw error } catch is TaskTimeoutError { @@ -788,8 +798,9 @@ extension MenuBarItemManager { source: CGEventSource, timeout: Duration ) async throws { - var itemBounds = try await getCurrentBounds(for: item) + var itemOrigin = try await getCurrentBounds(for: item).origin let targetPoints = try await getTargetPoints(forMoving: item, to: destination) + let mouseLocation = try getMouseLocation(item: item) let pid = getEventPID(for: item) guard @@ -815,8 +826,6 @@ extension MenuBarItemManager { latestMoveOperationTimestamp = .now } - let mouseLocation = try getMouseLocation(item: item) - MouseHelpers.hideCursor() defer { MouseHelpers.warpCursor(to: mouseLocation) @@ -824,8 +833,6 @@ extension MenuBarItemManager { } do { - logger.debug("Posting move events") - try await scrombleEvent( moveEvent1, from: .pid(pid), @@ -833,9 +840,9 @@ extension MenuBarItemManager { item: item, timeout: timeout ) - itemBounds = try await waitForResponse( + itemOrigin = try await waitForMoveEventResponse( from: item, - initialBounds: itemBounds, + initialOrigin: itemOrigin, timeout: timeout ) try await self.scrombleEvent( @@ -846,17 +853,14 @@ extension MenuBarItemManager { timeout: timeout, repeating: 2 // Double mouse up prevents invalid item state. ) - itemBounds = try await self.waitForResponse( + itemOrigin = try await waitForMoveEventResponse( from: item, - initialBounds: itemBounds, + initialOrigin: itemOrigin, timeout: timeout ) } catch { do { - logger.debug("Move events failed, posting fallback event") - - // Catch this for logging purposes only. We want to propagate the - // original error. + logger.debug("Move events failed, posting fallback") try await self.scrombleEvent( moveEvent2, from: .pid(pid), @@ -866,9 +870,10 @@ extension MenuBarItemManager { repeating: 2 // Double mouse up prevents invalid item state. ) } catch { - logger.error("Fallback event failed with error: \(error, privacy: .public)") + // Catch this for logging purposes only. We want to propagate + // the original error. + logger.error("Fallback failed with error: \(error, privacy: .public)") } - throw error } } @@ -879,6 +884,10 @@ extension MenuBarItemManager { /// - item: The menu bar item to move. /// - destination: The destination to move the item to. func move(item: MenuBarItem, to destination: MoveDestination) async throws { + guard try await !itemHasCorrectPosition(item: item, for: destination) else { + logger.log("\(item.logString, privacy: .public) already has correct position") + return + } guard item.isMovable else { throw EventError(code: .itemNotMovable, item: item) } @@ -908,6 +917,7 @@ extension MenuBarItemManager { } let source = try getEventSource(for: item) + let timeout = getTimeout(forMoving: item) logger.log( """ @@ -917,7 +927,6 @@ extension MenuBarItemManager { ) let maxAttempts = 10 - let timeout = getTimeout(forMoving: item) moveLoop: for n in 1...maxAttempts { guard !Task.isCancelled else { @@ -956,29 +965,37 @@ extension MenuBarItemManager { // MARK: - Clicking Items extension MenuBarItemManager { - /// Clicks a menu bar item with the given mouse button. + /// Returns the equivalent event subtypes for clicking a menu bar + /// item with the given mouse button. + private nonisolated func getClickSubtypes( + for mouseButton: CGMouseButton + ) -> (down: MenuBarItemEventType.ClickSubtype, up: MenuBarItemEventType.ClickSubtype) { + switch mouseButton { + case .left: (.leftMouseDown, .leftMouseUp) + case .right: (.rightMouseDown, .rightMouseUp) + default: (.otherMouseDown, .otherMouseUp) + } + } + + /// Creates and posts a series of events to click a menu bar item. /// /// - Parameters: /// - item: The menu bar item to click. /// - mouseButton: The mouse button to click the item with. - func click(item: MenuBarItem, with mouseButton: CGMouseButton) async throws { - guard let appState else { - throw EventError(code: .cannotComplete, item: item) - } - - do { - try await waitForUserToPauseInput() - } catch { - throw EventError(code: .cannotComplete, item: item) - } - - let source = try getEventSource(for: item) - let itemBounds = try await getCurrentBounds(for: item) + /// - source: The event source used to create the events. + /// - timeout: The duration for each individual operation to wait + /// before throwing an error. + private nonisolated func postClickEvents( + item: MenuBarItem, + mouseButton: CGMouseButton, + source: CGEventSource, + timeout: Duration + ) async throws { + let clickPoint = try await getCurrentBounds(for: item).center + let mouseLocation = try getMouseLocation(item: item) + let clickTypes = getClickSubtypes(for: mouseButton) let pid = getEventPID(for: item) - let clickTypes = mouseButton.clickTypes - let clickPoint = itemBounds.center - guard let clickEvent1 = CGEvent.menuBarItemEvent( source: source, @@ -998,37 +1015,12 @@ extension MenuBarItemManager { throw EventError(code: .eventCreationFailure, item: item) } - try permitAllEvents( - for: .combinedSessionState, - during: [ - .eventSuppressionStateRemoteMouseDrag, - .eventSuppressionStateSuppressionInterval, - ], - suppressionInterval: 0, - item: item - ) - - appState.eventManager.stopAll() - defer { - appState.eventManager.startAll() - } - - let mouseLocation = try getMouseLocation(item: item) - let timeout = Duration.milliseconds(250) - MouseHelpers.hideCursor() defer { MouseHelpers.warpCursor(to: mouseLocation) MouseHelpers.showCursor() } - logger.log( - """ - Clicking \(item.logString, privacy: .public) with \ - \(mouseButton.logString, privacy: .public) - """ - ) - do { try await scrombleEvent( clickEvent1, @@ -1047,10 +1039,7 @@ extension MenuBarItemManager { ) } catch { do { - logger.debug("Click events failed, posting fallback event") - - // Catch this for logging purposes only. We want to propagate the - // original error. + logger.debug("Click events failed, posting fallback") try await scrombleEvent( clickEvent2, from: .pid(pid), @@ -1060,11 +1049,61 @@ extension MenuBarItemManager { repeating: 2 // Double mouse up prevents invalid item state. ) } catch { - logger.error("Fallback event failed with error: \(error, privacy: .public)") + // Catch this for logging purposes only. We want to propagate + // the original error. + logger.error("Fallback failed with error: \(error, privacy: .public)") } - throw error } + } + + /// Clicks a menu bar item with the given mouse button. + /// + /// - Parameters: + /// - item: The menu bar item to click. + /// - mouseButton: The mouse button to click the item with. + func click(item: MenuBarItem, with mouseButton: CGMouseButton) async throws { + guard let appState else { + throw EventError(code: .cannotComplete, item: item) + } + + do { + try await waitForUserToPauseInput() + } catch { + throw EventError(code: .cannotComplete, item: item) + } + + try permitAllEvents( + for: .combinedSessionState, + during: [ + .eventSuppressionStateRemoteMouseDrag, + .eventSuppressionStateSuppressionInterval, + ], + suppressionInterval: 0, + item: item + ) + + appState.eventManager.stopAll() + defer { + appState.eventManager.startAll() + } + + let source = try getEventSource(for: item) + let timeout = Duration.milliseconds(250) + + logger.log( + """ + Clicking \(item.logString, privacy: .public) with \ + \(mouseButton.logString, privacy: .public) + """ + ) + + try await postClickEvents( + item: item, + mouseButton: mouseButton, + source: source, + timeout: timeout + ) logger.log("Successfully clicked \(item.logString, privacy: .public)") } @@ -1097,7 +1136,6 @@ extension MenuBarItemManager { // Window no longer exists, so assume closed. return false } - print(current.layer) if current.layer != CGWindowLevelForKey(.popUpMenuWindow), current.layer != CGWindowLevelForKey(.statusWindow), @@ -1152,7 +1190,7 @@ extension MenuBarItemManager { /// Temporarily shows the given item. /// - /// The item is cached and returned to its original destination after the + /// The item is cached and returned to its original location after the /// time interval specified by ``AdvancedSettings/tempShowInterval``. /// /// - Parameters: @@ -1268,6 +1306,10 @@ extension MenuBarItemManager { continue } do { + MouseHelpers.hideCursor() + defer { + MouseHelpers.showCursor() + } try await move(item: item, to: context.returnDestination) if try await !itemHasCorrectPosition(item: item, for: context.returnDestination) { throw EventError(code: .incorrectPositionAfterMove, item: item) @@ -1307,10 +1349,8 @@ extension MenuBarItemManager { } } - /// Removes a temporarily shown item from the cache. - /// - /// This ensures that the item will _not_ be returned to its - /// previous location. + /// Removes a temporarily shown item from the cache, ensuring that + /// the item is _not_ returned to its original location. func removeTemporarilyShownItemFromCache(with tag: MenuBarItemTag) { while let index = temporarilyShownItemContexts.firstIndex(where: { $0.tag == tag }) { logger.debug( @@ -1349,14 +1389,14 @@ extension MenuBarItemManager { } } -// MARK: - Event Types +// MARK: - MenuBarItemEventType /// Event types for menu bar item events. private enum MenuBarItemEventType { /// The event type for moving a menu bar item. - case move(MoveEventType) + case move(MoveSubtype) /// The event type for clicking a menu bar item. - case click(ClickEventType) + case click(ClickSubtype) var cgEventType: CGEventType { switch self { @@ -1378,12 +1418,11 @@ private enum MenuBarItemEventType { case .click(let subtype): subtype.cgMouseButton } } -} -// MARK: Move Subtype -extension MenuBarItemEventType { + // MARK: Subtypes + /// Subtype for menu bar item move events. - enum MoveEventType { + enum MoveSubtype { case mouseDown case mouseUp @@ -1394,12 +1433,9 @@ extension MenuBarItemEventType { } } } -} -// MARK: Click Subtype -extension MenuBarItemEventType { /// Subtype for menu bar item click events. - enum ClickEventType { + enum ClickSubtype { case leftMouseDown case leftMouseUp case rightMouseDown @@ -1503,15 +1539,6 @@ private extension CGMouseButton { @unknown default: "unknown mouse button" } } - - /// The equivalent down and up mouse types for menu bar item click events. - var clickTypes: (down: MenuBarItemEventType.ClickEventType, up: MenuBarItemEventType.ClickEventType) { - switch self { - case .left: (.leftMouseDown, .leftMouseUp) - case .right: (.rightMouseDown, .rightMouseUp) - default: (.otherMouseDown, .otherMouseUp) - } - } } // MARK: - CGEvent Helpers @@ -1559,26 +1586,14 @@ private extension CGEvent { return event } - /// Returns a Boolean value that indicates whether the given integer - /// fields on this event are equivalent to the same integer fields on - /// the given event. - /// - /// - Parameters: - /// - other: The event to compare with this event. - /// - fields: The integer fields to check. - func matches(_ other: CGEvent, byIntegerFields fields: [CGEventField]) -> Bool { - fields.allSatisfy { field in - getIntegerValueField(field) == other.getIntegerValueField(field) - } - } - /// Posts the event to the given event tap location. /// /// - Parameter location: The event tap location to post the event to. func post(to location: EventTap.Location) { + let type = self.type Logger.menuBarItemManager.debug( """ - Posting \(self.type.logString, privacy: .public) \ + Posting \(type.logString, privacy: .public) \ to \(location.logString, privacy: .public) """ ) @@ -1590,6 +1605,19 @@ private extension CGEvent { } } + /// Returns a Boolean value that indicates whether the given integer + /// fields from this event are equivalent to the same integer fields + /// from the specified event. + /// + /// - Parameters: + /// - other: The event to compare with this event. + /// - fields: The integer fields to check. + func matches(_ other: CGEvent, byIntegerFields fields: [CGEventField]) -> Bool { + fields.allSatisfy { field in + getIntegerValueField(field) == other.getIntegerValueField(field) + } + } + private func setFlags(for type: MenuBarItemEventType) { flags = type.cgEventFlags } @@ -1616,14 +1644,13 @@ private extension CGEvent { } private func setClickState(for type: MenuBarItemEventType) { - guard case .click(let subtype) = type else { - return + if case .click(let subtype) = type { + setIntegerValueField(.mouseEventClickState, value: subtype.clickState) } - setIntegerValueField(.mouseEventClickState, value: subtype.clickState) } } -// MARK: - Logger +// MARK: - Logger Helpers private extension Logger { /// Logger for the menu bar item manager. From 37722ee5e4af2db4127d3ea87347450504bab565 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Tue, 2 Sep 2025 13:13:00 -0600 Subject: [PATCH 53/80] Misc refactoring --- .../MenuBarAppearanceEditor.swift | 3 +- .../MenuBarItems/MenuBarItemImageCache.swift | 8 +- Ice/MenuBar/MenuBarManager.swift | 23 +-- .../SettingsPanes/GeneralSettingsPane.swift | 149 +++++++++--------- Ice/Settings/SettingsView.swift | 71 ++++----- Ice/Settings/SettingsWindow.swift | 14 +- Ice/UI/IceUI/IceGroupBox.swift | 6 +- Ice/Utilities/Extensions.swift | 62 ++++---- 8 files changed, 149 insertions(+), 187 deletions(-) diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift index 18d5316d4..80a1cdafe 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift @@ -94,7 +94,8 @@ struct MenuBarAppearanceEditor: View { } } .controlSize(.large) - .padding(10) + .padding(.vertical, 10) + .padding(mainFormPadding.horizontal) if case .panel = location { stack.background(.ultraThickMaterial) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift index 759a4af27..b8a8dfc6c 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift @@ -280,14 +280,10 @@ final class MenuBarItemImageCache: ObservableObject { if !isIceBarPresented && !isSearchPresented { guard + await appState.navigationState.isAppFrontmost, await appState.navigationState.isSettingsPresented, - case .menuBarLayout = await appState.navigationState.settingsNavigationIdentifier + await appState.navigationState.settingsNavigationIdentifier == .menuBarLayout else { - logger.debug("Skipping item image cache as interface not presented") - return - } - guard await appState.navigationState.isAppFrontmost else { - logger.debug("Skipping item image cache as app not frontmost") return } } diff --git a/Ice/MenuBar/MenuBarManager.swift b/Ice/MenuBar/MenuBarManager.swift index e0186e7ad..b90678ea2 100644 --- a/Ice/MenuBar/MenuBarManager.swift +++ b/Ice/MenuBar/MenuBarManager.swift @@ -57,12 +57,6 @@ final class MenuBarManager: ObservableObject { MenuBarSection(name: .alwaysHidden), ] - /// A Boolean value that indicates whether the manager can update its stored - /// information for the menu bar's average color. - private var canUpdateAverageColorInfo: Bool { - settingsWindow?.isVisible == true - } - /// A Boolean value that indicates whether at least one of the manager's /// sections is visible. var hasVisibleSection: Bool { @@ -143,17 +137,11 @@ final class MenuBarManager: ObservableObject { .store(in: &c) $settingsWindow - .flatMap { $0.publisher } // Short circuit if nil. + .removeNil() .flatMap { $0.publisher(for: \.isVisible) } + .discardMerge(Timer.publish(every: 5, on: .main, in: .default).autoconnect()) .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.updateAverageColorInfo() - } - .store(in: &c) - - Timer.publish(every: 5, on: .main, in: .default) - .autoconnect() - .sink { [weak self] _ in + .sink { [weak self] in self?.updateAverageColorInfo() } .store(in: &c) @@ -236,8 +224,9 @@ final class MenuBarManager: ObservableObject { /// of the menu bar. func updateAverageColorInfo() { guard - canUpdateAverageColorInfo, - let screen = settingsWindow?.screen + let settingsWindow, + settingsWindow.isVisible, + let screen = settingsWindow.screen else { return } diff --git a/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift b/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift index 4ec9527a4..10189e055 100644 --- a/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift @@ -36,7 +36,7 @@ struct GeneralSettingsPane: View { var body: some View { IceForm { IceSection { - launchAtLogin + appOptions } IceSection { iceIconOptions @@ -45,50 +45,25 @@ struct GeneralSettingsPane: View { iceBarOptions } IceSection { - showOnClick - showOnHover - showOnScroll + showOptions } IceSection { - autoRehideOptions + rehideOptions } IceSection { spacingOptions } } - .alert(isPresented: $isPresentingError, error: presentedError) { - Button("OK") { - presentedError = nil - isPresentingError = false - } - } } + // MARK: App Options + @ViewBuilder - private var launchAtLogin: some View { + private var appOptions: some View { LaunchAtLogin.Toggle() } - @ViewBuilder - private func menuItem(for imageSet: ControlItemImageSet) -> some View { - Label { - Text(imageSet.name.rawValue) - } icon: { - if let nsImage = imageSet.hidden.nsImage(for: appState) { - switch imageSet.name { - case .custom: - Image(size: CGSize(width: 18, height: 18)) { context in - context.draw( - Image(nsImage: nsImage), - in: context.clipBoundingRect - ) - } - default: - Image(nsImage: nsImage) - } - } - } - } + // MARK: Ice Icon Options @ViewBuilder private var iceIconOptions: some View { @@ -114,7 +89,7 @@ struct GeneralSettingsPane: View { Button { settings.iceIcon = imageSet } label: { - menuItem(for: imageSet) + iceIconMenuItem(for: imageSet) } .tag(imageSet) } @@ -122,7 +97,7 @@ struct GeneralSettingsPane: View { Button { settings.iceIcon = lastCustomIceIcon } label: { - menuItem(for: lastCustomIceIcon) + iceIconMenuItem(for: lastCustomIceIcon) } .tag(lastCustomIceIcon) } @@ -136,7 +111,7 @@ struct GeneralSettingsPane: View { isImportingCustomIceIcon = true } } title: { - menuItem(for: settings.iceIcon) + iceIconMenuItem(for: settings.iceIcon) } .annotation("Choose a custom icon to show in the menu bar.") .fileImporter( @@ -155,15 +130,21 @@ struct GeneralSettingsPane: View { isPresentingError = true } } + .alert(isPresented: $isPresentingError, error: presentedError) { + Button("OK") { + presentedError = nil + isPresentingError = false + } + } if case .custom = settings.iceIcon.name { - Toggle("Custom icon uses system theme", isOn: $settings.customIceIconIsTemplate) + Toggle("Custom icon uses dynamic appearance", isOn: $settings.customIceIconIsTemplate) .annotation { Text( """ Display the icon as a monochrome image that dynamically adjusts to match \ the menu bar's appearance. This setting removes all color from the icon, \ - but ensures consistent rendering against both light and dark backgrounds. + but ensures consistent rendering with both light and dark backgrounds. """ ) .padding(.trailing, 50) @@ -171,6 +152,26 @@ struct GeneralSettingsPane: View { } } + @ViewBuilder + private func iceIconMenuItem(for imageSet: ControlItemImageSet) -> some View { + Label { + Text(imageSet.name.rawValue) + } icon: { + if let nsImage = imageSet.hidden.nsImage(for: appState) { + switch imageSet.name { + case .custom: + Image(size: CGSize(width: 18, height: 18)) { context in + context.draw(Image(nsImage: nsImage), in: context.clipBoundingRect) + } + default: + Image(nsImage: nsImage) + } + } + } + } + + // MARK: Ice Bar Options + @ViewBuilder private var iceBarOptions: some View { useIceBar @@ -204,63 +205,65 @@ struct GeneralSettingsPane: View { } } + // MARK: Show Options + @ViewBuilder - private var showOnClick: some View { + private var showOptions: some View { Toggle("Show on click", isOn: $settings.showOnClick) .annotation("Click inside an empty area of the menu bar to show hidden menu bar items.") + Toggle("Show on hover", isOn: $settings.showOnHover) + .annotation("Hover over an empty area of the menu bar to show hidden menu bar items.") + Toggle("Show on scroll", isOn: $settings.showOnScroll) + .annotation("Scroll or swipe in the menu bar to show hidden menu bar items.") } + // MARK: Rehide Options + @ViewBuilder - private var showOnHover: some View { - Toggle("Show on hover", isOn: $settings.showOnHover) - .annotation("Hover over an empty area of the menu bar to show hidden menu bar items.") + private var rehideOptions: some View { + autoRehide + if settings.autoRehide { + rehideStrategyPicker + } } @ViewBuilder - private var showOnScroll: some View { - Toggle("Show on scroll", isOn: $settings.showOnScroll) - .annotation("Scroll or swipe in the menu bar to show hidden menu bar items.") + private var autoRehide: some View { + Toggle("Automatically rehide", isOn: $settings.autoRehide) } @ViewBuilder private var rehideStrategyPicker: some View { - IcePicker("Strategy", selection: $settings.rehideStrategy) { - ForEach(RehideStrategy.allCases) { strategy in - Text(strategy.localized).tag(strategy) + VStack { + IcePicker("Strategy", selection: $settings.rehideStrategy) { + ForEach(RehideStrategy.allCases) { strategy in + Text(strategy.localized).tag(strategy) + } } - } - .annotation { - switch settings.rehideStrategy { - case .smart: - Text("Menu bar items are rehidden using a smart algorithm.") - case .timed: - Text("Menu bar items are rehidden after a fixed amount of time.") - case .focusedApp: - Text("Menu bar items are rehidden when the focused app changes.") + .annotation { + switch settings.rehideStrategy { + case .smart: + Text("Menu bar items are rehidden using a smart algorithm.") + case .timed: + Text("Menu bar items are rehidden after a fixed amount of time.") + case .focusedApp: + Text("Menu bar items are rehidden when the focused app changes.") + } } - } - } - @ViewBuilder - private var autoRehideOptions: some View { - Toggle("Automatically rehide", isOn: $settings.autoRehide) - if settings.autoRehide { if case .timed = settings.rehideStrategy { - VStack { - rehideStrategyPicker - IceSlider( - rehideIntervalKey, - value: $settings.rehideInterval, - in: 0...30, - step: 1 - ) - } - } else { - rehideStrategyPicker + IceSlider( + rehideIntervalKey, + value: $settings.rehideInterval, + in: 0...30, + step: 1 + ) } } } + // MARK: Spacing Options + @ViewBuilder private var spacingOptions: some View { LabeledContent { diff --git a/Ice/Settings/SettingsView.swift b/Ice/Settings/SettingsView.swift index 9bc972527..82c201528 100644 --- a/Ice/Settings/SettingsView.swift +++ b/Ice/Settings/SettingsView.swift @@ -9,9 +9,7 @@ struct SettingsView: View { @EnvironmentObject var appState: AppState @ObservedObject var navigationState: AppNavigationState @Environment(\.appearsActive) private var appearsActive - @Environment(\.colorScheme) private var colorScheme @Environment(\.sidebarRowSize) private var sidebarRowSize - @State private var usesHardScrollEdgeEffect = false private let sidebarPadding: CGFloat = 3 @@ -52,15 +50,7 @@ struct SettingsView: View { } private var sidebarTextStyle: some ShapeStyle { - if colorScheme == .dark { - AnyShapeStyle(Color(nsColor: appearsActive ? .labelColor : .secondaryLabelColor)) - } else { - AnyShapeStyle(appearsActive ? .primary : .secondary) - } - } - - private var sidebarIconStyle: some ShapeStyle { - HierarchicalShapeStyle.primary.opacity(appearsActive ? 1 : 0.67) + appearsActive ? .primary : .secondary } private var navigationTitle: LocalizedStringKey { @@ -76,17 +66,6 @@ struct SettingsView: View { .navigationTitle(navigationTitle) } - @ToolbarContentBuilder - private var sidebarToolbarSpacer: some ToolbarContent { - if #available(macOS 26.0, *) { - ToolbarSpacer(.flexible) - } else { - ToolbarItem { - Spacer(minLength: 0) - } - } - } - @ViewBuilder private var sidebar: some View { List(selection: $navigationState.settingsNavigationIdentifier) { @@ -111,16 +90,37 @@ struct SettingsView: View { .navigationSplitViewColumnWidth(sidebarWidth) } + @ViewBuilder + private func sidebarItem(for identifier: SettingsNavigationIdentifier) -> some View { + Label { + Text(identifier.localized) + .font(.system(size: sidebarFontSize)) + .foregroundStyle(sidebarTextStyle) + } icon: { + identifier.iconResource.view + .foregroundStyle(sidebarTextStyle) + .padding(sidebarPadding) + } + .frame(height: sidebarItemHeight) + .tag(identifier) + } + + @ToolbarContentBuilder + private var sidebarToolbarSpacer: some ToolbarContent { + if #available(macOS 26.0, *) { + ToolbarSpacer(.flexible) + } else { + ToolbarItem { + Spacer(minLength: 0) + } + } + } + @ViewBuilder private var detailView: some View { if #available(macOS 26.0, *) { settingsPane - .onScrollGeometryChange(for: Bool.self) { geometry in - geometry.visibleRect.minY > -geometry.contentInsets.top - } action: { _, isScrolledPastTop in - usesHardScrollEdgeEffect = isScrolledPastTop - } - .scrollEdgeEffectStyle(usesHardScrollEdgeEffect ? .hard : .soft, for: .top) + .scrollEdgeEffectStyle(.hard, for: .top) } else { settingsPane } @@ -143,19 +143,4 @@ struct SettingsView: View { AboutSettingsPane(updatesManager: appState.updatesManager) } } - - @ViewBuilder - private func sidebarItem(for identifier: SettingsNavigationIdentifier) -> some View { - Label { - Text(identifier.localized) - .font(.system(size: sidebarFontSize)) - .foregroundStyle(sidebarTextStyle) - } icon: { - identifier.iconResource.view - .foregroundStyle(sidebarIconStyle) - .padding(sidebarPadding) - } - .frame(height: sidebarItemHeight) - .tag(identifier) - } } diff --git a/Ice/Settings/SettingsWindow.swift b/Ice/Settings/SettingsWindow.swift index 0c5b283ff..e9e94948e 100644 --- a/Ice/Settings/SettingsWindow.swift +++ b/Ice/Settings/SettingsWindow.swift @@ -14,27 +14,17 @@ struct SettingsWindow: Scene { var body: some Scene { IceWindow(id: .settings) { - settingsView + SettingsView(navigationState: appState.navigationState) .onWindowChange { window in model.observeWindowToolbar(window) } - .frame(minWidth: 825, minHeight: 500) + .frame(minWidth: 825, maxWidth: 1150, minHeight: 500, maxHeight: 750) } .commandsRemoved() .windowResizability(.contentSize) .defaultSize(width: 900, height: 625) .environmentObject(appState) } - - @ViewBuilder - private var settingsView: some View { - if #available(macOS 26.0, *) { - SettingsView(navigationState: appState.navigationState) - .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) - } else { - SettingsView(navigationState: appState.navigationState) - } - } } // MARK: - SettingsWindowModel diff --git a/Ice/UI/IceUI/IceGroupBox.swift b/Ice/UI/IceUI/IceGroupBox.swift index 55b4d66d9..e9cd5c137 100644 --- a/Ice/UI/IceUI/IceGroupBox.swift +++ b/Ice/UI/IceUI/IceGroupBox.swift @@ -21,9 +21,9 @@ struct IceGroupBox: View { private var borderStyle: some ShapeStyle { if #available(macOS 26.0, *) { - AnyShapeStyle(.clear) + AnyShapeStyle(Color.clear) } else { - AnyShapeStyle(.quaternary) + AnyShapeStyle(Color.primary.quaternary) } } @@ -171,7 +171,7 @@ struct IceGroupBox: View { .padding(padding) .background { backgroundShape - .fill(.quinary.opacity(0.75)) + .fill(Color.primary.quinary) .strokeBorder(borderStyle) } .containerShape(backgroundShape) diff --git a/Ice/Utilities/Extensions.swift b/Ice/Utilities/Extensions.swift index b1589d45c..d5efe297c 100644 --- a/Ice/Utilities/Extensions.swift +++ b/Ice/Utilities/Extensions.swift @@ -383,7 +383,19 @@ extension DistributedNotificationCenter { // MARK: - EdgeInsets extension EdgeInsets { - /// Creates edge insets with the given floating point value. + /// A copy of this instance with only the leading and trailing + /// edges set. + var horizontal: EdgeInsets { + EdgeInsets(top: 0, leading: leading, bottom: 0, trailing: trailing) + } + + /// A copy of this instance with only the top and bottom + /// edges set. + var vertical: EdgeInsets { + EdgeInsets(top: top, leading: 0, bottom: bottom, trailing: 0) + } + + /// Creates an instance with all edges set to the given value. init(all: CGFloat) { self.init(top: all, leading: all, bottom: all, trailing: all) } @@ -572,42 +584,38 @@ extension NSStatusItem { // MARK: - Publisher extension Publisher { - /// Replaces all elements from the upstream publisher using the - /// provided closure. + /// Replaces each upstream element with an element returned from + /// the given closure. /// - /// - Parameter transform: A closure that returns an element to + /// - Parameter output: A closure that returns a new element to /// publish in place of the upstream element. - func replace(_ transform: @escaping () -> T) -> Publishers.Map { - map { _ in transform() } + func replace(_ output: @escaping () -> T) -> Publishers.Map { + map { _ in output() } } - /// Replaces all elements from the upstream publisher with the - /// provided element. + /// Replaces each upstream element with the given element. /// - /// - Parameter output: An element to publish in place of the - /// upstream element. + /// - Parameter output: A new element to publish in place of the + /// upstream elements. func replace(with output: T) -> Publishers.Map { replace { output } } + /// Publishes only non-`nil` elements. func removeNil() -> Publishers.CompactMap where Output == T? { compactMap { $0 } } - func mergeReplace(_ other: P, with output: T) -> Publishers.Merge, Publishers.Map> { - replace(with: output).merge(with: other.replace(with: output)) - } - - func mergeReplace(_ other: P, transform: @escaping () -> T) -> Publishers.Merge, Publishers.Map> { - replace(transform).merge(with: other.replace(transform)) - } - - func discardMerge(_ other: P) -> Publishers.Merge, Publishers.Map> { - mergeReplace(other, with: ()) + /// Merges this publisher with the given publisher, replacing upstream + /// elements with `Void` values. + /// + /// - Parameter other: Another publisher. + func discardMerge(_ other: P) -> some Publisher where P.Failure == Failure { + replace(with: ()).merge(with: other.replace(with: ())) } - /// Transforms the elements of the upstream sequence into publishers and - /// merges the results. + /// Transforms the elements of the upstream sequence into a sequence of + /// publishers and merges the results. /// /// - Parameter transform: A closure that takes an element of the upstream /// sequence as a parameter and returns a publisher. @@ -621,16 +629,6 @@ extension Publisher { Publishers.MergeMany(sequence.map(transform)) } } - - /// Publishes only elements that don't match the previous element. - func removeDuplicates() -> Publishers.RemoveDuplicates where Output == (repeat each T) { - removeDuplicates { lhs, rhs in - for (left, right) in repeat (each lhs, each rhs) { - guard left == right else { return false } - } - return true - } - } } // MARK: - RangeReplaceableCollection where Element: Hashable From c474aeceed5ad4ba63d6bad27b392abbc22ab2a0 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Tue, 2 Sep 2025 22:53:02 -0600 Subject: [PATCH 54/80] Improve menu bar item handling --- Ice.xcodeproj/project.pbxproj | 17 ++++ .../xcshareddata/swiftpm/Package.resolved | 11 ++- .../MenuBarItems/MenuBarItemManager.swift | 89 ++++++++++++------- 3 files changed, 82 insertions(+), 35 deletions(-) diff --git a/Ice.xcodeproj/project.pbxproj b/Ice.xcodeproj/project.pbxproj index 23b19a59d..5d4bf2538 100644 --- a/Ice.xcodeproj/project.pbxproj +++ b/Ice.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 7127A9FF2C4886D100D99DEF /* IfritStatic in Frameworks */ = {isa = PBXBuildFile; productRef = 7127A9FE2C4886D100D99DEF /* IfritStatic */; }; 7168EE532E281CBC00FF9830 /* AXSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 7168EE522E281CBC00FF9830 /* AXSwift */; }; 7188A68C2E27F9ED008F131D /* MenuBarItemService.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 7188A6832E27F9ED008F131D /* MenuBarItemService.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 71A065092E680C620087DB38 /* Semaphore in Frameworks */ = {isa = PBXBuildFile; productRef = 71A065082E680C620087DB38 /* Semaphore */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -79,6 +80,7 @@ 175061912B1543DD003144CD /* LaunchAtLogin in Frameworks */, 1787C4272B16890B002F50DF /* AXSwift in Frameworks */, 17F71BB52B880B4500905CBA /* CompactSlider in Frameworks */, + 71A065092E680C620087DB38 /* Semaphore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -141,6 +143,7 @@ 170423D82B56DE78004A2549 /* Sparkle */, 17F71BB42B880B4500905CBA /* CompactSlider */, 7127A9FE2C4886D100D99DEF /* IfritStatic */, + 71A065082E680C620087DB38 /* Semaphore */, ); productName = Ice; productReference = 7166832A2A767E6A006ABF84 /* Ice.app */; @@ -203,6 +206,7 @@ 170423D72B56DE78004A2549 /* XCRemoteSwiftPackageReference "Sparkle" */, 17F71BB32B880B4500905CBA /* XCRemoteSwiftPackageReference "CompactSlider" */, 7127A9FB2C4881BC00D99DEF /* XCRemoteSwiftPackageReference "Ifrit" */, + 71A065072E680C620087DB38 /* XCRemoteSwiftPackageReference "Semaphore" */, ); productRefGroup = 7166832B2A767E6A006ABF84 /* Products */; projectDirPath = ""; @@ -594,6 +598,14 @@ minimumVersion = 2.0.3; }; }; + 71A065072E680C620087DB38 /* XCRemoteSwiftPackageReference "Semaphore" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/groue/Semaphore"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.1.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -627,6 +639,11 @@ package = 1787C4252B16890B002F50DF /* XCRemoteSwiftPackageReference "AXSwift" */; productName = AXSwift; }; + 71A065082E680C620087DB38 /* Semaphore */ = { + isa = XCSwiftPackageProductDependency; + package = 71A065072E680C620087DB38 /* XCRemoteSwiftPackageReference "Semaphore" */; + productName = Semaphore; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 716683222A767E6A006ABF84 /* Project object */; diff --git a/Ice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Ice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 797d6ea30..f12204193 100644 --- a/Ice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Ice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a7567d11f06745371832127a8ce2132148ef6a89fb55ecc72d6c313b688387fa", + "originHash" : "1220f1c3edc195fb1042614abebdfaa9e827392e81a19c77d52e34f846070cc4", "pins" : [ { "identity" : "axswift", @@ -37,6 +37,15 @@ "version" : "1.1.0" } }, + { + "identity" : "semaphore", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/Semaphore", + "state" : { + "revision" : "2543679282aa6f6c8ecf2138acd613ed20790bc2", + "version" : "0.1.0" + } + }, { "identity" : "sparkle", "kind" : "remoteSourceControl", diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index 1e544c94a..7b924a5c1 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -6,6 +6,7 @@ import Cocoa import Combine import OSLog +import Semaphore /// Manager for menu bar items. @MainActor @@ -13,6 +14,12 @@ final class MenuBarItemManager: ObservableObject { /// The current cache of menu bar items. @Published private(set) var itemCache = ItemCache(displayID: nil) + /// Logger for the menu bar item manager. + private nonisolated let logger = Logger.menuBarItemManager + + /// Semaphore to prevent overlapping event operations. + private nonisolated let eventSemaphore = AsyncSemaphore(value: 1) + /// Actor for managing menu bar item cache operations. private let cacheActor = CacheActor() @@ -25,19 +32,19 @@ final class MenuBarItemManager: ObservableObject { /// A timer for rehiding temporarily shown menu bar items. private var rehideTimer: Timer? - /// A timestamp representing the start of the most recent menu bar - /// item move operation. + /// A timestamp representing the start of the most recent menu + /// bar item move operation. private var latestMoveOperationTimestamp: ContinuousClock.Instant? + /// Cached timeouts for move operations. + private var moveOperationTimeouts = [MenuBarItemTag: Duration]() + /// Storage for internal observers. private var cancellables = Set() /// The shared app state. private(set) weak var appState: AppState? - /// Logger for the menu bar item manager. - private nonisolated var logger: Logger { .menuBarItemManager } - /// Sets up the manager. func performSetup(with appState: AppState) async { self.appState = appState @@ -375,7 +382,7 @@ extension MenuBarItemManager { let waitTask = Task(timeout: timeout) { while true { try Task.checkCancellation() - if self.hasUserPausedInput(for: duration) { + if hasUserPausedInput(for: duration) { break } try await Task.sleep(for: duration * 2) @@ -396,16 +403,10 @@ extension MenuBarItemManager { case cannotComplete /// A failure during the creation of an event. case eventCreationFailure - /// A failure during an event operation. - case eventOperationFailure /// A timeout during an event operation. case eventOperationTimeout - /// A menu bar item has an incorrect position after being moved. - case incorrectPositionAfterMove /// An event source cannot be created or is otherwise invalid. case invalidEventSource - /// A menu bar item is invalid. - case invalidItem /// A menu bar item is not movable. case itemNotMovable /// A timeout waiting for a menu bar item to respond to an event. @@ -420,11 +421,8 @@ extension MenuBarItemManager { switch self { case .cannotComplete: "cannotComplete" case .eventCreationFailure: "eventCreationFailure" - case .eventOperationFailure: "eventOperationFailure" case .eventOperationTimeout: "eventOperationTimeout" - case .incorrectPositionAfterMove: "incorrectPositionAfterMove" case .invalidEventSource: "invalidEventSource" - case .invalidItem: "invalidItem" case .itemNotMovable: "itemNotMovable" case .itemResponseTimeout: "itemResponseTimeout" case .missingItemBounds: "missingItemBounds" @@ -459,16 +457,10 @@ extension MenuBarItemManager { #"Operation could not be completed for "\#(item.displayName)""# case .eventCreationFailure: #"Failed to create event for "\#(item.displayName)""# - case .eventOperationFailure: - #"Event operation failed for "\#(item.displayName)""# case .eventOperationTimeout: - #"Event operation timed out for "\#(item.displayName)""# - case .incorrectPositionAfterMove: - #""\#(item.displayName)" has an incorrect position after being moved"# + #"Timeout sending events to "\#(item.displayName)""# case .invalidEventSource: #"Invalid event source for "\#(item.displayName)""# - case .invalidItem: - #""\#(item.displayName)" is invalid"# case .itemNotMovable: #""\#(item.displayName)" is not movable"# case .itemResponseTimeout: @@ -576,6 +568,10 @@ extension MenuBarItemManager { timeout: Duration, repeating count: Int = 1 ) async throws { + MouseHelpers.hideCursor() + defer { + MouseHelpers.showCursor() + } guard let entryEvent = CGEvent.uniqueNullEvent(), let exitEvent = CGEvent.uniqueNullEvent() @@ -686,8 +682,12 @@ extension MenuBarItemManager { } } - /// Returns the timeout duration for moving the given item. - private nonisolated func getTimeout(forMoving item: MenuBarItem) -> Duration { + /// Returns the timeout for move operations associated with the + /// given item. + private func getMoveOperationTimeout(for item: MenuBarItem) -> Duration { + if let timeout = moveOperationTimeouts[item.tag] { + return timeout + } if item.isBentoBox { // Bento Boxes (i.e. Control Center groups) generally take // a little longer to respond. @@ -696,6 +696,12 @@ extension MenuBarItemManager { return .milliseconds(25) } + /// Updates the timeout for move operations associated with the + /// given item. + private func updateMoveOperationTimeout(_ timeout: Duration, for item: MenuBarItem) { + moveOperationTimeouts[item.tag] = min(timeout, .milliseconds(100)) + } + /// Returns the target points for creating the events needed to /// move a menu bar item to the given destination. private nonisolated func getTargetPoints( @@ -756,6 +762,10 @@ extension MenuBarItemManager { initialOrigin: CGPoint, timeout: Duration ) async throws -> CGPoint { + MouseHelpers.hideCursor() + defer { + MouseHelpers.showCursor() + } let responseTask = Task.detached(timeout: timeout) { while true { try Task.checkCancellation() @@ -798,6 +808,11 @@ extension MenuBarItemManager { source: CGEventSource, timeout: Duration ) async throws { + try await eventSemaphore.waitUnlessCancelled() + defer { + eventSemaphore.signal() + } + var itemOrigin = try await getCurrentBounds(for: item).origin let targetPoints = try await getTargetPoints(forMoving: item, to: destination) let mouseLocation = try getMouseLocation(item: item) @@ -845,7 +860,7 @@ extension MenuBarItemManager { initialOrigin: itemOrigin, timeout: timeout ) - try await self.scrombleEvent( + try await scrombleEvent( moveEvent2, from: .pid(pid), to: .sessionEventTap, @@ -861,7 +876,7 @@ extension MenuBarItemManager { } catch { do { logger.debug("Move events failed, posting fallback") - try await self.scrombleEvent( + try await scrombleEvent( moveEvent2, from: .pid(pid), to: .sessionEventTap, @@ -917,7 +932,11 @@ extension MenuBarItemManager { } let source = try getEventSource(for: item) - let timeout = getTimeout(forMoving: item) + var timeout = getMoveOperationTimeout(for: item) + + defer { + updateMoveOperationTimeout(timeout, for: item) + } logger.log( """ @@ -944,13 +963,17 @@ extension MenuBarItemManager { source: source, timeout: timeout ) + timeout -= timeout / 2 } catch where n < maxAttempts { logger.debug("Attempt \(n, privacy: .public) failed: \(error, privacy: .public)") await eventSleep() + timeout += timeout / 2 continue moveLoop } catch let error as EventError { + timeout += timeout / 2 throw error } catch { + timeout += timeout / 2 throw EventError(code: .cannotComplete, item: item) } @@ -991,6 +1014,11 @@ extension MenuBarItemManager { source: CGEventSource, timeout: Duration ) async throws { + try await eventSemaphore.waitUnlessCancelled() + defer { + eventSemaphore.signal() + } + let clickPoint = try await getCurrentBounds(for: item).center let mouseLocation = try getMouseLocation(item: item) let clickTypes = getClickSubtypes(for: mouseButton) @@ -1306,14 +1334,7 @@ extension MenuBarItemManager { continue } do { - MouseHelpers.hideCursor() - defer { - MouseHelpers.showCursor() - } try await move(item: item, to: context.returnDestination) - if try await !itemHasCorrectPosition(item: item, for: context.returnDestination) { - throw EventError(code: .incorrectPositionAfterMove, item: item) - } } catch { context.rehideAttempts += 1 logger.warning( From 39db26841e2e46d13115d7a16dce737690bcad9f Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Wed, 3 Sep 2025 04:25:10 -0600 Subject: [PATCH 55/80] Misc refactoring --- Ice/MenuBar/ControlItem/ControlItem.swift | 37 +++++++---------------- Ice/MenuBar/MenuBarSection.swift | 12 ++++++++ 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/Ice/MenuBar/ControlItem/ControlItem.swift b/Ice/MenuBar/ControlItem/ControlItem.swift index d54c6084b..41c4b92eb 100644 --- a/Ice/MenuBar/ControlItem/ControlItem.swift +++ b/Ice/MenuBar/ControlItem/ControlItem.swift @@ -542,7 +542,6 @@ final class ControlItem { action: #selector(showSearchPanel), keyEquivalent: "" ) - searchItem.target = self if let hotkey = hotkey(withAction: .searchMenuBarItems), let keyCombination = hotkey.keyCombination @@ -550,18 +549,17 @@ final class ControlItem { searchItem.keyEquivalent = keyCombination.key.keyEquivalent searchItem.keyEquivalentModifierMask = keyCombination.modifiers.nsEventFlags } + searchItem.target = self menu.addItem(searchItem) menu.addItem(.separator()) - // Add menu items to toggle the hidden and always-hidden sections. - let sectionNames: [MenuBarSection.Name] = [.hidden, .alwaysHidden] - for name in sectionNames { + // Add items to toggle the hidden and always-hidden sections. + for name: MenuBarSection.Name in [.hidden, .alwaysHidden] { guard let section = appState.menuBarManager.section(withName: name), - section.controlItem.isAddedToMenuBar + section.isEnabled else { - // Section doesn't exist, or is disabled. continue } let item = NSMenuItem( @@ -569,28 +567,15 @@ final class ControlItem { action: #selector(toggleMenuBarSection), keyEquivalent: "" ) + if + let hotkey = section.hotkey, + let keyCombination = hotkey.keyCombination + { + item.keyEquivalent = keyCombination.key.keyEquivalent + item.keyEquivalentModifierMask = keyCombination.modifiers.nsEventFlags + } item.target = self item.representedObject = section - switch name { - case .visible: - break - case .hidden: - if - let hotkey = hotkey(withAction: .toggleHiddenSection), - let keyCombination = hotkey.keyCombination - { - item.keyEquivalent = keyCombination.key.keyEquivalent - item.keyEquivalentModifierMask = keyCombination.modifiers.nsEventFlags - } - case .alwaysHidden: - if - let hotkey = hotkey(withAction: .toggleAlwaysHiddenSection), - let keyCombination = hotkey.keyCombination - { - item.keyEquivalent = keyCombination.key.keyEquivalent - item.keyEquivalentModifierMask = keyCombination.modifiers.nsEventFlags - } - } menu.addItem(item) } diff --git a/Ice/MenuBar/MenuBarSection.swift b/Ice/MenuBar/MenuBarSection.swift index db83ca61b..6dab9fc80 100644 --- a/Ice/MenuBar/MenuBarSection.swift +++ b/Ice/MenuBar/MenuBarSection.swift @@ -112,6 +112,18 @@ final class MenuBarSection { return controlItem.isAddedToMenuBar } + /// The hotkey to toggle the section. + var hotkey: Hotkey? { + guard let hotkeys = appState?.settings.hotkeys else { + return nil + } + return switch name { + case .visible: nil + case .hidden: hotkeys.hotkey(withAction: .toggleHiddenSection) + case .alwaysHidden: hotkeys.hotkey(withAction: .toggleAlwaysHiddenSection) + } + } + /// Creates a section with the given name and control item. init(name: Name, controlItem: ControlItem) { self.name = name From 2f66c01740502aca5f7de1447519f48ceff66a9d Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Wed, 3 Sep 2025 04:52:48 -0600 Subject: [PATCH 56/80] Add Semaphore package to acknowledgements --- Ice/Resources/Acknowledgements.pdf | Bin 33602 -> 35883 bytes Ice/Resources/Acknowledgements.rtf | 35 +++++++++++++++++++++++------ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/Ice/Resources/Acknowledgements.pdf b/Ice/Resources/Acknowledgements.pdf index 8c8d58211b2638940dba95c1401c029b94392a20..6cb4a9ffd198c3ece1e9470f361ed3a9b1d03326 100644 GIT binary patch delta 29365 zcmZ^}1xy`X+cgZOxD|JIcXxMpch>_4ms8yJ;O=h4i%Wsx?hd6m6nFT0-_QFe-}~hI zGRdB6*6d6&naRG^UTY16f2z*-lqdq?=byA$UVB>TNpj$>Mh((h1*b zktYq@S)>c$$Nl6)#5vwDGz;p($0MNwxDxc>#Z+zUP!B?0b8YsO;J0 z5oz4QLZcGCkw;7q{Y>u3K+kf8dK%G)H`v_H2`{ydqd_aJra zH;FLI0@ZvJ%3%-;sf-~}K9owK06Vh4K;9ic?ktyzbC9K{uVAEaEshR~bx^pO>%Y`9 zSe@`}Zu`!ylLNmDcpquu!(MJRFoww?JzXMXBCRiB2~|U-!9i>5c8RugNxM+*xs7h# z)|`X?g>pc#UP7TgWE_063TGSIe6$8KR=23^`?6Z)vI1#&vkn zbS#eiUgu`3hifvqxi+gMm`$mdR-z%#jz84&cpr6P^JYhkdJ5!;v5>#CWj&d`*ylf> zyDL|@{W8sNn7H-(%`!{zvQh==i|N#ajIQ-t!%IxNlf!TN3J)I85`OffJeHnsy2!m+ zLO<d|w5A;Q%3Ka*o&+ z3;8lQF~#a_6vQx4GLq5Hx-Zc(DbK(>iF!R@1(@(h{tF@8=?H1m>im&WO1=Om?uiufoPs~IK^D-EbAuG+i^J(ZA1B2F!CRr!QnCp|s#_H|_|-3Ra}-3cnTv*DHW6y^h@ps-GzG7xf3h?%REXt| zAM}}(Y#)W$y~u)mXg&9k(VMKs#mi(W>0E6|hqiB`mcoI&<3F5@+eW56o7HN&G4RID znWgECxXeERVo2lUo<_P#FT2Q?RJbb#z97CaIOZ>cP%SVuGTmnByl5dx8#kDPx9dB^ z>Sc17!_swXS^H`SJexcMuv2{h0a5==02>S3nehQADo8j(s+zck_CDRc&fkYa| z3)Z_JP~YuV5Q$b|Q;u8*IdJ-WlmJ1TiG=F(L@sn7;+Ld~^SzOfg2FDuxU7c! zR;=)Id}>xW9O`_WO60cQ#;pUpc5RPr(+J2U!eQw?M=Vx{umkRRfL-_Z;s%=rTD$5u zP_Q+>&k^eN7BAj#Q*9%I;lwh^!&l6uQNuSB7{hkvvy69Ts6YV{u2$kA?KAJLTxb>v ze-1=rrr_*aZB0-Hg1+ z%?*#)Ef1@+2viOGEvpN?R{deF8CDF?pjC@siLQg&b01r~7BXW9x@Z?2*I0eg)l7Yp zOo%9k@4W8i$*ow){lg+Bxhvk7RY zCY=~iZwx6O@Me@Mn)z8^OfhBi)x9LJ?GoF>PYSh(uk=obIdt`?ZC?s5*{q@85$9Ymyi2@ zHS|f{b?3dG*a3$Ot=B2CC9X#P1TFd_kmVN0ki)#W^XkTM89R0j^DBzfA_Cj&<1pwL z1r{Cd{4~*_hOOh(_r@=c_dL%bJxO%3ZO|M@;1n}79BQ!ooM4GVbmgofAF{@rtOfr2 z1_A2fNdHGb+}#8GezgtGTbH-@MX zOTV_Lf$(MC5)eVk7{XhwMd)KT>h*zS;I%PcN9 zHEz2c&}8B-go?Gyh(&bP`D0YRe#L(%thi^~k^8k@vH|gXK0xdZv3!&HY|pSiD9~)e zy6s`;0VHhnIU-XgU9stYtz5fkUaD==?W&doXA3a3b}`3VpwM*go$g`Jmp)a#AtCIs z%h~JgXf=L{*rv-LsWE4r)i^2C!(b8OLHNo8Rlh!pH+Sawi6q_Q%TLt2FGhX=ZOZYN zckXq$FGdAZPYFB_&!mpxHu&k%T#jGo0qHx}i692U2h7YzH{~maFDIjJOv`pqS~n}l?(e!Z?3>!L3IPY3 z_&QSL0!)G5&Bqo(mB&Meb>Jrnri&d3s+>8-S)KOMh8!hi_jcl#IJ;XZ4(KPFQzpQ5 zr62|a416v5&W^%;3Uw{GO}Vtq@?I`{FEwtxjYcze2gV-exzVC;QN6>N)S_fD#A#B6 zt1RsA)@nyQ447f;{Bg>B^HlNEcG3+c!>MEnF&Za2^2Y~C-$yy;LvP0Y6k_BwIDG7` zWbU>3=XZN1yT2EpXX`@6_`b#>c~6Xk75DO#~X@d4%+%cY_Y{$w_m z=%cwC*{M}m5FropBDWn?_Q{Wb+UUYbOwKH$T41*BiT02|D#T;`Zd8hAOZ|{=bL5;n zJ9+izcW5XzCf}7UvDEDoTRcewnDsqt;rPKePqAE1J}@P)n@{zE$?JW_OAje1>uBj?!!bJiFqi zs#<{>qWEp@GI0;?cI!)Vo%ORC-(7#~QoEENn5CTXhr8z#o0G5r?D!MTya+NX3w&jm zo6ZJhJ@Jsoq>DkHXq@DA$1g)_{xTpa`~kWr@apGv!sLCdu~z4*04JsIoMofDez>F< zD0^tgbDVh6P<iuIb^6ymEQ*Ens^ zJ~bG-mMJ$`V#bkN5ny8qL<-m%0hKiEj4p86=4o>m>@W`^U2yOqN}kMDY--u5EZHu2VOr_65&{e#P_t$eQEMOkNW{(1`zGhMrKgaeVAD9czNc6;(trJLISt z3h~~H ztYa9Sgr@p5mxX>as91HXh(P8^W&YW7wZzpdi|kw)Ztum?825n+sx8~#EH{Rjv+cW} zj0@E09An^)JFLOh*lH&>9DWz zEq|Zke%nud%g~4u{awp^k>hk?%2CYPCuXd zwxzR!ZZCV!LRQUwQB#HX-aOxNKg4End<1u&1=7Cs!_$Klvqe6D6^Dd5`hy5Qtm~ku zY7UG1^E@v^lJ`$eh%LL!8Gw zfGFW+U4-*zjB(u$X{WY~NR^pd@&^Jx@4An+Wb4xJL7?yP;Rs-xlJsSPsTN|8$@h3> zh2`3S1-j{pErVJMN~%y}sn+gneH?C$w8DG3!p5zY+NBR%=;uJ*E(9xQ%m3L@|E*li z?GgCEWVo#8Z2vZE76}(;pp`Swos^9g{EeCB6B`@&gG3vAg^LZ|L8All^8D`>{$G3Y z=+hCcx>;LF@tpXU&vV0>jtT)1eeikEIsMj6Z|Eb63R4U6!cHU4Z+jq`=c8D4K|tGYO? z_4na{+HEsTo7;0zlHuFR&Vv2l77 z+f#PuMeHuCtmWT!fg+uFRI3fjAtez09OHnG8s60e8ffg21pt33BXJypA-pYflaG{@ zk5UlPYC`#1jNUm2Fl^+4Q($G6N+^UhfAwr$)a-0C4^RAA50x?`rLoyRetR#_IEPNR zM@gpmwoo^`yL}-X(0`5{yU2f54i-~q^tqp>-%fWz_8>#%*PC4`v3%61pmD0bA8Rcu zwTf@F#`r-Kh8q;IrSyE+uGq+aX`B38_q@?3#i+_~#OD-b;Gc@@r~97mois{{cc7W0 zZ~X%(A|EzR$b3iLMSDtLDm%m|Gq0pN@NnEr`C#{{(Z-Tt+DskSD0wo>O4_@n4UepD zaf1Hhf>pf8J-;Kj>8Jzk854NSe)fu=^IU#gIXiS!&Tj-e>vKp?ZivRcHmh@zqgMQu z`i|DDa_R?CJMj(HgQd7Qz&`l6VZZG(Itj%zG;IAu(uS}$xwL3E^R7tqb^bzf0f9Lf zPPZ5ZYxWCaw}b@(Q92fbt~Zj?_KM!J>#8CCX64yL2T1~?7k zc8ZkC<6?#n&7;ImOcw{P8==#e4+DMd^S2HWCS!khA_+Il_kCDn0@tLXIR(yXf0z;B zVM-@}BI`oxOvhytQUnh=4IGk#|KpRCTE<*H9&p*RcdUfdm}CQ}(6bhFw4 zNqag!%VqYI-=q=Y)XLAV#{&}SPT9_VIu&E7!jYMf*jH$kv{_~3t>A*k0fKf_9L25G zn8Ny%lcm_&xZT6dcUE!L>WCz%e`T_v7{5sn7q*G*p0nV^ChEN1Yu7G4RN)03y6ySK z4J9C!l7Y}mV_hr$DuuDR^N{`&F2Ym&ax8U%YizwxJ=`ZS{|)Hx56j2hY4%USqhxb+ zH7`>UKsH4fWcF)zaKo6hv(!ts4QJCx(gCYLLMG=sI+80s2AoIxT%((tVxn(G^%*N)W^g$1K-)Mt~~+p3@8 z91d|+p?Ki0njm~nvO&yib;J~KIho)&>O4>gio4{ftSb2^qpp0}{kEbxBZtF^i`ZxL zyXlm=19MycVrnzt6cpDelVmKWE2vzgqFM*iGf{Xgz&wfs(*0ZrY9mxAlVQXK_RS@t zfS_GiP#BV#g70cB+a; z38=k1y$;a`p_SXh2&3on^0=+Yk3z*H>fXmzvMh}v-bX)=YNDMs`qsT7B~EK48Q!qa;D$vsZs;v?=aQWDh)$}K&d z>D3mC!hE426o(V&_6N871K{dl#?%oIA(_l4w@p5o7&h{Pkz}vj>X5a)yTk}=G28>MpWFZM?Q)S*fyVtu~OL%Nw zp|#EZUaP?6_gS^r_S&kBg$v+*Mzrn*|HCM5@*85rCfMU6E*Hi6@q^f)|0b*a8`oBD z7o>C7A{(*qqwin-E&A(jYr6mJtnYu7=F`aBYD)dfMUDhAbCAZZ z>3MhoX6zblTIx<^D~7XlaYymLfwN+DwZwT`Z@Q#`#{ zFinm|iIupq1dl7W(&>wqEOxKFqsQy3N9%rj z(dIesQj0$`6HJb{CvaA6(cdSxH2&axheT=OkEhF+bP8#Yval`tF08y?vA^S3PdL)R zr-7v8vH&8HS*)1^9PI$k4ged+78x|0-lNyHX|s1{@8R7pn$wmQvF>#k%QaLbGUXLy z8o(c!iZON_g+#JRtYK3GR?#SVa#{zOpWpIIZfxaqX=7q;N$d5W3PH8Tx>Wox0(GNg z8F1>l1(Y^vRnBhSiOHtB$yqXa%U={1oo@PkxIkKbzq>jF>*2Y37}ZZArp>sc?3Igm zzidbw>G5lXUfqXcayKELe<2Ag3<(>*=FbrjWAYq1VjsJy2@fL88M8{5!HwS3&)+o_ z*B08-{bYZH*5OPd8`;oa*P|>d_C~7$9^xFUNG(y3!~YFnuOE#f@BHkGAp?butv_2; zv<0eBa0S9|3a4NpTk_H~q+d~Z6ay6lc}bM+If~a2IKGFW@8ZuReG~qfZ6(c?Y2~^v zYOQH>W;L9tTzRyG7yPs6R)%v+B}kNDk&~1KDTXx!s36m%Ts(YAQ9d`#P2|S~073=K zRRo)$HzpFtWu&C_%^w%tc3 zi{{jHsada=zkgqxM}d03ts=YnPN@}E1! z#U!gAloi56nO0Z6dQ$s2(dX^^Co{T@UJAy^G>&ruE@y#kY5uz1LUA^LOl&+YJxtSC za;R)dn=DR+;j55WVk}I7D1i;rGEgBg#-I7UKzti8b({s>)f>4CM5aFgbJB`+XF)1i z9lOvQO&2K#k>90(u`Y=U;(1@UT&&uyneGjualLxGv6bB?|4@#7eUjS#)GTQV@MVTr zCl+HYd5;hzRczQpZWbt8HvdcS_bw)Dm=oJzBRxe0_e-&*aJeyLD7^dcg8*mesEB3q5R|Be@_b;=o zx1qEJ!&$UEUU%s*kAeFb8zA!zbFJ1_k0WS{@m9i(cFrRsXs`IoWS)Ya?U%EbP3~@Y zn8!CG@I}{784nVi&Z9EVqkK1mTZhT9SN*zkUU&t42CG=bvO&sA>1AgK7m0qd9U2^VXeG1N?EMTvg_Z7gE31 z@-3HecH+@l*|J?{J7~Ey=9V{|$O|3`ZvKRK%L%+TRqM{A@k;|$?HuwA&p)}bX?7a4i)Ypa(4k}QI zR)QNo&$n*mME-(bblfq%VmXPGX^>9s%t$BvIe)3I#i%?44y0DTxi<+WpHIP`Y@9mc z-q&F}^J3M-QmCy)z@nd;alm;T7JDgW=deG+QCe(mb}o*+?0Q7G8`P50O{F>&S*-7- zgKbPH(hiv%aDscC<9aTAH^AT?;T5j2FD!m2^6dRwx9!1S5uUbA)E@FyQHWR)yE*tg z865s2o!ztTCumvMK^Hm@(m~WvRc!FU$`{|}65+A!7b1b2SXip_hy7Y_*5~=7eah*4VM9Yf5s@YU?qsqLyArXaMZLv1YXf zw;`6n!6xo@yINc;0<}zUyv4~uA`tPQK8wGp>aNXsbX{U8VM$bnt~HukG0n<}Q+ z5PZ4s1Ip+TRV`_%(W~GOme#KnsNCSTtJGKrW~~Whc3T&QK{Zt23hOzO^aM@bf4o+( zVv@d?@;Np>KHrxP)VK21|LNsMRG_0%iY@1SLc`hP%MVR-6X18IXpc6vR(m&tdaWXE zAJs&DKT!!?4y03^>Y?Uug8pXH&lEzT2Lb`w!HGttR_-t~UoYzHzdl&0((L_ejth3pnP z6rCj2A9o?TW%iBNkF+3QifFBNp%dBNa2h zPpUBS3cGUhE8*X6rqO_0d)xmQ5t6{b-cpww_q|y}#{Bs$cm7If&>IcAGr|jI0NL!U zudba`mSZVaEzPhnqSB4Q4vvfMKo9l_F>#_usN35??_h{n`W4PFV8*)@o{xnsRfCLK|i zYPx)DpS=A_;ygYH-d^Fx5(gRLiN$-H&&z2W$zpvHgx=#hOru*JbVAMNbTIcK$_ega z{}@1!T>pwmJpU~wasNk5;`u8k>0-0}#~2b8{>Kga9}$Y3^*>COjh*#>c|^6k1}lO zn&x&d_0+tvyh48jkBai(nsM)wA|X~dqib*|(WV0?|BP`)TAhAUoTQF*Xy(cr)Iv(BJmd!L@d5WTAj2NfnD_EX#4rsUV}tY+cMa z9CK9-`cMm6mS$ep&9-GoBN>4(-!7$`K!0GJK?`@VJWlQSL#%M-M=x!Gu?a(B29R5U zXHY*(3_Qg2QdBpqAnj3Mf*soL#M>fPau|9qM;_?8mjeY80pExFcv*o1D_$}IG@-k+ zgfL0VjQZhnw8i&$mbFmSR3IY}x0iU(sRz7i4Xf}@z `q)_>`$gi8R!9G46P=HGu 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 B>39GD delta 27070 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 z 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 diff --git a/Ice/Resources/Acknowledgements.rtf b/Ice/Resources/Acknowledgements.rtf index 69f7bc359..dccad2906 100644 --- a/Ice/Resources/Acknowledgements.rtf +++ b/Ice/Resources/Acknowledgements.rtf @@ -1,8 +1,8 @@ -{\rtf1\ansi\ansicpg1252\cocoartf2761 +{\rtf1\ansi\ansicpg1252\cocoartf2865 \cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 HelveticaNeue-Bold;\f1\fnil\fcharset0 HelveticaNeue;} {\colortbl;\red255\green255\blue255;\red0\green0\blue0;} {\*\expandedcolortbl;;\cssrgb\c0\c1\c1;} -\margl1440\margr1440\vieww34360\viewh20460\viewkind0 +\margl1440\margr1440\vieww12920\viewh7720\viewkind0 \deftab720 \pard\pardeftab720\partightenfactor0 @@ -55,11 +55,13 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI \f0\b\fs28 \cf2 Ifrit \f1\b0 \ +\pard\pardeftab720\partightenfactor0 {\field{\*\fldinst{HYPERLINK "https://github.com/ukushu/Ifrit"}}{\fldrslt -\fs24 \ul \ulc2 https://github.com/ukushu/Ifrit}} +\fs24 \cf2 \ul \ulc2 https://github.com/ukushu/Ifrit}} \fs22 \ +\pard\pardeftab720\partightenfactor0 -\fs24 \ +\fs24 \cf2 \ MIT License\ \ Copyright (c) 2024 Andrii Vynnychenko, Kirollos Risk(original "fuse-swift" repository code)\ @@ -69,8 +71,7 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\ \ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\ -\pard\pardeftab720\partightenfactor0 -\cf2 \ +\ \pard\pardeftab720\partightenfactor0 \f0\b\fs28 \cf2 LaunchAtLogin @@ -91,6 +92,25 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\page \ \pard\pardeftab720\partightenfactor0 +\f0\b\fs28 \cf2 Semaphore +\f1\b0 \ +\pard\pardeftab720\partightenfactor0 +{\field{\*\fldinst{HYPERLINK "https://github.com/groue/Semaphore"}}{\fldrslt +\fs24 \cf2 \ul \ulc2 https://github.com/groue/Semaphore}} +\fs24 \ +\ +MIT License\ +\ +Copyright (c) 2022 Gwendal Rou\'e9\ +\ +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\ +\ +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\ +\ +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\ +\ +\pard\pardeftab720\partightenfactor0 + \f0\b\fs28 \cf2 Sparkle \f1\b0 \ \pard\pardeftab720\partightenfactor0 @@ -170,4 +190,5 @@ Redistribution and use in source and binary forms, with or without modification, 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.\ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.\ \ -THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.} \ No newline at end of file +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\ +} \ No newline at end of file From 423272ad6b91046b3bf7815e35945965835068b6 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Wed, 3 Sep 2025 06:16:59 -0600 Subject: [PATCH 57/80] Reformat acknowledgements --- Ice/Resources/Acknowledgements.pdf | Bin 35883 -> 72837 bytes Ice/Resources/Acknowledgements.rtf | 123 ++++------------------------- 2 files changed, 15 insertions(+), 108 deletions(-) diff --git a/Ice/Resources/Acknowledgements.pdf b/Ice/Resources/Acknowledgements.pdf index 6cb4a9ffd198c3ece1e9470f361ed3a9b1d03326..60e181bdfa7246b5f64de6d51a7aa38dd7062800 100644 GIT binary patch delta 68064 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 delta 30759 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

xp?LY$ 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 E4 (sindresorhus.com)\ -\ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\ -\ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\ +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\ \ -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\page \ -\pard\pardeftab720\partightenfactor0 -\f0\b\fs28 \cf2 Semaphore +\f0\b Semaphore - https://github.com/groue/Semaphore \f1\b0 \ -\pard\pardeftab720\partightenfactor0 -{\field{\*\fldinst{HYPERLINK "https://github.com/groue/Semaphore"}}{\fldrslt -\fs24 \cf2 \ul \ulc2 https://github.com/groue/Semaphore}} -\fs24 \ -\ -MIT License\ -\ Copyright (c) 2022 Gwendal Rou\'e9\ -\ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\ -\ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\ -\ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\ \ -\pard\pardeftab720\partightenfactor0 -\f0\b\fs28 \cf2 Sparkle +\f0\b Sparkle - https://github.com/sparkle-project/Sparkle \f1\b0 \ -\pard\pardeftab720\partightenfactor0 -{\field{\*\fldinst{HYPERLINK "https://github.com/sparkle-project/Sparkle"}}{\fldrslt -\fs24 \cf2 \ul \ulc2 https://github.com/sparkle-project/Sparkle}} -\fs24 \ -\ Copyright (c) 2006-2013 Andy Matuschak.\ Copyright (c) 2009-2013 Elgato Systems GmbH.\ Copyright (c) 2011-2014 Kornel Lesi\uc0\u324 ski.\ @@ -126,69 +62,40 @@ Copyright (c) 2014 C.W. Betts.\ Copyright (c) 2014 Petroules Corporation.\ Copyright (c) 2014 Big Nerd Ranch.\ All rights reserved.\ -\ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\ -\ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\ -\ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\ -\ =================\ EXTERNAL LICENSES\ =================\ -\ bspatch.c and bsdiff.c, from bsdiff 4.3 :\ -\ Copyright 2003-2005 Colin Percival\ All rights reserved\ -\ Redistribution and use in source and binary forms, with or without modification, are permitted providing that the following conditions are met:\ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.\ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.\ -\ THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\ -\ --\ -\ sais.c and sais.c, from sais-lite (2010/08/07) :\ -\ The sais-lite copyright is as follows:\ -\ Copyright (c) 2008-2010 Yuta Mori All Rights Reserved.\ -\ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\ -\ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\ -\ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\ -\ --\ -\ Portable C implementation of Ed25519, from https://github.com/orlp/ed25519\ -\ Copyright (c) 2015 Orson Peters \ -\ This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software.\ -\ Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions:\ -\ 1. The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required.\ -\ 2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software.\ -\ 3. This notice may not be removed or altered from any source distribution.\ -\ --\ -\ SUSignatureVerifier.m:\ -\ Copyright (c) 2011 Mark Hamlin.\ -\ All rights reserved.\ -\ Redistribution and use in source and binary forms, with or without modification, are permitted providing that the following conditions are met:\ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.\ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.\ -\ THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\ } \ No newline at end of file From c1312ebb86d1a695dc3dcafce6b8a309b5255def Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Wed, 3 Sep 2025 07:08:05 -0600 Subject: [PATCH 58/80] Update .swiftlint.yml --- .swiftlint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index f60e17d03..8aa313288 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -53,7 +53,7 @@ custom_rules: regex: ^\t file_header: - required_pattern: | + required_pattern: |- // // SWIFTLINT_CURRENT_FILENAME // Ice From f233bf33360da8d7576a3a99978c7cde7c953988 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 5 Sep 2025 01:14:54 -0600 Subject: [PATCH 59/80] Update Menu Bar Appearance interface --- .../MenuBarAppearanceEditor.swift | 133 +++++++++--------- .../MenuBarAppearanceEditorPanel.swift | 2 +- Ice/UI/IceUI/IceSection.swift | 9 +- Ice/UI/Shapes/AnyInsettableShape.swift | 24 ++++ 4 files changed, 100 insertions(+), 68 deletions(-) create mode 100644 Ice/UI/Shapes/AnyInsettableShape.swift diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift index 80a1cdafe..f35f1a9f2 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift @@ -26,8 +26,16 @@ struct MenuBarAppearanceEditor: View { } var body: some View { - bodyContent.safeAreaInset(edge: .bottom, spacing: 0) { - bottomBar + if #available(macOS 26.0, *) { + bodyContent + .safeAreaBar(edge: .bottom, spacing: 0) { + bottomBar + } + } else { + VStack(spacing: 0) { + bodyContent + bottomBar + } } } @@ -35,6 +43,9 @@ struct MenuBarAppearanceEditor: View { private var bodyContent: some View { if appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults { cannotEdit + } else if #available(macOS 26.0, *) { + mainForm + .scrollEdgeEffectStyle(.hard, for: .bottom) } else { mainForm } @@ -77,7 +88,13 @@ struct MenuBarAppearanceEditor: View { @ViewBuilder private var bottomBar: some View { - let stack = HStack { + HStack { + if case .panel = location { + DismissWindowButton("Done") + } + + Spacer() + if !appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults, appearanceManager.configuration != .defaultConfiguration @@ -86,22 +103,10 @@ struct MenuBarAppearanceEditor: View { appearanceManager.configuration = .defaultConfiguration } } - - Spacer() - - if case .panel = location { - DismissWindowButton("Done") - } } .controlSize(.large) - .padding(.vertical, 10) - .padding(mainFormPadding.horizontal) - - if case .panel = location { - stack.background(.ultraThickMaterial) - } else { - stack.background(.bar) - } + .buttonBorderShape(.capsule) + .padding(10) } @ViewBuilder @@ -238,22 +243,12 @@ private struct LabeledPartialEditor: View { .onFrameChange(update: $textFrame) if currentAppearance != appearance { - previewButton + PreviewButton(appearance: appearance) } } .frame(height: textFrame.height) } - @ViewBuilder - private var previewButton: some View { - switch appearance { - case .light: - PreviewButton(configuration: configuration.lightModeConfiguration) - case .dark: - PreviewButton(configuration: configuration.darkModeConfiguration) - } - } - @ViewBuilder private var partialEditor: some View { switch appearance { @@ -274,50 +269,56 @@ private struct StaticPartialEditor: View { } private struct PreviewButton: View { - private struct DummyButton: NSViewRepresentable { - @Binding var isPressed: Bool - - func makeNSView(context: Context) -> NSButton { - let button = NSButton() - button.title = "" - button.bezelStyle = .accessoryBarAction - return button - } + @EnvironmentObject private var appState: AppState + @State private var isPressed = false - func updateNSView(_ nsView: NSButton, context: Context) { - nsView.isHighlighted = isPressed - } + let appearance: SystemAppearance + + private var manager: MenuBarAppearanceManager { + appState.appearanceManager } - @EnvironmentObject var appearanceManager: MenuBarAppearanceManager + private var previewConfiguration: MenuBarAppearancePartialConfiguration { + switch appearance { + case .light: + manager.configuration.lightModeConfiguration + case .dark: + manager.configuration.darkModeConfiguration + } + } - @State private var frame = CGRect.zero - @State private var isPressed = false + var body: some View { + Button("Hold to Preview") { } + .buttonStyle(PreviewButtonStyle(isPressed: $isPressed)) + .onChange(of: isPressed) { + manager.previewConfiguration = isPressed ? previewConfiguration : nil + } + } +} - let configuration: MenuBarAppearancePartialConfiguration +private struct PreviewButtonStyle: ButtonStyle { + @Binding var isPressed: Bool - var body: some View { - ZStack { - DummyButton(isPressed: $isPressed) - .allowsHitTesting(false) - Text("Hold to Preview") - .baselineOffset(1.5) - .padding(.horizontal, 10) - .contentShape(Rectangle()) - } - .fixedSize() - .simultaneousGesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - isPressed = frame.contains(value.location) - } - .onEnded { _ in - isPressed = false - } - ) - .onChange(of: isPressed) { _, newValue in - appearanceManager.previewConfiguration = newValue ? configuration : nil + private var borderShape: some InsettableShape { + if #available(macOS 26.0, *) { + AnyInsettableShape(Capsule(style: .continuous)) + } else { + AnyInsettableShape(RoundedRectangle(cornerRadius: 6, style: .circular)) } - .onFrameChange(update: $frame) + } + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.horizontal, 10) + .padding(.vertical, 3) + .background { + borderShape + .fill(configuration.isPressed ? .tertiary : .quaternary) + .opacity(configuration.isPressed ? 0.5 : 0.75) + } + .contentShape([.focusEffect, .interaction], borderShape) + .onChange(of: configuration.isPressed) { _, newValue in + isPressed = newValue + } } } diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift index f53c3f3af..3d73075b7 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift @@ -83,7 +83,7 @@ final class MenuBarAppearanceEditorPanel: NSPanel { /// Updates the panel's position for display on the given screen. private func updatePosition(for screen: NSScreen) { let originX = screen.frame.midX - frame.width / 2 - let originY = screen.frame.maxY - frame.height / 8 + let originY = screen.visibleFrame.maxY setFrameTopLeftPoint(CGPoint(x: originX, y: originY)) } diff --git a/Ice/UI/IceUI/IceSection.swift b/Ice/UI/IceUI/IceSection.swift index f2fd96907..a0092dda3 100644 --- a/Ice/UI/IceUI/IceSection.swift +++ b/Ice/UI/IceUI/IceSection.swift @@ -108,9 +108,16 @@ struct IceSection: View { } } else { VStack(alignment: .leading) { - header.accessibilityAddTraits(.isHeader) + header + .accessibilityAddTraits(.isHeader) + .padding([.top, .leading], 8) + .padding(.bottom, 2) + contentLayout + footer + .padding([.bottom, .leading], 8) + .padding(.top, 2) } .focusSection() .accessibilityElement(children: .contain) diff --git a/Ice/UI/Shapes/AnyInsettableShape.swift b/Ice/UI/Shapes/AnyInsettableShape.swift new file mode 100644 index 000000000..2d8ab6509 --- /dev/null +++ b/Ice/UI/Shapes/AnyInsettableShape.swift @@ -0,0 +1,24 @@ +// +// AnyInsettableShape.swift +// Ice +// + +import SwiftUI + +/// A type-erased insettable shape. +struct AnyInsettableShape: InsettableShape { + private let base: any InsettableShape + + /// Creates a type-erased insettable shape. + init(_ shape: S) { + self.base = shape + } + + func path(in rect: CGRect) -> Path { + base.path(in: rect) + } + + func inset(by amount: CGFloat) -> AnyInsettableShape { + AnyInsettableShape(base.inset(by: amount)) + } +} From f18f7f624494bfdbca170aeb27a88fed8f57f7cc Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 5 Sep 2025 10:22:50 -0600 Subject: [PATCH 60/80] Adjust AppState setup order - Documentation changes --- Ice/Main/AppState.swift | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/Ice/Main/AppState.swift b/Ice/Main/AppState.swift index 8b42667ef..04f764f07 100644 --- a/Ice/Main/AppState.swift +++ b/Ice/Main/AppState.swift @@ -59,12 +59,13 @@ final class AppState: ObservableObject { private lazy var setupTask = Task { permissions.stopAllChecks() + settings.performSetup(with: self) + menuBarManager.performSetup(with: self) + if #available(macOS 26.0, *) { await MenuBarItemService.Connection.shared.start() } - settings.performSetup(with: self) - menuBarManager.performSetup(with: self) appearanceManager.performSetup(with: self) eventManager.performSetup(with: self) await itemManager.performSetup(with: self) @@ -195,6 +196,8 @@ final class AppState: ObservableObject { cancellables = c } + /// Returns a Boolean value indicating whether the app has been + /// granted the permission associated with the given key. func hasPermission(_ key: AppPermissions.PermissionKey) -> Bool { switch key { case .accessibility: @@ -221,7 +224,7 @@ final class AppState: ObservableObject { /// Opens the window with the given identifier. func openWindow(_ id: IceWindowIdentifier) { - // Defer to the next run loop to prevent conflicts with SwiftUI. + // Async prevents conflicts with SwiftUI. DispatchQueue.main.async { self.logger.debug("Opening window with id: \(id, privacy: .public)") EnvironmentValues().openWindow(id: id) @@ -230,7 +233,7 @@ final class AppState: ObservableObject { /// Dismisses the window with the given identifier. func dismissWindow(_ id: IceWindowIdentifier) { - // Defer to the next run loop to prevent conflicts with SwiftUI. + // Async prevents conflicts with SwiftUI. DispatchQueue.main.async { self.logger.debug("Dismissing window with id: \(id, privacy: .public)") EnvironmentValues().dismissWindow(id: id) @@ -242,11 +245,9 @@ final class AppState: ObservableObject { if let policy { NSApp.setActivationPolicy(policy) } - // NSApplication.activate(ignoringOtherApps:) is deprecated, with - // no suitable alternative for explicit activation, so we're using - // NSRunningApplication for now. - + // no suitable alternative for explicit activation, so we activate + // through NSRunningApplication.current for now. guard let frontmost = NSWorkspace.shared.frontmostApplication else { NSRunningApplication.current.activate() return From 9c5fd18c6bc4dbf0c4078141f3db4a1a40c8ffc7 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 5 Sep 2025 12:01:45 -0600 Subject: [PATCH 61/80] Update Menu Bar Layout settings pane --- .../MenuBarLayoutSettingsPane.swift | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift index 13af54cab..d0573fa67 100644 --- a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift @@ -15,11 +15,11 @@ struct MenuBarLayoutSettingsPane: View { var body: some View { if !ScreenCapture.cachedCheckPermissions() { - missingScreenRecordingPermission + missingScreenRecordingPermissions } else if appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults { cannotArrange } else { - IceForm(alignment: .leading, spacing: 20) { + IceForm(spacing: 20) { header layoutBars } @@ -28,18 +28,21 @@ struct MenuBarLayoutSettingsPane: View { @ViewBuilder private var header: some View { - Text("Drag to arrange your menu bar items") - .font(.title2) - - CalloutBox( - "Tip: You can also arrange menu bar items by ⌘ Command + dragging them in the menu bar.", - systemImage: "lightbulb" - ) + IceSection { + VStack(spacing: 2) { + Text("Drag to arrange your menu bar items into different sections.") + .font(.title3.bold()) + Text("Menu bar items can also be arranged by ⌘ Command + dragging them in the menu bar.") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + } + .padding(15) + } } @ViewBuilder private var layoutBars: some View { - VStack(spacing: 15) { + VStack(spacing: 20) { ForEach(MenuBarSection.Name.allCases, id: \.self) { section in layoutBar(for: section) } @@ -49,26 +52,22 @@ struct MenuBarLayoutSettingsPane: View { .allowsHitTesting(hasItems) .overlay { if !hasItems { - VStack { - Text("Loading menu bar items…") - .font(.title) - ProgressView() - } + loadingMenuBarItems } } } @ViewBuilder private var cannotArrange: some View { - Text("Ice cannot arrange menu bar items in automatically hidden menu bars") + Text("Ice cannot arrange menu bar items in automatically hidden menu bars.") .font(.title3) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } @ViewBuilder - private var missingScreenRecordingPermission: some View { + private var missingScreenRecordingPermissions: some View { VStack { - Text("Menu bar layout requires screen recording permissions") + Text("Menu bar layout requires screen recording permissions.") .font(.title2) Button { @@ -80,6 +79,15 @@ struct MenuBarLayoutSettingsPane: View { } } + @ViewBuilder + private var loadingMenuBarItems: some View { + VStack { + Text("Loading menu bar items…") + ProgressView() + } + .font(.title) + } + @ViewBuilder private func layoutBar(for name: MenuBarSection.Name) -> some View { if @@ -88,7 +96,9 @@ struct MenuBarLayoutSettingsPane: View { { VStack(alignment: .leading) { Text(name.localized) - .font(.title3) + .font(.headline) + .padding(.leading, 8) + LayoutBar(imageCache: appState.imageCache, section: name) } } From a89b65a3faaca2f2dd9c93f450c97f1a0d133c7a Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Fri, 5 Sep 2025 22:39:44 -0600 Subject: [PATCH 62/80] UI tweaks --- .../MenuBarAppearanceEditor.swift | 5 +- .../MenuBarLayoutSettingsPane.swift | 6 +-- Ice/UI/Views/BetaBadge.swift | 15 ++++-- Ice/UI/Views/DismissWindowButton.swift | 49 ------------------- 4 files changed, 19 insertions(+), 56 deletions(-) delete mode 100644 Ice/UI/Views/DismissWindowButton.swift diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift index f35f1a9f2..7852ad67b 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift @@ -13,6 +13,7 @@ struct MenuBarAppearanceEditor: View { @EnvironmentObject var appState: AppState @ObservedObject var appearanceManager: MenuBarAppearanceManager + @Environment(\.dismissWindow) private var dismissWindow let location: Location @@ -90,7 +91,9 @@ struct MenuBarAppearanceEditor: View { private var bottomBar: some View { HStack { if case .panel = location { - DismissWindowButton("Done") + Button("Done") { + dismissWindow() + } } Spacer() diff --git a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift index d0573fa67..1ebe6bf68 100644 --- a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift @@ -29,11 +29,11 @@ struct MenuBarLayoutSettingsPane: View { @ViewBuilder private var header: some View { IceSection { - VStack(spacing: 2) { + VStack(spacing: 3) { Text("Drag to arrange your menu bar items into different sections.") .font(.title3.bold()) - Text("Menu bar items can also be arranged by ⌘ Command + dragging them in the menu bar.") - .font(.system(size: 13, weight: .medium)) + Text("They can also be arranged by ⌘ Command + dragging them in the menu bar.") + .font(.system(size: 12, weight: .medium)) .foregroundStyle(.secondary) } .padding(15) diff --git a/Ice/UI/Views/BetaBadge.swift b/Ice/UI/Views/BetaBadge.swift index 26d8b67cb..95dd6864e 100644 --- a/Ice/UI/Views/BetaBadge.swift +++ b/Ice/UI/Views/BetaBadge.swift @@ -7,13 +7,22 @@ import SwiftUI /// A view that displays a badge indicating a beta feature. struct BetaBadge: View { + private var backgroundShape: some Shape { + if #available(macOS 26.0, *) { + Capsule(style: .continuous) + } else { + Capsule(style: .circular) + } + } + var body: some View { Text("BETA") - .font(.caption.bold()) + .font(.system(size: 10, weight: .medium)) .padding(.horizontal, 6) + .padding(.vertical, 1) .background { - Capsule(style: .circular) - .stroke() + backgroundShape + .fill(.foreground.opacity(0.25)) } .foregroundStyle(.green) } diff --git a/Ice/UI/Views/DismissWindowButton.swift b/Ice/UI/Views/DismissWindowButton.swift deleted file mode 100644 index c19ce39a5..000000000 --- a/Ice/UI/Views/DismissWindowButton.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// DismissWindowButton.swift -// Ice -// - -import SwiftUI - -struct DismissWindowButton: View { - @State private var dismissWindow: (() -> Void)? - - private let label: Label - - init(@ViewBuilder label: () -> Label) { - self.label = label() - } - - init(_ titleKey: LocalizedStringKey) where Label == Text { - self.label = Text(titleKey) - } - - private var role: ButtonRole? { - if #available(macOS 26.0, *) { - return .close - } else { - return nil - } - } - - var body: some View { - Button(role: role) { - dismissWindow?() - } label: { - label - } - .onWindowChange { window in - updateAction(with: window) - } - } - - private func updateAction(with window: NSWindow?) { - guard let window else { - dismissWindow = nil - return - } - dismissWindow = { [weak window] in - window?.close() - } - } -} From 124561299cf2030a51591e4fed36f4bb0a93d11e Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Sat, 6 Sep 2025 12:36:41 -0600 Subject: [PATCH 63/80] Tweak menu bar appearance interface --- Ice/Main/AppDelegate.swift | 3 - .../MenuBarAppearanceEditor.swift | 32 ++-- .../MenuBarAppearanceEditorPanel.swift | 62 +++--- Ice/UI/IceUI/IceColorPicker.swift | 176 ------------------ 4 files changed, 42 insertions(+), 231 deletions(-) delete mode 100644 Ice/UI/IceUI/IceColorPicker.swift diff --git a/Ice/Main/AppDelegate.swift b/Ice/Main/AppDelegate.swift index 1d491be54..be67ceee9 100644 --- a/Ice/Main/AppDelegate.swift +++ b/Ice/Main/AppDelegate.swift @@ -17,9 +17,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Initial chore work. NSSplitViewItem.swizzle() MigrationManager(appState: appState).migrateAll() - NSColorPanel.shared.animationBehavior = .none - NSColorPanel.shared.hidesOnDeactivate = false - NSColorPanel.shared.styleMask.insert(.nonactivatingPanel) } func applicationDidFinishLaunching(_ notification: Notification) { diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift index 7852ad67b..c1839dae5 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift @@ -14,18 +14,10 @@ struct MenuBarAppearanceEditor: View { @EnvironmentObject var appState: AppState @ObservedObject var appearanceManager: MenuBarAppearanceManager @Environment(\.dismissWindow) private var dismissWindow + @State private var isResetPromptPresented = false let location: Location - private var mainFormPadding: EdgeInsets { - withMutableCopy(of: EdgeInsets.iceFormDefaultPadding) { insets in - switch location { - case .settings: break - case .panel: insets.top = insets.bottom - } - } - } - var body: some View { if #available(macOS 26.0, *) { bodyContent @@ -46,7 +38,7 @@ struct MenuBarAppearanceEditor: View { cannotEdit } else if #available(macOS 26.0, *) { mainForm - .scrollEdgeEffectStyle(.hard, for: .bottom) + .scrollEdgeEffectStyle(.hard, for: .vertical) } else { mainForm } @@ -61,7 +53,7 @@ struct MenuBarAppearanceEditor: View { @ViewBuilder private var mainForm: some View { - IceForm(padding: mainFormPadding) { + IceForm { if case .settings = location, appState.settings.advanced.enableSecondaryContextMenu @@ -103,11 +95,21 @@ struct MenuBarAppearanceEditor: View { appearanceManager.configuration != .defaultConfiguration { Button("Reset") { - appearanceManager.configuration = .defaultConfiguration + isResetPromptPresented = true + } + .alert("Reset Menu Bar Appearance", isPresented: $isResetPromptPresented) { + Button("Cancel", role: .cancel) { + isResetPromptPresented = false + } + Button("Reset", role: .destructive) { + appearanceManager.configuration = .defaultConfiguration + isResetPromptPresented = false + } + } message: { + Text("This action cannot be undone.") } } } - .controlSize(.large) .buttonBorderShape(.capsule) .padding(10) } @@ -165,7 +167,7 @@ private struct UnlabeledPartialEditor: View { case .noTint: EmptyView() case .solid: - IceColorPicker( + ColorPicker( configuration.tintKind.localized, selection: $configuration.tintColor, supportsOpacity: false @@ -197,7 +199,7 @@ private struct UnlabeledPartialEditor: View { @ViewBuilder private var borderColor: some View { if configuration.hasBorder { - IceColorPicker( + ColorPicker( "Border Color", selection: $configuration.borderColor, supportsOpacity: true diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift index 3d73075b7..93b27b492 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift @@ -27,18 +27,17 @@ final class MenuBarAppearanceEditorPanel: NSPanel { init() { super.init( contentRect: .zero, - styleMask: [.titled, .closable, .fullSizeContentView, .nonactivatingPanel, .utilityWindow, .hudWindow], + styleMask: [.titled, .closable, .fullSizeContentView, .nonactivatingPanel], backing: .buffered, defer: false ) + self.title = "Menu Bar Appearance" self.titlebarAppearsTransparent = true - self.isExcludedFromWindowsMenu = false - self.becomesKeyOnlyIfNeeded = true + self.allowsToolTipsWhenApplicationIsInactive = true + self.isFloatingPanel = true self.hidesOnDeactivate = false - self.level = .floating - self.collectionBehavior = [.fullScreenAuxiliary, .ignoresCycle, .moveToActiveSpace] - self.animationBehavior = .documentWindow - standardWindowButton(.closeButton)?.isHidden = true + self.isMovableByWindowBackground = false + self.collectionBehavior = [.fullScreenAuxiliary, .moveToActiveSpace] } /// Sets up the panel. @@ -66,23 +65,23 @@ final class MenuBarAppearanceEditorPanel: NSPanel { } .store(in: &c) - // Close the panel when certain app or system events occur. - Publishers.Merge3( - NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.activeSpaceDidChangeNotification), - NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification), - NotificationCenter.default.publisher(for: NSApplication.didResignActiveNotification) - ) - .sink { [weak self] _ in - self?.close() - } - .store(in: &c) + publisher(for: \.isVisible) + .sink { isVisible in + if isVisible { + NSColorPanel.shared.hidesOnDeactivate = false + } else { + NSColorPanel.shared.hidesOnDeactivate = true + NSColorPanel.shared.close() + } + } + .store(in: &c) cancellables = c } /// Updates the panel's position for display on the given screen. private func updatePosition(for screen: NSScreen) { - let originX = screen.frame.midX - frame.width / 2 + let originX = screen.visibleFrame.midX - frame.width / 2 let originY = screen.visibleFrame.maxY setFrameTopLeftPoint(CGPoint(x: originX, y: originY)) } @@ -92,21 +91,14 @@ final class MenuBarAppearanceEditorPanel: NSPanel { updatePosition(for: screen) makeKeyAndOrderFront(nil) } - - override func cancelOperation(_ sender: Any?) { - super.cancelOperation(sender) - close() - } } // MARK: - MenuBarAppearanceEditorHostingView private final class MenuBarAppearanceEditorHostingView: NSHostingView { - override var acceptsFirstResponder: Bool { true } - override var needsPanelToBecomeKey: Bool { true } - - override var safeAreaInsets: NSEdgeInsets { NSEdgeInsets() } - override var intrinsicContentSize: CGSize { CGSize(width: 550, height: 600) } + override var intrinsicContentSize: CGSize { + CGSize(width: 550, height: 600) + } init(appState: AppState) { super.init(rootView: MenuBarAppearanceEditorContentView(appState: appState)) @@ -130,14 +122,10 @@ private struct MenuBarAppearanceEditorContentView: View { @ObservedObject var appState: AppState var body: some View { - MenuBarAppearanceEditor(appearanceManager: appState.appearanceManager, location: .panel) - .background { - Rectangle() - .fill(.regularMaterial) - Rectangle() - .fill(.windowBackground) - .opacity(0.25) - } - .environmentObject(appState) + MenuBarAppearanceEditor( + appearanceManager: appState.appearanceManager, + location: .panel + ) + .environmentObject(appState) } } diff --git a/Ice/UI/IceUI/IceColorPicker.swift b/Ice/UI/IceUI/IceColorPicker.swift deleted file mode 100644 index 4b4032c56..000000000 --- a/Ice/UI/IceUI/IceColorPicker.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// IceColorPicker.swift -// Ice -// - -import Combine -import SwiftUI - -struct IceColorPicker: View { - @Binding private var selection: CGColor - @State private var isActive: Bool = false - - private let supportsOpacity: Bool - private let label: Label - - init( - selection: Binding, - supportsOpacity: Bool = true, - @ViewBuilder label: () -> Label - ) { - self._selection = selection - self.supportsOpacity = supportsOpacity - self.label = label() - } - - init( - _ labelKey: LocalizedStringKey, - selection: Binding, - supportsOpacity: Bool = true - ) where Label == Text { - self._selection = selection - self.supportsOpacity = supportsOpacity - self.label = Text(labelKey) - } - - /// Creates a new color picker. - /// - /// - Parameters: - /// - gradient: A binding to a color. - /// - supportsOpacity: A Boolean value indicating whether the - /// picker should support opacity. - init( - selection: Binding, - supportsOpacity: Bool = true - ) where Label == EmptyView { - self._selection = selection - self.supportsOpacity = supportsOpacity - self.label = EmptyView() - } - - var body: some View { - LabeledContent { - IceColorPickerRoot( - selection: $selection, - isActive: $isActive, - supportsOpacity: supportsOpacity - ) - .onKeyDown(key: .escape, isEnabled: isActive) { - isActive = false - NSColorPanel.shared.close() - return .handled - } - } label: { - label - } - } -} - -private struct IceColorPickerRoot: NSViewRepresentable { - @Binding var selection: CGColor - @Binding var isActive: Bool - - let supportsOpacity: Bool - - func makeNSView(context: Context) -> NSColorWell { - let colorWell = NSColorWell() - updateNSView(colorWell, context: context) - context.coordinator.configure(with: colorWell) - return colorWell - } - - func updateNSView(_ colorWell: NSColorWell, context: Context) { - if colorWell.supportsAlpha != supportsOpacity { - colorWell.supportsAlpha = supportsOpacity - } - - if - let color = NSColor(cgColor: selection), - colorWell.color != color - { - colorWell.color = color - } - - if isActive != colorWell.isActive { - if isActive, let window = colorWell.window, window.isVisible { - colorWell.activate(true) - } else { - colorWell.deactivate() - } - } - } - - func makeCoordinator() -> IceColorPickerCoordinator { - IceColorPickerCoordinator(selection: $selection, isActive: $isActive) - } - - func sizeThatFits( - _ proposal: ProposedViewSize, - nsView colorWell: NSColorWell, - context: Context - ) -> CGSize? { - colorWell.intrinsicContentSize - } -} - -@MainActor -private final class IceColorPickerCoordinator { - @Binding var selection: CGColor - @Binding var isActive: Bool - - private var cancellables = Set() - - init(selection: Binding, isActive: Binding) { - self._selection = selection - self._isActive = isActive - } - - func configure(with colorWell: NSColorWell) { - var c = Set() - - colorWell.publisher(for: \.color) - .removeDuplicates() - .map { $0.cgColor } - .receive(on: DispatchQueue.main) - .sink { [weak self] selection in - guard let self else { - return - } - if self.selection != selection { - self.selection = selection - } - } - .store(in: &c) - - colorWell.publisher(for: \.isActive) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] isActive in - guard let self else { - return - } - if self.isActive != isActive { - self.isActive = isActive - } - } - .store(in: &c) - - colorWell.publisher(for: \.window) - .removeNil() - .flatMap { $0.publisher(for: \.isVisible) } - .replaceEmpty(with: false) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] isVisible in - guard let self else { - return - } - if !isVisible, isActive { - isActive = false - } - } - .store(in: &c) - - cancellables = c - } -} From 88a14fb671ed307c82ff7ea0da7d412132edae4c Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Tue, 9 Sep 2025 16:01:07 -0600 Subject: [PATCH 64/80] Simplify `ControlItem` state observation --- Ice/MenuBar/ControlItem/ControlItem.swift | 137 +++++++++------------- Ice/Utilities/Extensions.swift | 10 ++ 2 files changed, 66 insertions(+), 81 deletions(-) diff --git a/Ice/MenuBar/ControlItem/ControlItem.swift b/Ice/MenuBar/ControlItem/ControlItem.swift index 41c4b92eb..a92502701 100644 --- a/Ice/MenuBar/ControlItem/ControlItem.swift +++ b/Ice/MenuBar/ControlItem/ControlItem.swift @@ -177,12 +177,7 @@ final class ControlItem { /// Performs the initial setup of the control item. func performSetup(with appState: AppState) { self.appState = appState - Task { - updateStatusItem(with: state) - Task { - configureCancellables() - } - } + configureCancellables() } /// Configures the internal observers for the control item. @@ -190,30 +185,23 @@ final class ControlItem { var c = Set() $state - .sink { [weak self] state in - self?.updateStatusItem(with: state) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateStatusItem() } .store(in: &c) statusItem.publisher(for: \.isVisible) .receive(on: DispatchQueue.main) .sink { [weak self] isVisible in - guard let self, let appState else { - return - } - - let hotkeysSettings = appState.settings.hotkeys - - let hotkey: Hotkey? = switch identifier { - case .visible: nil - case .hidden: hotkeysSettings.hotkey(withAction: .toggleHiddenSection) - case .alwaysHidden: hotkeysSettings.hotkey(withAction: .toggleAlwaysHiddenSection) - } - - guard let hotkey else { + guard + let self, + let menuBarManager = appState?.menuBarManager, + let section = menuBarManager.section(withName: sectionName), + let hotkey = section.hotkey + else { return } - if isVisible { hotkey.enable() } else { @@ -222,58 +210,58 @@ final class ControlItem { } .store(in: &c) - statusItem.publisher(for: \.button) - .compactMap { $0 } + statusItem.publisher(for: \.button).removeNil() .flatMap { $0.publisher(for: \.window) } + .receive(on: DispatchQueue.main) .sink { [weak self] window in self?.window = window } .store(in: &c) - $window - .compactMap { $0 } + $window.removeNil() .flatMap { $0.publisher(for: \.frame) } + .removeDuplicates() + .receive(on: DispatchQueue.main) .sink { [weak self] frame in self?.frame = frame } .store(in: &c) - $window - .compactMap { $0 } + $window.removeNil() .flatMap { $0.publisher(for: \.screen) } + .receive(on: DispatchQueue.main) .sink { [weak self] screen in self?.screen = screen } .store(in: &c) - Publishers.CombineLatest( - $frame - .compactMap { $0 }, - $screen - .compactMap { $0 } - .flatMap { $0.publisher(for: \.frame) } - ) - .sink { [weak self] frame, screenFrame in - guard let self else { - return - } - if screenFrame.intersects(frame) { - onScreenFrame = frame - } else { - onScreenFrame = nil + $screen.removeNil() + .flatMap { $0.publisher(for: \.frame) } + .combineLatest($frame.removeNil()) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] screenFrame, frame in + guard let self else { + return + } + if screenFrame.intersects(frame) { + onScreenFrame = frame + } else { + onScreenFrame = nil + } } - } - .store(in: &c) + .store(in: &c) if let appState { appState.$isDraggingMenuBarItem + .removeDuplicates() .receive(on: DispatchQueue.main) - .sink { [weak self] dragging in + .sink { [weak self] isDragging in guard let self else { return } - if dragging { - updateStatusItem(with: state) + if isDragging { + updateStatusItem() } } .store(in: &c) @@ -281,7 +269,7 @@ final class ControlItem { if identifier == .visible { appState.settings.general.$showIceIcon .combineLatest(statusItem.publisher(for: \.isVisible)) - .removeDuplicates { $0 == $1 } + .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self] shouldShow, _ in guard let self else { @@ -296,22 +284,11 @@ final class ControlItem { .store(in: &c) appState.settings.general.$iceIcon + .combineLatest(appState.settings.general.$customIceIconIsTemplate) + .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self] _ in - guard let self else { - return - } - updateStatusItem(with: state) - } - .store(in: &c) - - appState.settings.general.$customIceIconIsTemplate - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self else { - return - } - updateStatusItem(with: state) + self?.updateStatusItem() } .store(in: &c) } @@ -319,7 +296,7 @@ final class ControlItem { if identifier == .alwaysHidden { appState.settings.advanced.$enableAlwaysHiddenSection .combineLatest(statusItem.publisher(for: \.isVisible)) - .removeDuplicates { $0 == $1 } + .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self] shouldEnable, _ in guard let self else { @@ -336,12 +313,10 @@ final class ControlItem { if isSectionDivider { appState.settings.advanced.$sectionDividerStyle + .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self] _ in - guard let self else { - return - } - updateStatusItem(with: state) + self?.updateStatusItem() } .store(in: &c) } @@ -350,8 +325,8 @@ final class ControlItem { cancellables = c } - /// Updates the appearance of the status item using the given hiding state. - private func updateStatusItem(with state: HidingState) { + /// Updates the appearance of the status item using the current hiding state. + private func updateStatusItem() { guard let appState, let button = statusItem.button @@ -365,7 +340,7 @@ final class ControlItem { switch identifier { case .visible: - updateStatusItemVisibility(true, state: state) + updateStatusItemVisibility(true) button.appearsDisabled = false let icon = appState.settings.general.iceIcon @@ -394,7 +369,7 @@ final class ControlItem { case .showSection: switch appState.settings.advanced.sectionDividerStyle { case .noDivider: - updateStatusItemVisibility(false, state: state) + updateStatusItemVisibility(false) button.appearsDisabled = true button.isHighlighted = false @@ -403,7 +378,7 @@ final class ControlItem { button.title = "|" } case .chevron: - updateStatusItemVisibility(true, state: state) + updateStatusItemVisibility(true) button.appearsDisabled = false button.image = switch identifier { @@ -415,7 +390,7 @@ final class ControlItem { } } case .hideSection: - updateStatusItemVisibility(true, state: state) + updateStatusItemVisibility(true) button.appearsDisabled = true button.isHighlighted = false } @@ -424,13 +399,13 @@ final class ControlItem { /// Updates the visibility of the status item. /// - /// The control item must be present in the menu bar so that Ice can determine - /// the items in its section. The status item's `isVisible` property completely - /// removes the item, and therefore cannot be used. Instead, this method sets - /// the status item's length to the appropriate value for the provided hiding - /// state, then either enables or disables a layout constraint on the item's - /// content view and adjusts the item's window if needed. - private func updateStatusItemVisibility(_ isVisible: Bool, state: HidingState) { + /// The hidden and always-hidden control items must always be present in + /// the menu bar, as we use their positions to determine the items in each + /// section. Setting `statusItem.isVisible` to `false` completely removes + /// the item. Instead, we toggle the width constraint on the item's content + /// view, update the item's length, then adjust the content size of the + /// item's window if needed. + private func updateStatusItemVisibility(_ isVisible: Bool) { guard let appState else { return } diff --git a/Ice/Utilities/Extensions.swift b/Ice/Utilities/Extensions.swift index d5efe297c..e7fc911b6 100644 --- a/Ice/Utilities/Extensions.swift +++ b/Ice/Utilities/Extensions.swift @@ -606,6 +606,16 @@ extension Publisher { compactMap { $0 } } + /// Publishes only elements that don't match the previous element. + func removeDuplicates() -> Publishers.RemoveDuplicates where Output == (repeat each T) { + removeDuplicates { lhs, rhs in + for (left, right) in repeat (each lhs, each rhs) { + guard left == right else { return false } + } + return true + } + } + /// Merges this publisher with the given publisher, replacing upstream /// elements with `Void` values. /// From 4b61aad1fe0252b10cdd4523fe56a3a2e930c2b3 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Tue, 9 Sep 2025 16:01:30 -0600 Subject: [PATCH 65/80] Update Menu Bar Layout settings pane --- Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift index 1ebe6bf68..a759c79b3 100644 --- a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift @@ -32,7 +32,7 @@ struct MenuBarLayoutSettingsPane: View { VStack(spacing: 3) { Text("Drag to arrange your menu bar items into different sections.") .font(.title3.bold()) - Text("They can also be arranged by ⌘ Command + dragging them in the menu bar.") + Text("Items can also be arranged by ⌘ Command + dragging them in the menu bar.") .font(.system(size: 12, weight: .medium)) .foregroundStyle(.secondary) } From c7a7d7c0dcbea3a5798660cfd5c4bf090121eae3 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Mon, 15 Sep 2025 01:47:45 -0600 Subject: [PATCH 66/80] Add workspace setting to disable auto-creating schemes --- .../xcshareddata/WorkspaceSettings.xcsettings | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 Ice.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/Ice.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Ice.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000..08de0be8d --- /dev/null +++ b/Ice.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded + + + From 5a015de06396683a3dd255aa27cf2d053fab3d2b Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Tue, 16 Sep 2025 01:40:23 -0600 Subject: [PATCH 67/80] Item management reworks - Misc event handling improvements - Fix temporarily shown item interface check - Fix broken on screen item check - Additional minor refactoring --- Ice/Events/EventTap.swift | 22 +- Ice/MenuBar/IceBar/IceBar.swift | 6 +- Ice/MenuBar/MenuBarItems/MenuBarItem.swift | 34 +- .../MenuBarItems/MenuBarItemImageCache.swift | 4 +- .../MenuBarItems/MenuBarItemManager.swift | 836 ++++++++++-------- Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift | 65 +- Ice/MenuBar/Search/MenuBarSearchPanel.swift | 6 +- Ice/Utilities/Extensions.swift | 31 +- Shared/Bridging/Bridging.swift | 23 + Shared/Bridging/Shims.swift | 7 - Shared/Utilities/WindowInfo.swift | 12 +- 11 files changed, 604 insertions(+), 442 deletions(-) diff --git a/Ice/Events/EventTap.swift b/Ice/Events/EventTap.swift index 4c51e40d6..937b41fc8 100644 --- a/Ice/Events/EventTap.swift +++ b/Ice/Events/EventTap.swift @@ -47,16 +47,18 @@ final class EventTap { guard let refcon else { return Unmanaged.passUnretained(event) } - let tap: EventTap = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { - tap.enable() - return nil - } - guard tap.isEnabled else { - return Unmanaged.passUnretained(event) - } - return tap.callback(tap, event).map { eventFromCallback in - Unmanaged.passUnretained(eventFromCallback) + let unretained: EventTap = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + return withExtendedLifetime(unretained) { tap in + if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { + tap.enable() + return nil + } + guard tap.isEnabled else { + return Unmanaged.passUnretained(event) + } + return tap.callback(tap, event).map { eventFromCallback in + Unmanaged.passUnretained(eventFromCallback) + } } } diff --git a/Ice/MenuBar/IceBar/IceBar.swift b/Ice/MenuBar/IceBar/IceBar.swift index 226311a95..e7593edd2 100644 --- a/Ice/MenuBar/IceBar/IceBar.swift +++ b/Ice/MenuBar/IceBar/IceBar.swift @@ -380,7 +380,6 @@ private struct IceBarContentView: View { itemManager: itemManager, menuBarManager: menuBarManager, item: item, - displayID: screen.displayID, section: section ) } @@ -404,7 +403,6 @@ private struct IceBarItemView: View { @ObservedObject var menuBarManager: MenuBarManager let item: MenuBarItem - let displayID: CGDirectDisplayID let section: MenuBarSection.Name private var leftClickAction: () -> Void { @@ -415,7 +413,7 @@ private struct IceBarItemView: View { menuBarManager.section(withName: section)?.hide() Task { try await Task.sleep(for: .milliseconds(25)) - if Bridging.isWindowOnDisplay(item.windowID, displayID) { + if Bridging.isWindowOnScreen(item.windowID) { try await itemManager.click(item: item, with: .left) } else { await itemManager.temporarilyShow(item: item, clickingWith: .left) @@ -432,7 +430,7 @@ private struct IceBarItemView: View { menuBarManager.section(withName: section)?.hide() Task { try await Task.sleep(for: .milliseconds(25)) - if Bridging.isWindowOnDisplay(item.windowID, displayID) { + if Bridging.isWindowOnScreen(item.windowID) { try await itemManager.click(item: item, with: .right) } else { await itemManager.temporarilyShow(item: item, clickingWith: .right) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift index 8e3f1c5b8..ae3e329e3 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift @@ -25,31 +25,38 @@ struct MenuBarItem: CustomStringConvertible { /// The item's window title. let title: String? - /// A Boolean value that indicates whether the item is onscreen. - let isOnscreen: Bool + /// A Boolean value that indicates whether the item is on screen. + let isOnScreen: Bool - /// A Boolean value that indicates whether the item can be moved. + /// A Boolean value that indicates whether this item can be moved. var isMovable: Bool { tag.isMovable } - /// A Boolean value that indicates whether the item can be hidden. + /// A Boolean value that indicates whether this item can be hidden. var canBeHidden: Bool { tag.canBeHidden } - /// A Boolean value that indicates whether the item is one of Ice's + /// A Boolean value that indicates whether this item is one of Ice's /// control items. var isControlItem: Bool { tag.isControlItem } - /// A Boolean value that indicates whether the item is a "BentoBox" + /// A Boolean value that indicates whether this item is a "BentoBox" /// item owned by the Control Center. var isBentoBox: Bool { tag.isBentoBox } + /// A Boolean value that indicates whether this item is a + /// system-created clone of an actual item, and therefore invalid + /// for management. + var isSystemClone: Bool { + tag.isSystemClone + } + /// The application that owns the item. /// /// - Note: In macOS 26 and later, this property always returns the @@ -153,7 +160,7 @@ struct MenuBarItem: CustomStringConvertible { self.sourcePID = itemWindow.ownerPID self.bounds = itemWindow.bounds self.title = itemWindow.title - self.isOnscreen = itemWindow.isOnscreen + self.isOnScreen = itemWindow.isOnScreen } /// Creates a menu bar item without checks. @@ -169,14 +176,7 @@ struct MenuBarItem: CustomStringConvertible { self.sourcePID = sourcePID self.bounds = itemWindow.bounds self.title = itemWindow.title - self.isOnscreen = itemWindow.isOnscreen - } - - /// Returns the current bounds for the given menu bar item. - /// - /// - Parameter item: A menu bar item. - static func currentBounds(for item: MenuBarItem) -> CGRect? { - Bridging.getWindowBounds(for: item.windowID) + self.isOnScreen = itemWindow.isOnScreen } } @@ -276,7 +276,7 @@ extension MenuBarItem: Equatable { lhs.sourcePID == rhs.sourcePID && NSStringFromRect(lhs.bounds) == NSStringFromRect(rhs.bounds) && lhs.title == rhs.title && - lhs.isOnscreen == rhs.isOnscreen + lhs.isOnScreen == rhs.isOnScreen } } @@ -289,7 +289,7 @@ extension MenuBarItem: Hashable { hasher.combine(sourcePID) hasher.combine(NSStringFromRect(bounds)) hasher.combine(title) - hasher.combine(isOnscreen) + hasher.combine(isOnScreen) } } diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift index b8a8dfc6c..fb51a94ed 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift @@ -189,7 +189,7 @@ final class MenuBarItemImageCache: ObservableObject { private nonisolated func captureImages(of items: [MenuBarItem], scale: CGFloat, appState: AppState) async -> CaptureResult { // Use individual capture after a move operation, since composite capture // doesn't account for overlapping items. - if await appState.itemManager.latestMoveOperationStarted(within: .seconds(2)) { + if await appState.itemManager.lastMoveOperationOccurred(within: .seconds(2)) { logger.debug("Capturing individually due to recent item movement") return individualCapture(items, scale: scale) } @@ -288,7 +288,7 @@ final class MenuBarItemImageCache: ObservableObject { } } - guard await !appState.itemManager.latestMoveOperationStarted(within: .seconds(1)) else { + guard await !appState.itemManager.lastMoveOperationOccurred(within: .seconds(1)) else { logger.debug("Skipping item image cache due to recent item movement") return } diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index 7b924a5c1..f4aec995a 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -23,18 +23,14 @@ final class MenuBarItemManager: ObservableObject { /// Actor for managing menu bar item cache operations. private let cacheActor = CacheActor() - /// Window identifiers for the most recently cached menu bar items. - private var cachedItemWindowIDs = [CGWindowID]() - /// Contexts for temporarily shown menu bar items. private var temporarilyShownItemContexts = [TemporarilyShownItemContext]() /// A timer for rehiding temporarily shown menu bar items. private var rehideTimer: Timer? - /// A timestamp representing the start of the most recent menu - /// bar item move operation. - private var latestMoveOperationTimestamp: ContinuousClock.Instant? + /// Timestamp of the most recent menu bar item move operation. + private var lastMoveOperationTimestamp: ContinuousClock.Instant? /// Cached timeouts for move operations. private var moveOperationTimeouts = [MenuBarItemTag: Duration]() @@ -84,10 +80,10 @@ final class MenuBarItemManager: ObservableObject { cancellables = c } - /// Returns a Boolean value that indicates whether the latest menu bar - /// item move operation was started within the given time duration. - func latestMoveOperationStarted(within duration: Duration) -> Bool { - guard let timestamp = latestMoveOperationTimestamp else { + /// Returns a Boolean value that indicates whether the most recent + /// menu bar item move operation occurred within the given duration. + func lastMoveOperationOccurred(within duration: Duration) -> Bool { + guard let timestamp = lastMoveOperationTimestamp else { return false } return timestamp.duration(to: .now) <= duration @@ -99,17 +95,33 @@ final class MenuBarItemManager: ObservableObject { extension MenuBarItemManager { /// An actor that manages menu bar item cache operations. private final actor CacheActor { + /// Stored task for the current cache operation. private var cacheTask: Task? + /// A list of the menu bar item window identifiers at the time + /// of the previous cache. + private(set) var cachedItemWindowIDs = [CGWindowID]() + /// Runs the given async closure as a task and waits for it to /// complete before returning. /// /// If a task from a previous call to this method is currently /// running, that task is cancelled and replaced. func runCacheTask(_ operation: @escaping () async -> Void) async { - cacheTask?.cancel() - cacheTask = Task(operation: operation) - await cacheTask?.value + cacheTask.take()?.cancel() + let task = Task(operation: operation) + cacheTask = task + await task.value + } + + /// Updates the list of cached menu bar item window identifiers. + func updateCachedItemWindowIDs(_ itemWindowIDs: [CGWindowID]) { + cachedItemWindowIDs = itemWindowIDs + } + + /// Clears the list of cached menu bar item window identifiers. + func clearCachedItemWindowIDs() { + cachedItemWindowIDs.removeAll() } } @@ -231,13 +243,16 @@ extension MenuBarItemManager { } func bestBounds(for item: MenuBarItem) -> CGRect { - MenuBarItem.currentBounds(for: item) ?? item.bounds + Bridging.getWindowBounds(for: item.windowID) ?? item.bounds } func isValidForCaching(_ item: MenuBarItem) -> Bool { if !item.canBeHidden { return false } + if item.isSystemClone { + return false + } if item.isControlItem, item.tag != .visibleControlItem { return false } @@ -274,7 +289,7 @@ extension MenuBarItemManager { items: [MenuBarItem], controlItems: ControlItemPair, displayID: CGDirectDisplayID? - ) { + ) async { var context = CacheContext(controlItems: controlItems, displayID: displayID) for item in items where context.isValidForCaching(item) { @@ -306,59 +321,132 @@ extension MenuBarItemManager { if context.shouldClearCachedItemWindowIDs { logger.info("Clearing cached menu bar item windowIDs") - cachedItemWindowIDs.removeAll() // Make sure we don't skip the next cache attempt. + await cacheActor.clearCachedItemWindowIDs() // Ensure next cache isn't skipped. } itemCache = context.cache logger.debug("Updated menu bar item cache") } - /// Caches the current menu bar items regardless of the current item - /// state, ensuring that the control items are correctly ordered. + /// Caches the current menu bar items, regardless of whether the + /// items have changed since the previous cache. + /// + /// Before caching, this method ensures that the control items for + /// the hidden and always-hidden sections are correctly ordered, + /// arranging them into valid positions if needed. func cacheItemsRegardless(_ currentItemWindowIDs: [CGWindowID]? = nil) async { await cacheActor.runCacheTask { [weak self] in guard let self else { return } + guard !lastMoveOperationOccurred(within: .seconds(1)) else { + logger.debug("Skipping menu bar item cache due to recent item movement") + return + } + let displayID = Bridging.getActiveMenuBarDisplayID() var items = await MenuBarItem.getMenuBarItems(option: .activeSpace) - cachedItemWindowIDs = currentItemWindowIDs ?? items.reversed().map { $0.windowID } + let itemWindowIDs = currentItemWindowIDs ?? items.reversed().map { $0.windowID } + await cacheActor.updateCachedItemWindowIDs(itemWindowIDs) guard let controlItems = ControlItemPair(items: &items) else { // ???: Is clearing the cache the best thing to do here? - logger.warning("Missing control item for hidden section - clearing menu bar item cache") + logger.warning("Missing control item for hidden section, clearing menu bar item cache") itemCache = ItemCache(displayID: nil) return } await enforceControlItemOrder(controlItems: controlItems) - uncheckedCacheItems(items: items, controlItems: controlItems, displayID: displayID) + await uncheckedCacheItems(items: items, controlItems: controlItems, displayID: displayID) } } - /// Caches the current menu bar items if needed, ensuring that the - /// control items are correctly ordered. + /// Caches the current menu bar items, if the items have changed + /// since the previous cache. + /// + /// Before caching, this method ensures that the control items for + /// the hidden and always-hidden sections are correctly ordered, + /// arranging them into valid positions if needed. func cacheItemsIfNeeded() async { - guard !latestMoveOperationStarted(within: .seconds(1)) else { - logger.debug("Skipping menu bar item cache due to recent item movement") - return - } - let itemWindowIDs = Bridging.getMenuBarWindowList(option: [.itemsOnly, .activeSpace]) - - guard cachedItemWindowIDs != itemWindowIDs else { - return + if await cacheActor.cachedItemWindowIDs != itemWindowIDs { + await cacheItemsRegardless(itemWindowIDs) } - - await cacheItemsRegardless(itemWindowIDs) } } -// MARK: - User Input Checks +// MARK: - Event Helpers extension MenuBarItemManager { + /// An error that can occur during menu bar item event operations. + enum EventError: CustomStringConvertible, LocalizedError { + /// A generic indication of a failure. + case cannotComplete + /// An event source cannot be created or is otherwise invalid. + case invalidEventSource + /// The location of the mouse cannot be found. + case missingMouseLocation + /// A failure during the creation of an event. + case eventCreationFailure(MenuBarItem) + /// A timeout during an event operation. + case eventOperationTimeout(MenuBarItem) + /// A menu bar item is not movable. + case itemNotMovable(MenuBarItem) + /// A timeout waiting for a menu bar item to respond to an event. + case itemResponseTimeout(MenuBarItem) + /// A menu bar item's bounds cannot be found. + case missingItemBounds(MenuBarItem) + + var description: String { + switch self { + case .cannotComplete: + "\(Self.self).cannotComplete" + case .invalidEventSource: + "\(Self.self).invalidEventSource" + case .missingMouseLocation: + "\(Self.self).missingMouseLocation" + case .eventCreationFailure(let item): + "\(Self.self).eventCreationFailure(item: \(item.tag))" + case .eventOperationTimeout(let item): + "\(Self.self).eventOperationTimeout(item: \(item.tag))" + case .itemNotMovable(let item): + "\(Self.self).itemNotMovable(item: \(item.tag))" + case .itemResponseTimeout(let item): + "\(Self.self).itemResponseTimeout(item: \(item.tag))" + case .missingItemBounds(let item): + "\(Self.self).missingItemBounds(item: \(item.tag))" + } + } + + var errorDescription: String? { + switch self { + case .cannotComplete: + "Operation could not be completed" + case .invalidEventSource: + "Invalid event source" + case .missingMouseLocation: + "Missing mouse location" + case .eventCreationFailure(let item): + "Could not create event for \"\(item.displayName)\"" + case .eventOperationTimeout(let item): + "Event operation timed out for \"\(item.displayName)\"" + case .itemNotMovable(let item): + "\"\(item.displayName)\" is not movable" + case .itemResponseTimeout(let item): + "\"\(item.displayName)\" took too long to respond" + case .missingItemBounds(let item): + "Missing bounds rectangle for \"\(item.displayName)\"" + } + } + + var recoverySuggestion: String? { + if case .itemNotMovable = self { return nil } + return "Please try again. If the error persists, please file a bug report." + } + } + /// Returns a Boolean value that indicates whether the user has /// paused input for at least the given duration. /// @@ -372,110 +460,35 @@ extension MenuBarItemManager { } /// Waits asynchronously for the user to pause input. - /// - /// - Parameter timeout: The duration to wait before throwing an error. - private nonisolated func waitForUserToPauseInput(timeout: Duration = .seconds(30)) async throws { - let duration = Duration.milliseconds(100) - if hasUserPausedInput(for: duration) { - return - } - let waitTask = Task(timeout: timeout) { + private nonisolated func waitForUserToPauseInput() async throws { + let waitTask = Task { while true { try Task.checkCancellation() - if hasUserPausedInput(for: duration) { + if hasUserPausedInput(for: .milliseconds(50)) { break } - try await Task.sleep(for: duration * 2) + try await Task.sleep(for: .milliseconds(250)) } } - try await waitTask.value - } -} - -// MARK: - Event Helpers - -extension MenuBarItemManager { - /// An error that can occur during menu bar item event operations. - struct EventError: Error, CustomStringConvertible, LocalizedError { - /// Error codes within the domain of menu bar item event errors. - enum ErrorCode: Int, CustomStringConvertible { - /// A generic indication of a failure. - case cannotComplete - /// A failure during the creation of an event. - case eventCreationFailure - /// A timeout during an event operation. - case eventOperationTimeout - /// An event source cannot be created or is otherwise invalid. - case invalidEventSource - /// A menu bar item is not movable. - case itemNotMovable - /// A timeout waiting for a menu bar item to respond to an event. - case itemResponseTimeout - /// A menu bar item's bounds cannot be found. - case missingItemBounds - /// The location of the mouse cannot be found. - case missingMouseLocation - - /// Description of the code for debugging purposes. - var description: String { - switch self { - case .cannotComplete: "cannotComplete" - case .eventCreationFailure: "eventCreationFailure" - case .eventOperationTimeout: "eventOperationTimeout" - case .invalidEventSource: "invalidEventSource" - case .itemNotMovable: "itemNotMovable" - case .itemResponseTimeout: "itemResponseTimeout" - case .missingItemBounds: "missingItemBounds" - case .missingMouseLocation: "missingMouseLocation" - } - } - - /// A string to use for logging purposes. - var logString: String { - "\(self) (rawValue: \(rawValue))" - } - } - - /// The error code associated with the error. - let code: ErrorCode - - /// The menu bar item associated with the error. - let item: MenuBarItem - - /// Description of the error for debugging purposes. - var description: String { - var parameters = [String]() - parameters.append("code: \(code.logString)") - parameters.append("item: \(item.logString)") - return "\(Self.self)(\(parameters.joined(separator: ", ")))" + do { + try await waitTask.value + } catch { + throw EventError.cannotComplete } + } - /// Description of the error for display purposes. - var errorDescription: String? { - switch code { - case .cannotComplete: - #"Operation could not be completed for "\#(item.displayName)""# - case .eventCreationFailure: - #"Failed to create event for "\#(item.displayName)""# - case .eventOperationTimeout: - #"Timeout sending events to "\#(item.displayName)""# - case .invalidEventSource: - #"Invalid event source for "\#(item.displayName)""# - case .itemNotMovable: - #""\#(item.displayName)" is not movable"# - case .itemResponseTimeout: - #"Timeout waiting for response from "\#(item.displayName)""# - case .missingItemBounds: - #"Missing screen bounds for "\#(item.displayName)""# - case .missingMouseLocation: - #"Missing mouse location for "\#(item.displayName)""# + /// Waits between move operations for a dynamic amount of time, + /// based on the timestamp of the last move operation. + private nonisolated func waitForMoveOperationBuffer() async throws { + if let timestamp = await lastMoveOperationTimestamp { + let buffer = max(.milliseconds(25) - timestamp.duration(to: .now), .zero) + logger.debug("Move operation buffer: \(buffer)") + do { + try await Task.sleep(for: buffer) + } catch { + throw EventError.cannotComplete } } - - /// Suggestion for recovery from the error. - var recoverySuggestion: String? { - "Please try again. If the error persists, please file a bug report." - } } /// Waits for the given duration between event operations. @@ -492,8 +505,8 @@ extension MenuBarItemManager { /// Returns the current bounds for the given item. private nonisolated func getCurrentBounds(for item: MenuBarItem) async throws -> CGRect { let task = Task.detached(priority: .userInitiated) { - guard let bounds = MenuBarItem.currentBounds(for: item) else { - throw EventError(code: .missingItemBounds, item: item) + guard let bounds = Bridging.getWindowBounds(for: item.windowID) else { + throw EventError.missingItemBounds(item) } return bounds } @@ -501,9 +514,9 @@ extension MenuBarItemManager { } /// Returns the current mouse location. - private nonisolated func getMouseLocation(item: MenuBarItem) throws -> CGPoint { + private nonisolated func getMouseLocation() throws -> CGPoint { guard let location = MouseHelpers.locationCoreGraphics else { - throw EventError(code: .missingMouseLocation, item: item) + throw EventError.missingMouseLocation } return location } @@ -516,8 +529,7 @@ extension MenuBarItemManager { /// Returns an event source for a menu bar item event operation. private nonisolated func getEventSource( - with stateID: CGEventSourceStateID = .hidSystemState, - for item: MenuBarItem + with stateID: CGEventSourceStateID = .hidSystemState ) throws -> CGEventSource { enum Context { static var cache = [CGEventSourceStateID: CGEventSource]() @@ -526,45 +538,40 @@ extension MenuBarItemManager { return source } guard let source = CGEventSource(stateID: stateID) else { - throw EventError(code: .invalidEventSource, item: item) + throw EventError.invalidEventSource } Context.cache[stateID] = source return source } - /// Permits all events for an event source during the given suppression - /// states, suppressing local events for the given interval. - private nonisolated func permitAllEvents( - for stateID: CGEventSourceStateID, - during states: [CGEventSuppressionState], - suppressionInterval: TimeInterval, - item: MenuBarItem - ) throws { - let source = try getEventSource(with: stateID, for: item) + /// Prevents local events from being suppressed. + private nonisolated func permitLocalEvents() throws { + let source = try getEventSource(with: .combinedSessionState) + let states: [CGEventSuppressionState] = [ + .eventSuppressionStateRemoteMouseDrag, + .eventSuppressionStateSuppressionInterval, + ] for state in states { source.setLocalEventsFilterDuringSuppressionState(.permitAllEvents, state: state) } - source.localEventsSuppressionInterval = suppressionInterval + source.localEventsSuppressionInterval = 0 } - /// Casts forbidden magic to make a menu bar item receive an event. + /// Posts an event to the given menu bar item and waits until + /// it is received before returning. /// /// - Parameters: /// - event: The event to post. - /// - firstLocation: The first event tap location to post the event. - /// - secondLocation: The second event tap location to post the event. /// - item: The menu bar item that the event targets. - /// - timeout: The base duration to wait before throwing an error. The - /// value of this parameter is multiplied by `count` to produce the - /// actual timeout duration. - /// - count: The number of times to repeat the operation. As it is - /// considerably more efficient, prefer increasing this value over - /// repeatedly calling `scrombleEvent`. - private nonisolated func scrombleEvent( + /// - timeout: The base duration to wait before throwing an error. + /// The value of this parameter is multiplied by `count` to + /// produce the actual timeout duration. + /// - count: The number of times to repeat the operation. As it + /// is considerably more efficient, prefer increasing this value + /// over repeatedly calling `postEventWithBarrier`. + private nonisolated func postEventWithBarrier( _ event: CGEvent, - from firstLocation: EventTap.Location, - to secondLocation: EventTap.Location, - item: MenuBarItem, + to item: MenuBarItem, timeout: Duration, repeating count: Int = 1 ) async throws { @@ -572,21 +579,28 @@ extension MenuBarItemManager { defer { MouseHelpers.showCursor() } + guard let entryEvent = CGEvent.uniqueNullEvent(), let exitEvent = CGEvent.uniqueNullEvent() else { - throw EventError(code: .eventCreationFailure, item: item) + throw EventError.eventCreationFailure(item) } - var counter = count + let firstLocation = EventTap.Location.pid(getEventPID(for: item)) + let secondLocation = EventTap.Location.sessionEventTap + + var count = count var eventTaps = [EventTap]() let timeoutTask = Task(timeout: timeout * count) { try await withCheckedThrowingContinuation { continuation in - // Create a tap for the entry and exit events at the first location. - // On entry, decrement the count and post the real event. - // On exit, resume the continuation. + // Listen for the following events at the first location and + // perform the following actions: + // + // - Entry event: Decrement the count and post the real event + // to the second location. + // - Exit event: Disable the tap and resume the continuation. let eventTap1 = EventTap( label: "EventTap 1", type: .null, @@ -595,7 +609,7 @@ extension MenuBarItemManager { option: .defaultTap ) { tap, rEvent in if rEvent.matches(entryEvent, byIntegerFields: [.eventSourceUserData]) { - counter -= 1 + count -= 1 event.post(to: secondLocation) return nil } @@ -607,9 +621,11 @@ extension MenuBarItemManager { return rEvent } - // Create a tap for the real event at the second location. If the - // count has reached zero, post the exit event. Otherwise, repost - // the entry event to go around again. + // Listen for the real event at the second location and + // perform the following actions: + // + // - If count <= 0: Disable the tap and post the exit event. + // - Otherwise: Repost the entry event to start another pass. let eventTap2 = EventTap( label: "EventTap 2", type: event.type, @@ -620,7 +636,7 @@ extension MenuBarItemManager { guard rEvent.matches(event, byIntegerFields: CGEventField.menuBarItemEventFields) else { return rEvent } - if counter <= 0 { + if count <= 0 { tap.disable() exitEvent.post(to: firstLocation) } else { @@ -649,9 +665,129 @@ extension MenuBarItemManager { do { try await timeoutTask.value } catch is TaskTimeoutError { - throw EventError(code: .eventOperationTimeout, item: item) + throw EventError.eventOperationTimeout(item) } catch { - throw EventError(code: .cannotComplete, item: item) + throw EventError.cannotComplete + } + } + + /// Casts forbidden magic to make a menu bar item receive and + /// respond to an event during a move operation. + /// + /// - Parameters: + /// - event: The event to post. + /// - item: The menu bar item that the event targets. + /// - timeout: The base duration to wait before throwing an error. + /// The value of this parameter is multiplied by `count` to + /// produce the actual timeout duration. + /// - count: The number of times to repeat the operation. As it + /// is considerably more efficient, prefer increasing this value + /// over repeatedly calling `scrombleEvent`. + private nonisolated func scrombleEvent( + _ event: CGEvent, + item: MenuBarItem, + timeout: Duration, + repeating count: Int = 1 + ) async throws { + MouseHelpers.hideCursor() + defer { + MouseHelpers.showCursor() + } + + guard + let entryEvent = CGEvent.uniqueNullEvent(), + let exitEvent = CGEvent.uniqueNullEvent() + else { + throw EventError.eventCreationFailure(item) + } + + let firstLocation = EventTap.Location.pid(getEventPID(for: item)) + let secondLocation = EventTap.Location.sessionEventTap + + var count = count + var eventTaps = [EventTap]() + + let timeoutTask = Task(timeout: timeout * count) { + try await withCheckedThrowingContinuation { continuation in + // Listen for the following events at the first location and + // perform the following actions: + // + // - Entry event: Decrement the count and post the real event + // to the second location. + // - Exit event: Disable the tap and resume the continuation. + // - Real event: + // - If count <= 0: Post the exit event. + // - Otherwise: Repost the entry event to start another pass. + let eventTap1 = EventTap( + label: "EventTap 1", + types: [.null, event.type], + location: firstLocation, + placement: .headInsertEventTap, + option: .defaultTap + ) { tap, rEvent in + switch rEvent.type { + case .null where rEvent.matches(entryEvent, byIntegerFields: [.eventSourceUserData]): + count -= 1 + event.post(to: secondLocation) + return nil + case .null where rEvent.matches(exitEvent, byIntegerFields: [.eventSourceUserData]): + tap.disable() + continuation.resume() + return nil + case event.type where rEvent.matches(event, byIntegerFields: CGEventField.menuBarItemEventFields): + if count <= 0 { + exitEvent.post(to: firstLocation) + } else { + entryEvent.post(to: firstLocation) + } + return rEvent + default: + return rEvent + } + } + + // Listen for the real event at the second location and forward + // it back to the first location. If count <= 0, disable the tap. + let eventTap2 = EventTap( + label: "EventTap 2", + type: event.type, + location: secondLocation, + placement: .tailAppendEventTap, + option: .listenOnly + ) { tap, rEvent in + guard rEvent.matches(event, byIntegerFields: CGEventField.menuBarItemEventFields) else { + return rEvent + } + if count <= 0 { + tap.disable() + } + event.post(to: firstLocation) + return rEvent + } + + // Keep the taps alive. + eventTaps.append(eventTap1) + eventTaps.append(eventTap2) + + Task { + await withTaskCancellationHandler { + eventTap1.enable() + eventTap2.enable() + entryEvent.post(to: firstLocation) + } onCancel: { + eventTap1.disable() + eventTap2.disable() + continuation.resume(throwing: CancellationError()) + } + } + } + } + do { + try await timeoutTask.value + } catch is TaskTimeoutError { + throw EventError.eventOperationTimeout(item) + } catch { + throw EventError.cannotComplete } } } @@ -682,24 +818,33 @@ extension MenuBarItemManager { } } - /// Returns the timeout for move operations associated with the - /// given item. + /// Returns the default timeout for move operations associated + /// with the given item. + private func getDefaultMoveOperationTimeout(for item: MenuBarItem) -> Duration { + if item.isBentoBox { + // Bento Boxes (i.e. Control Center groups) generally + // take a little longer to respond. + return .milliseconds(100) + } + return .milliseconds(50) + } + + /// Returns the cached timeout for move operations associated + /// with the given item. private func getMoveOperationTimeout(for item: MenuBarItem) -> Duration { if let timeout = moveOperationTimeouts[item.tag] { return timeout } - if item.isBentoBox { - // Bento Boxes (i.e. Control Center groups) generally take - // a little longer to respond. - return .milliseconds(100) - } - return .milliseconds(25) + return getDefaultMoveOperationTimeout(for: item) } - /// Updates the timeout for move operations associated with the - /// given item. + /// Updates the cached timeout for move operations associated + /// with the given item. private func updateMoveOperationTimeout(_ timeout: Duration, for item: MenuBarItem) { - moveOperationTimeouts[item.tag] = min(timeout, .milliseconds(100)) + let current = getMoveOperationTimeout(for: item) + let average = (timeout + current) / 2 + let clamped = average.clamped(min: .milliseconds(25), max: .milliseconds(150)) + moveOperationTimeouts[item.tag] = clamped } /// Returns the target points for creating the events needed to @@ -766,7 +911,7 @@ extension MenuBarItemManager { defer { MouseHelpers.showCursor() } - let responseTask = Task.detached(timeout: timeout) { + let responseTask = Task.detached { while true { try Task.checkCancellation() let origin = try await self.getCurrentBounds(for: item).origin @@ -775,8 +920,15 @@ extension MenuBarItemManager { } } } + let timeoutTask = Task(timeout: timeout) { + try await withTaskCancellationHandler { + try await responseTask.value + } onCancel: { + responseTask.cancel() + } + } do { - let origin = try await responseTask.value + let origin = try await timeoutTask.value logger.debug( """ Item responded to events with new origin: \ @@ -787,9 +939,9 @@ extension MenuBarItemManager { } catch let error as EventError { throw error } catch is TaskTimeoutError { - throw EventError(code: .itemResponseTimeout, item: item) + throw EventError.itemResponseTimeout(item) } catch { - throw EventError(code: .cannotComplete, item: item) + throw EventError.cannotComplete } } @@ -799,15 +951,7 @@ extension MenuBarItemManager { /// - Parameters: /// - item: The menu bar item to move. /// - destination: The destination to move the menu bar item. - /// - source: The event source used to create the events. - /// - timeout: The duration for each individual operation to wait - /// before throwing an error. - private nonisolated func postMoveEvents( - item: MenuBarItem, - destination: MoveDestination, - source: CGEventSource, - timeout: Duration - ) async throws { + private func postMoveEvents(item: MenuBarItem, destination: MoveDestination) async throws { try await eventSemaphore.waitUnlessCancelled() defer { eventSemaphore.signal() @@ -815,43 +959,43 @@ extension MenuBarItemManager { var itemOrigin = try await getCurrentBounds(for: item).origin let targetPoints = try await getTargetPoints(forMoving: item, to: destination) - let mouseLocation = try getMouseLocation(item: item) - let pid = getEventPID(for: item) + let mouseLocation = try getMouseLocation() + let source = try getEventSource() + + try permitLocalEvents() guard - let moveEvent1 = CGEvent.menuBarItemEvent( + let mouseDown = CGEvent.menuBarItemEvent( + item: item, source: source, type: .move(.mouseDown), - location: targetPoints.start, - item: item, - pid: pid + location: targetPoints.start ), - let moveEvent2 = CGEvent.menuBarItemEvent( + let mouseUp = CGEvent.menuBarItemEvent( + item: destination.targetItem, source: source, type: .move(.mouseUp), - location: targetPoints.end, - item: destination.targetItem, - pid: pid + location: targetPoints.end ) else { - throw EventError(code: .eventCreationFailure, item: item) + throw EventError.eventCreationFailure(item) } - await MainActor.run { - latestMoveOperationTimestamp = .now - } + var timeout = getMoveOperationTimeout(for: item) + logger.debug("Move operation timeout: \(timeout)") + lastMoveOperationTimestamp = .now MouseHelpers.hideCursor() defer { MouseHelpers.warpCursor(to: mouseLocation) MouseHelpers.showCursor() + lastMoveOperationTimestamp = .now + updateMoveOperationTimeout(timeout, for: item) } do { try await scrombleEvent( - moveEvent1, - from: .pid(pid), - to: .sessionEventTap, + mouseDown, item: item, timeout: timeout ) @@ -861,9 +1005,7 @@ extension MenuBarItemManager { timeout: timeout ) try await scrombleEvent( - moveEvent2, - from: .pid(pid), - to: .sessionEventTap, + mouseUp, item: item, timeout: timeout, repeating: 2 // Double mouse up prevents invalid item state. @@ -873,15 +1015,14 @@ extension MenuBarItemManager { initialOrigin: itemOrigin, timeout: timeout ) + timeout -= timeout / 4 } catch { do { - logger.debug("Move events failed, posting fallback") + logger.warning("Move events failed, posting fallback") try await scrombleEvent( - moveEvent2, - from: .pid(pid), - to: .sessionEventTap, + mouseUp, item: item, - timeout: timeout, + timeout: .milliseconds(100), // Fixed timeout for fallback. repeating: 2 // Double mouse up prevents invalid item state. ) } catch { @@ -889,6 +1030,7 @@ extension MenuBarItemManager { // the original error. logger.error("Fallback failed with error: \(error, privacy: .public)") } + timeout += timeout / 2 throw error } } @@ -899,44 +1041,15 @@ extension MenuBarItemManager { /// - item: The menu bar item to move. /// - destination: The destination to move the item to. func move(item: MenuBarItem, to destination: MoveDestination) async throws { - guard try await !itemHasCorrectPosition(item: item, for: destination) else { - logger.log("\(item.logString, privacy: .public) already has correct position") - return - } guard item.isMovable else { - throw EventError(code: .itemNotMovable, item: item) + throw EventError.itemNotMovable(item) } guard let appState else { - throw EventError(code: .cannotComplete, item: item) + throw EventError.cannotComplete } - do { - try await waitForUserToPauseInput() - } catch { - throw EventError(code: .cannotComplete, item: item) - } - - try permitAllEvents( - for: .combinedSessionState, - during: [ - .eventSuppressionStateRemoteMouseDrag, - .eventSuppressionStateSuppressionInterval, - ], - suppressionInterval: 0, - item: item - ) - - appState.eventManager.stopAll() - defer { - appState.eventManager.startAll() - } - - let source = try getEventSource(for: item) - var timeout = getMoveOperationTimeout(for: item) - - defer { - updateMoveOperationTimeout(timeout, for: item) - } + try await waitForUserToPauseInput() + try await waitForMoveOperationBuffer() logger.log( """ @@ -945,43 +1058,43 @@ extension MenuBarItemManager { """ ) - let maxAttempts = 10 + guard try await !itemHasCorrectPosition(item: item, for: destination) else { + logger.debug("Item has correct position, cancelling move") + return + } + + appState.eventManager.stopAll() + MouseHelpers.hideCursor() + defer { + MouseHelpers.showCursor() + appState.eventManager.startAll() + } - moveLoop: for n in 1...maxAttempts { + let maxAttempts = 8 + for n in 1...maxAttempts { guard !Task.isCancelled else { - throw EventError(code: .cannotComplete, item: item) + throw EventError.cannotComplete } - - attempt: do { + do { if try await itemHasCorrectPosition(item: item, for: destination) { - logger.debug("Item has correct position") - break attempt + logger.debug("Item has correct position, finished with move") + return } - try await postMoveEvents( - item: item, - destination: destination, - source: source, - timeout: timeout - ) - timeout -= timeout / 2 - } catch where n < maxAttempts { - logger.debug("Attempt \(n, privacy: .public) failed: \(error, privacy: .public)") - await eventSleep() - timeout += timeout / 2 - continue moveLoop - } catch let error as EventError { - timeout += timeout / 2 - throw error + try await postMoveEvents(item: item, destination: destination) + logger.debug("Attempt \(n, privacy: .public) succeeded, finished with move") + return } catch { - timeout += timeout / 2 - throw EventError(code: .cannotComplete, item: item) + logger.debug("Attempt \(n, privacy: .public) failed: \(error, privacy: .public)") + if n < maxAttempts { + try await waitForMoveOperationBuffer() + continue + } + if error is EventError { + throw error + } + throw EventError.cannotComplete } - - logger.debug("Attempt \(n, privacy: .public) succeeded") - break moveLoop } - - logger.log("Successfully moved \(item.logString, privacy: .public)") } } @@ -1005,42 +1118,36 @@ extension MenuBarItemManager { /// - Parameters: /// - item: The menu bar item to click. /// - mouseButton: The mouse button to click the item with. - /// - source: The event source used to create the events. - /// - timeout: The duration for each individual operation to wait - /// before throwing an error. - private nonisolated func postClickEvents( - item: MenuBarItem, - mouseButton: CGMouseButton, - source: CGEventSource, - timeout: Duration - ) async throws { + private func postClickEvents(item: MenuBarItem, mouseButton: CGMouseButton) async throws { try await eventSemaphore.waitUnlessCancelled() defer { eventSemaphore.signal() } let clickPoint = try await getCurrentBounds(for: item).center - let mouseLocation = try getMouseLocation(item: item) + let mouseLocation = try getMouseLocation() + let source = try getEventSource() + + try permitLocalEvents() + let clickTypes = getClickSubtypes(for: mouseButton) - let pid = getEventPID(for: item) + let timeout = Duration.milliseconds(250) guard - let clickEvent1 = CGEvent.menuBarItemEvent( + let mouseDown = CGEvent.menuBarItemEvent( + item: item, source: source, type: .click(clickTypes.down), - location: clickPoint, - item: item, - pid: pid + location: clickPoint ), - let clickEvent2 = CGEvent.menuBarItemEvent( + let mouseUp = CGEvent.menuBarItemEvent( + item: item, source: source, type: .click(clickTypes.up), - location: clickPoint, - item: item, - pid: pid + location: clickPoint ) else { - throw EventError(code: .eventCreationFailure, item: item) + throw EventError.eventCreationFailure(item) } MouseHelpers.hideCursor() @@ -1050,29 +1157,23 @@ extension MenuBarItemManager { } do { - try await scrombleEvent( - clickEvent1, - from: .pid(pid), - to: .sessionEventTap, - item: item, + try await postEventWithBarrier( + mouseDown, + to: item, timeout: timeout ) - try await scrombleEvent( - clickEvent2, - from: .pid(pid), - to: .sessionEventTap, - item: item, + try await postEventWithBarrier( + mouseUp, + to: item, timeout: timeout, repeating: 2 // Double mouse up prevents invalid item state. ) } catch { do { - logger.debug("Click events failed, posting fallback") - try await scrombleEvent( - clickEvent2, - from: .pid(pid), - to: .sessionEventTap, - item: item, + logger.warning("Click events failed, posting fallback") + try await postEventWithBarrier( + mouseUp, + to: item, timeout: timeout, repeating: 2 // Double mouse up prevents invalid item state. ) @@ -1092,32 +1193,10 @@ extension MenuBarItemManager { /// - mouseButton: The mouse button to click the item with. func click(item: MenuBarItem, with mouseButton: CGMouseButton) async throws { guard let appState else { - throw EventError(code: .cannotComplete, item: item) - } - - do { - try await waitForUserToPauseInput() - } catch { - throw EventError(code: .cannotComplete, item: item) - } - - try permitAllEvents( - for: .combinedSessionState, - during: [ - .eventSuppressionStateRemoteMouseDrag, - .eventSuppressionStateSuppressionInterval, - ], - suppressionInterval: 0, - item: item - ) - - appState.eventManager.stopAll() - defer { - appState.eventManager.startAll() + throw EventError.cannotComplete } - let source = try getEventSource(for: item) - let timeout = Duration.milliseconds(250) + try await waitForUserToPauseInput() logger.log( """ @@ -1126,14 +1205,32 @@ extension MenuBarItemManager { """ ) - try await postClickEvents( - item: item, - mouseButton: mouseButton, - source: source, - timeout: timeout - ) + appState.eventManager.stopAll() + defer { + appState.eventManager.startAll() + } - logger.log("Successfully clicked \(item.logString, privacy: .public)") + let maxAttempts = 4 + for n in 1...maxAttempts { + guard !Task.isCancelled else { + throw EventError.cannotComplete + } + do { + try await postClickEvents(item: item, mouseButton: mouseButton) + logger.debug("Attempt \(n, privacy: .public) succeeded, finished with click") + return + } catch { + logger.debug("Attempt \(n, privacy: .public) failed: \(error, privacy: .public)") + if n < maxAttempts { + await eventSleep() + continue + } + if error is EventError { + throw error + } + throw EventError.cannotComplete + } + } } } @@ -1166,12 +1263,14 @@ extension MenuBarItemManager { } if current.layer != CGWindowLevelForKey(.popUpMenuWindow), + current.layer != CGWindowLevelForKey(.popUpMenuWindow) - 1, current.layer != CGWindowLevelForKey(.statusWindow), + current.layer != CGWindowLevelForKey(.mainMenuWindow), let app = current.owningApplication { - return app.isActive && current.isOnscreen + return app.isActive && current.isOnScreen } - return current.isOnscreen + return current.isOnScreen } init(tag: MenuBarItemTag, returnDestination: MoveDestination) { @@ -1257,7 +1356,7 @@ extension MenuBarItemManager { // Remove items until we have enough room to show this item. items.trimPrefix { item in - if item.isOnscreen && item.canBeHidden { + if item.isOnScreen && item.canBeHidden { return item.bounds.minX <= maxX } return true @@ -1314,12 +1413,16 @@ extension MenuBarItemManager { guard !temporarilyShownItemContexts.isEmpty else { return } - guard !temporarilyShownItemContexts.contains(where: { $0.isShowingInterface }) else { logger.debug("Menu bar item interface is shown, so waiting to rehide") runRehideTimer(for: 3) return } + guard hasUserPausedInput(for: .milliseconds(250)) else { + logger.debug("Found recent user input, so waiting to rehide") + runRehideTimer(for: 1) + return + } var currentContexts = temporarilyShownItemContexts temporarilyShownItemContexts.removeAll() @@ -1329,6 +1432,11 @@ extension MenuBarItemManager { logger.debug("Rehiding temporarily shown items") + MouseHelpers.hideCursor() + defer { + MouseHelpers.showCursor() + } + while let context = currentContexts.popLast() { guard let item = items.first(matching: context.tag) else { continue @@ -1353,7 +1461,6 @@ extension MenuBarItemManager { failedContexts.append(context) } } - await eventSleep() } if failedContexts.isEmpty { @@ -1568,19 +1675,16 @@ private extension CGEvent { /// Returns an event that can be sent to a menu bar item. /// /// - Parameters: - /// - source: The source of the event. - /// - type: The type of the event. - /// - location: The location of the event. Does not need to be within - /// the bounds of the item. - /// - item: The target item of the event. - /// - pid: The target process identifier of the event. Does not need - /// to be the item's `ownerPID`. + /// - item: The event's target item. + /// - source: The event's source. + /// - type: The event's specialized type. + /// - location: The event's location. Does not need to be + /// within the bounds of the item. static func menuBarItemEvent( + item: MenuBarItem, source: CGEventSource, type: MenuBarItemEventType, - location: CGPoint, - item: MenuBarItem, - pid: pid_t + location: CGPoint ) -> CGEvent? { guard let event = CGEvent( mouseEventSource: source, @@ -1592,7 +1696,6 @@ private extension CGEvent { } event.setFlags(for: type) event.setUserData(ObjectIdentifier(event)) - event.setTargetPID(pid) event.setWindowID(item.windowID, for: type) event.setClickState(for: type) return event @@ -1648,11 +1751,6 @@ private extension CGEvent { setIntegerValueField(.eventSourceUserData, value: userData) } - private func setTargetPID(_ pid: pid_t) { - let targetPID = Int64(pid) - setIntegerValueField(.eventTargetUnixProcessID, value: targetPID) - } - private func setWindowID(_ windowID: CGWindowID, for type: MenuBarItemEventType) { let windowID = Int64(windowID) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift index b4f3456a2..9f5363716 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift @@ -25,7 +25,8 @@ struct MenuBarItemTag: Hashable, CustomStringConvertible { /// A Boolean value that indicates whether the item identified /// by this tag can be hidden. var canBeHidden: Bool { - !MenuBarItemTag.nonHideableItems.contains(self) + !MenuBarItemTag.nonHideableItems.contains(self) && + !(namespace.isUUID && title == "AudioVideoModule") } /// A Boolean value that indicates whether the item identified @@ -40,18 +41,20 @@ struct MenuBarItemTag: Hashable, CustomStringConvertible { namespace == .controlCenter && title.hasPrefix("BentoBox") } - /// A string representation of the tag. - var stringValue: String { - var result = namespace.stringValue - if !title.isEmpty { - result.append(":\(title)") - } - return result + /// A Boolean value that indicates whether the item identified + /// by this tag is a system-created clone of an actual item, + /// and therefore invalid for management. + var isSystemClone: Bool { + namespace.isUUID && title == "System Status Item Clone" } /// A textual representation of the tag. var description: String { - stringValue + var result = String(describing: namespace) + if !title.isEmpty { + result.append(":\(title)") + } + return result } /// Creates a tag with the given namespace and title. @@ -162,15 +165,15 @@ extension MenuBarItemTag { extension MenuBarItemTag { /// A type that represents a menu bar item namespace. enum Namespace: Hashable, CustomStringConvertible { - /// The null namespace. + /// The `null` namespace. case null - /// A namespace represented by string. + /// A namespace represented by a string. case string(String) - /// A namespace represented by uuid. + /// A namespace represented by a UUID. case uuid(UUID) - /// The namespace's string value. - var stringValue: String { + /// A textual representation of the namespace. + var description: String { switch self { case .null: "null" case .string(let string): string @@ -178,17 +181,39 @@ extension MenuBarItemTag { } } - /// A textual representation of the namespace. - var description: String { - stringValue + /// A Boolean value that indicates whether this namespace is + /// the `null` namespace. + var isNull: Bool { + switch self { + case .null: true + case .string, .uuid: false + } + } + + /// A Boolean value that indicates whether this namespace is + /// represented by a string. + var isString: Bool { + switch self { + case .string: true + case .uuid, .null: false + } + } + + /// A Boolean value that indicates whether this namespace is + /// represented by a UUID. + var isUUID: Bool { + switch self { + case .uuid: true + case .null, .string: false + } } /// Creates a namespace with the given optional value. - /// + /// /// - Parameter value: An optional value for the namespace. /// - /// - Returns: The ``string(_:)`` namespace when `value` is not `nil`. - /// Otherwise, the ``null`` namespace. + /// - Returns: A namespace represented by a string when `value` + /// is not `nil`. Otherwise, the `null` namespace. static func optional(_ value: String?) -> Namespace { value.map { .string($0) } ?? .null } diff --git a/Ice/MenuBar/Search/MenuBarSearchPanel.swift b/Ice/MenuBar/Search/MenuBarSearchPanel.swift index af9a0f0b2..bf4ace8f1 100644 --- a/Ice/MenuBar/Search/MenuBarSearchPanel.swift +++ b/Ice/MenuBar/Search/MenuBarSearchPanel.swift @@ -30,7 +30,7 @@ final class MenuBarSearchPanel: NSPanel { else { return event } - if !appState.itemManager.latestMoveOperationStarted(within: .seconds(1)) { + if !appState.itemManager.lastMoveOperationOccurred(within: .seconds(1)) { close() } return event @@ -377,7 +377,7 @@ private struct MenuBarSearchContentView: View { closePanel() Task { try await Task.sleep(for: .milliseconds(25)) - if item.isOnscreen { + if Bridging.isWindowOnScreen(item.windowID) { try await itemManager.click(item: item, with: .left) } else { await itemManager.temporarilyShow(item: item, clickingWith: .left) @@ -415,7 +415,7 @@ private struct ShowItemButton: View { var body: some View { Button(action: action) { HStack { - Text("\(item.isOnscreen ? "Click" : "Show") Item") + Text("\(Bridging.isWindowOnScreen(item.windowID) ? "Click" : "Show") Item") .padding(.leading, 5) Image(systemName: "return") diff --git a/Ice/Utilities/Extensions.swift b/Ice/Utilities/Extensions.swift index e7fc911b6..31db49b43 100644 --- a/Ice/Utilities/Extensions.swift +++ b/Ice/Utilities/Extensions.swift @@ -365,11 +365,34 @@ extension Collection where Element == MenuBarItem { // MARK: - Comparable extension Comparable { - /// Returns a copy of this value, clamped to the given limiting range. + /// Returns a copy of this value, clamped to the given minimum + /// and maximum limiting values. /// - /// - Parameter limits: A range of values to clamp the copy to. - func clamped(to limits: ClosedRange) -> Self { - min(max(self, limits.lowerBound), limits.upperBound) + /// - Parameters: + /// - min: The minimum limiting value. + /// - max: The maximum limiting value. + /// + /// - Precondition: `min <= max` + /// + /// - Returns: The value nearest this value that is both greater + /// than or equal to `min` and less than or equal to `max`. + func clamped(min: Self, max: Self) -> Self { + precondition(min <= max, "Clamp requires min <= max") + return Swift.min(Swift.max(self, min), max) + } + + /// Returns a copy of this value, clamped to the given limiting + /// range. + /// + /// - Parameter range: A range of values of this type, whose + /// lower and upper bounds represent the minimum and maximum + /// limiting values. + /// + /// - Returns: The value nearest this value that is both greater + /// than or equal to `range.lowerBound` and less than or equal + /// to `range.upperBound`. + func clamped(to range: ClosedRange) -> Self { + clamped(min: range.lowerBound, max: range.upperBound) } } diff --git a/Shared/Bridging/Bridging.swift b/Shared/Bridging/Bridging.swift index 1baed257c..561b9322a 100644 --- a/Shared/Bridging/Bridging.swift +++ b/Shared/Bridging/Bridging.swift @@ -272,6 +272,29 @@ extension Bridging { return windowIntersectsDisplayBounds(windowID, displayBounds) } + /// Returns a Boolean value that indicates whether the given window + /// is on screen. + /// + /// - Parameter windowID: An identifier for a window. + static func isWindowOnScreen(_ windowID: CGWindowID) -> Bool { + // On screen window list could potentially include menu bar + // items hidden via drag-and-drop (seems like a bug in macOS?). + // + // Checking individual displays could be relatively expensive, + // so we can at least short circuit if the window is _not_ in + // the list. + if !getOnScreenWindowList().contains(windowID) { + return false + } + guard let windowBounds = getWindowBounds(for: windowID) else { + return false + } + return getActiveDisplayList().contains { displayID in + let displayBounds = CGDisplayBounds(displayID) + return displayBounds.intersects(windowBounds) + } + } + // MARK: Private Window List Helpers private static func getWindowCount() -> Int32? { diff --git a/Shared/Bridging/Shims.swift b/Shared/Bridging/Shims.swift index 4496c8499..3cdf6e03f 100644 --- a/Shared/Bridging/Shims.swift +++ b/Shared/Bridging/Shims.swift @@ -149,13 +149,6 @@ func CGSGetScreenRectForWindow( _ outRect: inout CGRect ) -> CGError -@_silgen_name("CGSGetWindowBounds") -func CGSGetWindowBounds( - _ cid: CGSConnectionID, - _ wid: CGWindowID, - _ outBounds: inout CGRect -) -> CGError - @_silgen_name("CGSGetWindowLevel") func CGSGetWindowLevel( _ cid: CGSConnectionID, diff --git a/Shared/Utilities/WindowInfo.swift b/Shared/Utilities/WindowInfo.swift index 434faf02c..9b68fa333 100644 --- a/Shared/Utilities/WindowInfo.swift +++ b/Shared/Utilities/WindowInfo.swift @@ -28,8 +28,8 @@ struct WindowInfo { /// a localized name. let ownerName: String? - /// A Boolean value that indicates whether the window is onscreen. - let isOnscreen: Bool + /// A Boolean value that indicates whether the window is on screen. + let isOnScreen: Bool /// The application that owns the window. var owningApplication: NSRunningApplication? { @@ -60,7 +60,7 @@ struct WindowInfo { self.layer = layer self.title = info[kCGWindowName] as? String self.ownerName = info[kCGWindowOwnerName] as? String - self.isOnscreen = info[kCGWindowIsOnscreen] as? Bool ?? false + self.isOnScreen = info[kCGWindowIsOnscreen] as? Bool ?? false } /// Creates a window with the given window identifier. @@ -142,7 +142,7 @@ extension WindowInfo { return windows.first { window in // Menu bar window belongs to the WindowServer process. window.isWindowServerWindow && - window.isOnscreen && + window.isOnScreen && window.layer == kCGMainMenuWindowLevel && window.title == "Menubar" && displayBounds.contains(window.bounds) @@ -167,7 +167,7 @@ extension WindowInfo: Equatable { lhs.layer == rhs.layer && lhs.title == rhs.title && lhs.ownerName == rhs.ownerName && - lhs.isOnscreen == rhs.isOnscreen + lhs.isOnScreen == rhs.isOnScreen } } @@ -180,6 +180,6 @@ extension WindowInfo: Hashable { hasher.combine(layer) hasher.combine(title) hasher.combine(ownerName) - hasher.combine(isOnscreen) + hasher.combine(isOnScreen) } } From 9978e491e4a3c28e1392ab5bf58c9dfaaf8faab6 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Tue, 16 Sep 2025 02:02:10 -0600 Subject: [PATCH 68/80] Update packages --- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Ice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Ice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f12204193..79f001b1c 100644 --- a/Ice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Ice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1220f1c3edc195fb1042614abebdfaa9e827392e81a19c77d52e34f846070cc4", + "originHash" : "977d7500481760b6dc046c9b6e7def6420058990c9c91809fa741c67a8f83c48", "pins" : [ { "identity" : "axswift", @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "df074165274afaa39539c05d57b0832620775b11", - "version" : "2.7.1" + "revision" : "9a1d2a19d3595fcf8d9c447173f9a1687b3dcadb", + "version" : "2.8.0" } } ], From 405c440488c90c2ec88484394e2e532e7a100452 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Tue, 16 Sep 2025 02:55:43 -0600 Subject: [PATCH 69/80] Update menu bar item display name --- Ice/MenuBar/MenuBarItems/MenuBarItem.swift | 60 +++++++++++++--------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift index ae3e329e3..8c7140f1b 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift @@ -77,7 +77,8 @@ struct MenuBarItem: CustomStringConvertible { return NSRunningApplication(processIdentifier: sourcePID) } - /// A name associated with the item that is suited for display. + // TODO: Generate this once, during initialization. + /// A name associated with the item, suited for display. var displayName: String { /// Converts "UpperCamelCase" to "Title Case". /// @@ -87,24 +88,24 @@ struct MenuBarItem: CustomStringConvertible { String(s).replacing(/([a-z]{2})([A-Z])/) { $0.output.1 + " " + $0.output.2 } } - guard let sourceApplication else { - return "Menu Bar Item" + guard !isControlItem else { + return Constants.displayName } - var bestName: String { - if isControlItem { - Constants.displayName - } else { - sourceApplication.localizedName ?? - sourceApplication.bundleIdentifier ?? - title ?? "Unknown" - } + lazy var fallbackName = "Menu Bar Item" + + guard let sourceApplication else { + return fallbackName } + lazy var sourceName = sourceApplication.localizedName ?? sourceApplication.bundleIdentifier + guard let title else { - return bestName + return sourceName ?? fallbackName } + lazy var bestName = sourceName ?? title + guard !isBentoBox else { if tag == .controlCenter { return bestName @@ -114,29 +115,38 @@ struct MenuBarItem: CustomStringConvertible { // Most items use their computed "best name", but we handle // a few special cases for system items. - switch tag.namespace { + let displayName = switch tag.namespace { case .passwords, .weather, .textInputMenuAgent: // "PasswordsMenuBarExtra" -> "Passwords" // "WeatherMenu" -> "Weather" // "TextInputMenuAgent" -> "Text Input" - return toTitleCase(bestName.replacing(/Menu.*/, with: "")) + toTitleCase(bestName.replacing(/Menu.*/, with: "")) case .controlCenter: - guard let match = title.prefixMatch(of: /Hearing/) else { - return toTitleCase(title) + if let match = title.prefixMatch(of: /Hearing/) { + // Changed from "Hearing" to "Hearing_GlowE" in macOS 15.4 + toTitleCase(match.output) + } else { + toTitleCase(title) } - // Changed from "Hearing" to "Hearing_GlowE" in macOS 15.4 - return toTitleCase(match.output) case .systemUIServer: - guard let match = title.firstMatch(of: /TimeMachine/) else { - return toTitleCase(title) + if let match = title.firstMatch(of: /TimeMachine/) { + // Sonoma: "TimeMachine.TMMenuExtraHost" + // Sequoia: "TimeMachineMenuExtra.TMMenuExtraHost" + // Tahoe: "com.apple.menuextra.TimeMachine" + toTitleCase(match.output) + } else { + toTitleCase(title) } - // Sonoma: "TimeMachine.TMMenuExtraHost" - // Sequoia: "TimeMachineMenuExtra.TMMenuExtraHost" - // Tahoe: "com.apple.menuextra.TimeMachine" - return toTitleCase(match.output) default: - return bestName + bestName } + + // Provide some extra context if the name is just a UUID. + if UUID(uuidString: displayName) != nil, let sourceName { + return "\(sourceName) (\(displayName))" + } + + return displayName } /// A textual representation of the item. From 28712ea5628e7363e0dc1b544489eead4d9ce9e2 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Tue, 16 Sep 2025 11:08:17 -0600 Subject: [PATCH 70/80] Use different `menuBarInsetAmount` depending on macOS version --- Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift index fd0ad43e3..8422ea58a 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceManager.swift @@ -32,7 +32,7 @@ final class MenuBarAppearanceManager: ObservableObject { private(set) var overlayPanels = Set() /// The amount to inset the menu bar if called for by the configuration. - let menuBarInsetAmount: CGFloat = 3.5 + let menuBarInsetAmount: CGFloat = if #available(macOS 26.0, *) { 3.5 } else { 5 } /// Performs initial setup of the manager. func performSetup(with appState: AppState) { From da2dd23cdd70d287445cbe3dbb5628a4c3ad8c7e Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Tue, 16 Sep 2025 11:14:39 -0600 Subject: [PATCH 71/80] Bump version and build numbers --- Ice.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Ice.xcodeproj/project.pbxproj b/Ice.xcodeproj/project.pbxproj index 5d4bf2538..7afb0513a 100644 --- a/Ice.xcodeproj/project.pbxproj +++ b/Ice.xcodeproj/project.pbxproj @@ -417,7 +417,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1119; + CURRENT_PROJECT_VERSION = 1120; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_APP_SANDBOX = NO; @@ -433,7 +433,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = "0.11.13-dev.1"; + MARKETING_VERSION = "0.11.13-dev.2"; PRODUCT_BUNDLE_IDENTIFIER = com.jordanbaird.Ice; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -450,7 +450,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1119; + CURRENT_PROJECT_VERSION = 1120; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_APP_SANDBOX = NO; @@ -466,7 +466,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = "0.11.13-dev.1"; + MARKETING_VERSION = "0.11.13-dev.2"; PRODUCT_BUNDLE_IDENTIFIER = com.jordanbaird.Ice; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; From ea4df545272a6151d90619f66d0d03eab8aa5f5b Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Wed, 17 Sep 2025 19:51:57 -0600 Subject: [PATCH 72/80] Don't update menu bar item cache if items haven't changed --- Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index f4aec995a..6723325b0 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -324,6 +324,11 @@ extension MenuBarItemManager { await cacheActor.clearCachedItemWindowIDs() // Ensure next cache isn't skipped. } + guard itemCache != context.cache else { + logger.debug("Not updating menu bar item cache, as items haven't changed") + return + } + itemCache = context.cache logger.debug("Updated menu bar item cache") } From 38d344f392888bf9a419815d31a660774a2ea31c Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Thu, 18 Sep 2025 08:33:17 -0600 Subject: [PATCH 73/80] Update screen capture Revert to using a direct call to the `CGImage` initializer, rather than calling it through a static protocol method (this was originally done to suppress the deprecation warning, but it seems to cause screen capture to fail for some users). --- Ice/Utilities/ScreenCapture.swift | 66 +++++++++++-------------------- 1 file changed, 22 insertions(+), 44 deletions(-) diff --git a/Ice/Utilities/ScreenCapture.swift b/Ice/Utilities/ScreenCapture.swift index 3cfe38227..64597b682 100644 --- a/Ice/Utilities/ScreenCapture.swift +++ b/Ice/Utilities/ScreenCapture.swift @@ -3,8 +3,6 @@ // Ice // -// MARK: - ScreenCapture - import CoreGraphics import ScreenCaptureKit @@ -13,7 +11,8 @@ enum ScreenCapture { // MARK: Permissions - /// Returns a Boolean value that indicates whether the app has screen capture permissions. + /// Returns a Boolean value that indicates whether the app has screen + /// capture permissions. static func checkPermissions() -> Bool { for windowID in Bridging.getMenuBarWindowList(option: [.itemsOnly, .activeSpace]) { guard @@ -24,24 +23,24 @@ enum ScreenCapture { } return window.title != nil } - // CGPreflightScreenCaptureAccess() only returns an initial value, but we can - // use it as a fallback. + // CGPreflightScreenCaptureAccess() only returns an initial value, + // but we can use it as a fallback. return CGPreflightScreenCaptureAccess() } - /// Returns a Boolean value that indicates whether the app has screen capture permissions. + /// Returns a Boolean value that indicates whether the app has screen + /// capture permissions. /// - /// This function caches its initial result and returns it on subsequent calls. Pass `true` - /// to the `reset` parameter to replace the cached result with a newly computed value. + /// This function caches its initial result and returns it on subsequent + /// calls. Pass `true` to the `reset` parameter to replace the cached + /// result with a newly computed value. static func cachedCheckPermissions(reset: Bool = false) -> Bool { enum Context { static var cachedResult: Bool? } - if !reset, let result = Context.cachedResult { return result } - let result = checkPermissions() Context.cachedResult = result return result @@ -50,9 +49,10 @@ enum ScreenCapture { /// Requests screen capture permissions. static func requestPermissions() { if #available(macOS 15.0, *) { - // TODO: Find out if we still need this. - // CGRequestScreenCaptureAccess() is broken on macOS 15. SCShareableContent requires - // screen capture permissions, and triggers a request if the user doesn't have them. + // CGRequestScreenCaptureAccess() is broken on macOS 15. We can + // try accessing SCShareableContent to trigger a request if the + // user doesn't have permissions. + // TODO: Find out if we still need this as of macOS 26. SCShareableContent.getWithCompletionHandler { _, _ in } } else { CGRequestScreenCaptureAccess() @@ -61,56 +61,34 @@ enum ScreenCapture { // MARK: Capture Window(s) - /// Queue for screen capture operations. - private static let captureQueue = DispatchQueue(label: "ScreenCapture.captureQueue", qos: .userInteractive) - /// Captures a composite image of an array of windows. /// - /// The windows are composited from front to back, according to the order of the `windowIDs` - /// parameter. + /// The windows are composited from front to back, according to the order + /// of the `windowIDs` parameter. /// /// - Parameters: /// - windowIDs: The identifiers of the windows to capture. - /// - screenBounds: The bounds to capture, specified in screen coordinates. Pass `nil` to - /// capture the minimum rectangle that encloses the windows. + /// - screenBounds: The bounds to capture, specified in screen coordinates. + /// Pass `nil` to capture the minimum rectangle that encloses the windows. /// - option: Options that specify which parts of the windows are captured. static func captureWindows(with windowIDs: [CGWindowID], screenBounds: CGRect? = nil, option: CGWindowImageOption = []) -> CGImage? { guard let array = Bridging.createCGWindowArray(with: windowIDs) else { return nil } let bounds = screenBounds ?? .null - return captureQueue.sync { - CGImage.createWindowListImageFromArray(screenBounds: bounds, windowArray: array, imageOption: option) - } + // ScreenCaptureKit doesn't support capturing images of offscreen menu bar + // items, so we unfortunately have to use the deprecated CGWindowList API. + return CGImage(windowListFromArrayScreenBounds: bounds, windowArray: array, imageOption: option) } /// Captures an image of a window. /// /// - Parameters: /// - windowID: The identifier of the window to capture. - /// - screenBounds: The bounds to capture, specified in screen coordinates. Pass `nil` to - /// capture the minimum rectangle that encloses the window. + /// - screenBounds: The bounds to capture, specified in screen coordinates. + /// Pass `nil` to capture the minimum rectangle that encloses the window. /// - option: Options that specify which parts of the window are captured. static func captureWindow(with windowID: CGWindowID, screenBounds: CGRect? = nil, option: CGWindowImageOption = []) -> CGImage? { captureWindows(with: [windowID], screenBounds: screenBounds, option: option) } } - -// MARK: - WindowListImage Helper - -/// A protocol to suppress warnings for the deprecated CGWindowList screen capture APIs. -/// -/// ScreenCaptureKit doesn't support capturing composite images of offscreen menu bar items. -/// This should be replaced once it does. -private protocol WindowListImage { - init?(windowListFromArrayScreenBounds: CGRect, windowArray: CFArray, imageOption: CGWindowImageOption) -} - -private extension WindowListImage { - @inline(__always) // Ensure a direct call to the initializer. - static func createWindowListImageFromArray(screenBounds: CGRect, windowArray: CFArray, imageOption: CGWindowImageOption) -> Self? { - Self(windowListFromArrayScreenBounds: screenBounds, windowArray: windowArray, imageOption: imageOption) - } -} - -extension CGImage: WindowListImage { } From 22959853af95b2517bd95b350a08de98f6a28bec Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Thu, 18 Sep 2025 08:33:57 -0600 Subject: [PATCH 74/80] Switch the MenuBarItemService connection to use a higher priority concurrent queue --- Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift index 7c17a5c13..97bd98c64 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift @@ -26,7 +26,11 @@ extension MenuBarItemService { /// Creates a new connection. private init() { - let queue = DispatchQueue.targetingGlobal(label: "MenuBarItemService.Connection.queue", qos: .utility) + let queue = DispatchQueue.targetingGlobal( + label: "MenuBarItemService.Connection.queue", + qos: .userInteractive, + attributes: .concurrent + ) let logger = Logger(category: "MenuBarItemService.Connection") self.session = Session(queue: queue, logger: logger) self.queue = queue From 8c0b9bbc2ff1999c9ec9d46570f4c332f61472d5 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Thu, 18 Sep 2025 08:53:10 -0600 Subject: [PATCH 75/80] Rework menu bar item event handling to for better reliability across environments --- .../MenuBarItems/MenuBarItemManager.swift | 103 ++++++++++++------ 1 file changed, 68 insertions(+), 35 deletions(-) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index 6723325b0..b1883aedb 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -592,7 +592,10 @@ extension MenuBarItemManager { throw EventError.eventCreationFailure(item) } - let firstLocation = EventTap.Location.pid(getEventPID(for: item)) + let pid = getEventPID(for: item) + event.setTargetPID(pid) + + let firstLocation = EventTap.Location.pid(pid) let secondLocation = EventTap.Location.sessionEventTap var count = count @@ -600,12 +603,15 @@ extension MenuBarItemManager { let timeoutTask = Task(timeout: timeout * count) { try await withCheckedThrowingContinuation { continuation in - // Listen for the following events at the first location and - // perform the following actions: + // Listen for the following events at the first location + // and perform the following actions: + // + // - Entry event: Decrement the count and post the real + // event to the second location (handled in EventTap 2). + // - Exit event: Resume the continuation. // - // - Entry event: Decrement the count and post the real event - // to the second location. - // - Exit event: Disable the tap and resume the continuation. + // These events serve as start (or continue) and stop + // signals, and are discarded. let eventTap1 = EventTap( label: "EventTap 1", type: .null, @@ -626,11 +632,9 @@ extension MenuBarItemManager { return rEvent } - // Listen for the real event at the second location and - // perform the following actions: - // - // - If count <= 0: Disable the tap and post the exit event. - // - Otherwise: Repost the entry event to start another pass. + // Listen for the real event at the second location and, + // depending on the count, post either the entry or exit + // event to the first location (handled in EventTap 1). let eventTap2 = EventTap( label: "EventTap 2", type: event.type, @@ -647,6 +651,7 @@ extension MenuBarItemManager { } else { entryEvent.post(to: firstLocation) } + rEvent.setTargetPID(pid) return rEvent } @@ -706,7 +711,10 @@ extension MenuBarItemManager { throw EventError.eventCreationFailure(item) } - let firstLocation = EventTap.Location.pid(getEventPID(for: item)) + let pid = getEventPID(for: item) + event.setTargetPID(pid) + + let firstLocation = EventTap.Location.pid(pid) let secondLocation = EventTap.Location.sessionEventTap var count = count @@ -714,45 +722,38 @@ extension MenuBarItemManager { let timeoutTask = Task(timeout: timeout * count) { try await withCheckedThrowingContinuation { continuation in - // Listen for the following events at the first location and - // perform the following actions: + // Listen for the following events at the first location + // and perform the following actions: // - // - Entry event: Decrement the count and post the real event - // to the second location. - // - Exit event: Disable the tap and resume the continuation. - // - Real event: - // - If count <= 0: Post the exit event. - // - Otherwise: Repost the entry event to start another pass. + // - Entry event: Decrement the count and post the real + // event to the second location (handled in EventTap 2). + // - Exit event: Resume the continuation. + // + // These events serve as start (or continue) and stop + // signals, and are discarded. let eventTap1 = EventTap( label: "EventTap 1", - types: [.null, event.type], + type: .null, location: firstLocation, placement: .headInsertEventTap, option: .defaultTap ) { tap, rEvent in - switch rEvent.type { - case .null where rEvent.matches(entryEvent, byIntegerFields: [.eventSourceUserData]): + if rEvent.matches(entryEvent, byIntegerFields: [.eventSourceUserData]) { count -= 1 event.post(to: secondLocation) return nil - case .null where rEvent.matches(exitEvent, byIntegerFields: [.eventSourceUserData]): + } + if rEvent.matches(exitEvent, byIntegerFields: [.eventSourceUserData]) { tap.disable() continuation.resume() return nil - case event.type where rEvent.matches(event, byIntegerFields: CGEventField.menuBarItemEventFields): - if count <= 0 { - exitEvent.post(to: firstLocation) - } else { - entryEvent.post(to: firstLocation) - } - return rEvent - default: - return rEvent } + return rEvent } - // Listen for the real event at the second location and forward - // it back to the first location. If count <= 0, disable the tap. + // Listen for the real event at the second location and + // post the real event to the first location (handled in + // EventTap 3). let eventTap2 = EventTap( label: "EventTap 2", type: event.type, @@ -767,21 +768,48 @@ extension MenuBarItemManager { tap.disable() } event.post(to: firstLocation) + rEvent.setTargetPID(pid) + return rEvent + } + + // Listen for the real event at the first location and, + // depending on the count, post either the entry or exit + // event to the first location (handled in EventTap 1). + let eventTap3 = EventTap( + label: "EventTap 3", + type: event.type, + location: firstLocation, + placement: .headInsertEventTap, + option: .listenOnly + ) { tap, rEvent in + guard rEvent.matches(event, byIntegerFields: CGEventField.menuBarItemEventFields) else { + return rEvent + } + if count <= 0 { + tap.disable() + exitEvent.post(to: firstLocation) + } else { + entryEvent.post(to: firstLocation) + } + rEvent.setTargetPID(pid) return rEvent } // Keep the taps alive. eventTaps.append(eventTap1) eventTaps.append(eventTap2) + eventTaps.append(eventTap3) Task { await withTaskCancellationHandler { eventTap1.enable() eventTap2.enable() + eventTap3.enable() entryEvent.post(to: firstLocation) } onCancel: { eventTap1.disable() eventTap2.disable() + eventTap3.disable() continuation.resume(throwing: CancellationError()) } } @@ -1747,6 +1775,11 @@ private extension CGEvent { } } + func setTargetPID(_ pid: pid_t) { + let targetPID = Int64(pid) + setIntegerValueField(.eventTargetUnixProcessID, value: targetPID) + } + private func setFlags(for type: MenuBarItemEventType) { flags = type.cgEventFlags } From f8828cd42b41025c58cee485a69620db24a7f178 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Sat, 20 Sep 2025 01:37:57 -0600 Subject: [PATCH 76/80] Rename `EventManager` to `HIDEventManager` and rework enabling/disabling its monitors --- ...entManager.swift => HIDEventManager.swift} | 207 +++++++++--------- Ice/Main/AppState.swift | 8 +- Ice/MenuBar/IceBar/IceBar.swift | 2 +- .../MenuBarItems/MenuBarItemManager.swift | 32 ++- Ice/MenuBar/MenuBarManager.swift | 4 +- 5 files changed, 144 insertions(+), 109 deletions(-) rename Ice/Events/{EventManager.swift => HIDEventManager.swift} (82%) diff --git a/Ice/Events/EventManager.swift b/Ice/Events/HIDEventManager.swift similarity index 82% rename from Ice/Events/EventManager.swift rename to Ice/Events/HIDEventManager.swift index a9ffa9d6c..16edb49ac 100644 --- a/Ice/Events/EventManager.swift +++ b/Ice/Events/HIDEventManager.swift @@ -1,15 +1,18 @@ // -// EventManager.swift +// HIDEventManager.swift // Ice // import Cocoa import Combine -/// Manager for the various event monitors maintained by the app. +/// Manager that monitors input events and implements the features +/// that are triggered by them, such as showing hidden items on +/// click/hover/scroll. @MainActor -final class EventManager: ObservableObject { - /// A Boolean value that indicates whether the user is dragging a menu bar item. +final class HIDEventManager: ObservableObject { + /// A Boolean value that indicates whether the user is dragging + /// a menu bar item. @Published private(set) var isDraggingMenuBarItem = false /// The shared app state. @@ -18,13 +21,31 @@ final class EventManager: ObservableObject { /// Storage for internal observers. private var cancellables = Set() + /// History of the manager's enabled states. + private var enabledStateStack = [Bool]() + + /// A Boolean value that indicates whether the manager is enabled. + private var isEnabled = false { + didSet { + if isEnabled { + for monitor in allMonitors { + monitor.start() + } + } else { + for monitor in allMonitors { + monitor.stop() + } + } + } + } + // MARK: Monitors /// Monitor for mouse down events. private(set) lazy var mouseDownMonitor = EventMonitor.universal( for: [.leftMouseDown, .rightMouseDown] ) { [weak self] event in - guard let self, let appState, let screen = bestScreen(appState: appState) else { + guard let self, isEnabled, let appState, let screen = bestScreen(appState: appState) else { return event } switch event.type { @@ -32,7 +53,7 @@ final class EventManager: ObservableObject { handleShowOnClick(appState: appState, screen: screen) handleSmartRehide(with: event, appState: appState, screen: screen) case .rightMouseDown: - handleShowSecondaryContextMenu(appState: appState, screen: screen) + handleSecondaryContextMenu(appState: appState, screen: screen) default: return event } @@ -44,7 +65,10 @@ final class EventManager: ObservableObject { private(set) lazy var mouseUpMonitor = EventMonitor.universal( for: .leftMouseUp ) { [weak self] event in - self?.handleLeftMouseUp() + guard let self, isEnabled else { + return event + } + handleMenuBarItemDragStop() return event } @@ -52,8 +76,8 @@ final class EventManager: ObservableObject { private(set) lazy var mouseDraggedMonitor = EventMonitor.universal( for: .leftMouseDragged ) { [weak self] event in - if let self, let appState, let screen = bestScreen(appState: appState) { - handleLeftMouseDragged(with: event, appState: appState, screen: screen) + if let self, isEnabled, let appState, let screen = bestScreen(appState: appState) { + handleMenuBarItemDragStart(with: event, appState: appState, screen: screen) } return event } @@ -65,7 +89,7 @@ final class EventManager: ObservableObject { placement: .tailAppendEventTap, option: .listenOnly ) { [weak self] _, event in - if let self, let appState, let screen = bestScreen(appState: appState) { + if let self, isEnabled, let appState, let screen = bestScreen(appState: appState) { handleShowOnHover(appState: appState, screen: screen) } return event @@ -75,7 +99,7 @@ final class EventManager: ObservableObject { private(set) lazy var scrollWheelMonitor = EventMonitor.universal( for: .scrollWheel ) { [weak self] event in - if let self, let appState, let screen = bestScreen(appState: appState) { + if let self, isEnabled, let appState, let screen = bestScreen(appState: appState) { handleShowOnScroll(with: event, appState: appState, screen: screen) } return event @@ -116,7 +140,7 @@ final class EventManager: ObservableObject { ) .receive(on: DispatchQueue.main) .sink { [weak self, weak appState] _, isFullscreen, isMenuBarHiddenBySystem in - guard let self, let appState, isFullscreen || isMenuBarHiddenBySystem else { + guard let self, isEnabled, let appState, isFullscreen || isMenuBarHiddenBySystem else { return } if let screen = bestScreen(appState: appState) { @@ -133,22 +157,19 @@ final class EventManager: ObservableObject { /// Starts all monitors. func startAll() { - for monitor in allMonitors { - monitor.start() - } + isEnabled = enabledStateStack.popLast() ?? true } /// Stops all monitors. func stopAll() { - for monitor in allMonitors { - monitor.stop() - } + enabledStateStack.append(isEnabled) + isEnabled = false } } // MARK: - Handler Methods -extension EventManager { +extension HIDEventManager { // MARK: Handle Show On Click @@ -161,11 +182,8 @@ extension EventManager { } Task { - // Short delay helps the toggle action feel more natural. - try await Task.sleep(for: .milliseconds(50)) - if NSEvent.modifierFlags == .control { - handleShowSecondaryContextMenu(appState: appState, screen: screen) + handleSecondaryContextMenu(appState: appState, screen: screen) return } @@ -207,28 +225,25 @@ extension EventManager { } } - // Make sure clicking the Ice Bar doesn't trigger rehide. - guard event.window !== appState.menuBarManager.iceBarPanel else { - return - } - - // Only continue if at least one section is visible. - guard appState.menuBarManager.hasVisibleSection else { - return - } - - // Make sure the mouse is not in the menu bar. - guard !isMouseInsideMenuBar(appState: appState, screen: screen) else { + // Only continue if the click is not inside the Ice Bar, at + // least one section is visible, and the mouse is not inside + // the menu bar. + guard + event.window !== appState.menuBarManager.iceBarPanel, + appState.menuBarManager.hasVisibleSection, + !isMouseInsideMenuBar(appState: appState, screen: screen) + else { return } let initialSpaceID = Bridging.getActiveSpaceID() Task { - // Wait for a bit to give the window under the mouse a chance to focus. + // Give the window under the mouse a chance to focus. try await Task.sleep(for: .milliseconds(250)) - // If clicking caused a space change, don't bother with the window check. + // Don't bother checking the window if the click caused + // a space change. if Bridging.getActiveSpaceID() != initialSpaceID { for section in appState.menuBarManager.sections { section.hide() @@ -236,7 +251,7 @@ extension EventManager { return } - // Get the window that the user has clicked into. + // Get the window that was clicked. guard let mouseLocation = MouseHelpers.locationCoreGraphics, let windowUnderMouse = WindowInfo.createWindows(option: .onScreen) @@ -247,9 +262,9 @@ extension EventManager { return } - // The dock is an exception to the following check. + // Note: The Dock is an exception to the following check. if owningApplication.bundleIdentifier != "com.apple.dock" { - // Only continue if the user has clicked into an active window with + // Only continue if the clicked app is active, and has // a regular activation policy. guard owningApplication.isActive, @@ -259,16 +274,16 @@ extension EventManager { } } - // All checks have passed, so hide the sections. + // All checks have passed, hide the sections. for section in appState.menuBarManager.sections { section.hide() } } } - // MARK: Handle Show Secondary Context Menu + // MARK: Handle Secondary Context Menu - private func handleShowSecondaryContextMenu(appState: AppState, screen: NSScreen) { + private func handleSecondaryContextMenu(appState: AppState, screen: NSScreen) { Task { guard appState.settings.advanced.enableSecondaryContextMenu, @@ -277,63 +292,25 @@ extension EventManager { else { return } - // This delay prevents the menu from immediately closing. + // Delay prevents the menu from immediately closing. try await Task.sleep(for: .milliseconds(100)) appState.menuBarManager.showSecondaryContextMenu(at: mouseLocation) } } - // MARK: Handle Prevent Show On Hover - - private func handlePreventShowOnHover(with event: NSEvent, appState: AppState, screen: NSScreen) { - guard - appState.settings.general.showOnHover, - !appState.settings.general.useIceBar - else { - return - } + // MARK: Handle Menu Bar Item Drag Stop - guard isMouseInsideMenuBar(appState: appState, screen: screen) else { - return + private func handleMenuBarItemDragStop() { + if isDraggingMenuBarItem { + isDraggingMenuBarItem = false } - - if isMouseInsideMenuBarItem(appState: appState, screen: screen) { - switch event.type { - case .leftMouseDown: - if appState.menuBarManager.hasVisibleSection { - break - } - if isMouseInsideIceIcon(appState: appState) { - break - } - return - case .rightMouseDown: - if appState.menuBarManager.hasVisibleSection { - break - } - return - default: - return - } - } else if isMouseInsideApplicationMenu(appState: appState, screen: screen) { - return - } - - // Mouse is inside the menu bar, outside an item or application - // menu, so it must be inside an empty menu bar space. - appState.menuBarManager.showOnHoverAllowed = false } - // MARK: Handle Left Mouse Up + // MARK: Handle Menu Bar Item Drag Start - private func handleLeftMouseUp() { - isDraggingMenuBarItem = false - } - - // MARK: Handle Left Mouse Dragged - - private func handleLeftMouseDragged(with event: NSEvent, appState: AppState, screen: NSScreen) { + private func handleMenuBarItemDragStart(with event: NSEvent, appState: AppState, screen: NSScreen) { guard + !isDraggingMenuBarItem, event.modifierFlags.contains(.command), isMouseInsideMenuBar(appState: appState, screen: screen) else { @@ -400,21 +377,55 @@ extension EventManager { } } - // MARK: Handle Show On Scroll + // MARK: Handle Prevent Show On Hover - private func handleShowOnScroll(with event: NSEvent, appState: AppState, screen: NSScreen) { - // Make sure the "ShowOnScroll" feature is enabled. - guard appState.settings.general.showOnScroll else { + private func handlePreventShowOnHover(with event: NSEvent, appState: AppState, screen: NSScreen) { + guard + appState.settings.general.showOnHover, + !appState.settings.general.useIceBar + else { return } - // Make sure the mouse is inside the menu bar. guard isMouseInsideMenuBar(appState: appState, screen: screen) else { return } - // Only continue if we have a hidden section (we should). - guard let hiddenSection = appState.menuBarManager.section(withName: .hidden) else { + if isMouseInsideMenuBarItem(appState: appState, screen: screen) { + switch event.type { + case .leftMouseDown: + if appState.menuBarManager.hasVisibleSection { + break + } + if isMouseInsideIceIcon(appState: appState) { + break + } + return + case .rightMouseDown: + if appState.menuBarManager.hasVisibleSection { + break + } + return + default: + return + } + } else if isMouseInsideApplicationMenu(appState: appState, screen: screen) { + return + } + + // Mouse is inside the menu bar, outside an item or application + // menu, so it must be inside an empty menu bar space. + appState.menuBarManager.showOnHoverAllowed = false + } + + // MARK: Handle Show On Scroll + + private func handleShowOnScroll(with event: NSEvent, appState: AppState, screen: NSScreen) { + guard + appState.settings.general.showOnScroll, + isMouseInsideMenuBar(appState: appState, screen: screen), + let hiddenSection = appState.menuBarManager.section(withName: .hidden) + else { return } @@ -430,7 +441,7 @@ extension EventManager { // MARK: - Helper Methods -extension EventManager { +extension HIDEventManager { /// Returns the best screen to use for event manager calculations. func bestScreen(appState: AppState) -> NSScreen? { guard @@ -525,7 +536,7 @@ extension EventManager { let panel = appState.menuBarManager.iceBarPanel // Pad the frame to be more forgiving if the user accidentally // moves their mouse outside of the Ice Bar. - let paddedFrame = panel.frame.insetBy(dx: -10, dy: -10) + let paddedFrame = panel.frame.insetBy(dx: -15, dy: -15) return paddedFrame.contains(mouseLocation) } diff --git a/Ice/Main/AppState.swift b/Ice/Main/AppState.swift index 04f764f07..fa0601e80 100644 --- a/Ice/Main/AppState.swift +++ b/Ice/Main/AppState.swift @@ -40,8 +40,8 @@ final class AppState: ObservableObject { /// Global cache for menu bar item images. let imageCache = MenuBarItemImageCache() - /// Manager for events received by the app. - let eventManager = EventManager() + /// Manager for input events received by the app. + let hidEventManager = HIDEventManager() /// Manager for app updates. let updatesManager = UpdatesManager() @@ -67,7 +67,7 @@ final class AppState: ObservableObject { } appearanceManager.performSetup(with: self) - eventManager.performSetup(with: self) + hidEventManager.performSetup(with: self) await itemManager.performSetup(with: self) imageCache.performSetup(with: self) updatesManager.performSetup(with: self) @@ -148,7 +148,7 @@ final class AppState: ObservableObject { } .store(in: &c) - eventManager.$isDraggingMenuBarItem + hidEventManager.$isDraggingMenuBarItem .removeDuplicates() .sink { [weak self] isDragging in self?.isDraggingMenuBarItem = isDragging diff --git a/Ice/MenuBar/IceBar/IceBar.swift b/Ice/MenuBar/IceBar/IceBar.swift index e7593edd2..bf92e00f7 100644 --- a/Ice/MenuBar/IceBar/IceBar.swift +++ b/Ice/MenuBar/IceBar/IceBar.swift @@ -118,7 +118,7 @@ final class IceBarPanel: NSPanel { switch iceBarLocation { case .dynamic: - if appState.eventManager.isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen) { + if appState.hidEventManager.isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen) { return getOrigin(for: .mousePointer) } return getOrigin(for: .iceIcon) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift index b1883aedb..de9f636e3 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -1082,6 +1082,12 @@ extension MenuBarItemManager { } try await waitForUserToPauseInput() + + appState.hidEventManager.stopAll() + defer { + appState.hidEventManager.startAll() + } + try await waitForMoveOperationBuffer() logger.log( @@ -1096,11 +1102,9 @@ extension MenuBarItemManager { return } - appState.eventManager.stopAll() MouseHelpers.hideCursor() defer { MouseHelpers.showCursor() - appState.eventManager.startAll() } let maxAttempts = 8 @@ -1238,9 +1242,9 @@ extension MenuBarItemManager { """ ) - appState.eventManager.stopAll() + appState.hidEventManager.stopAll() defer { - appState.eventManager.startAll() + appState.hidEventManager.startAll() } let maxAttempts = 4 @@ -1357,6 +1361,10 @@ extension MenuBarItemManager { /// - item: The item to temporarily show. /// - mouseButton: The mouse button to click the item with. func temporarilyShow(item: MenuBarItem, clickingWith mouseButton: CGMouseButton) async { + guard let appState else { + logger.error("Missing AppState, so not showing \(item.logString, privacy: .public)") + return + } guard let screen = NSScreen.screenWithActiveMenuBar else { logger.error("No active menu bar screen, so not showing \(item.logString, privacy: .public)") return @@ -1403,6 +1411,11 @@ extension MenuBarItemManager { return } + appState.hidEventManager.stopAll() + defer { + appState.hidEventManager.startAll() + } + logger.debug("Temporarily showing \(item.logString, privacy: .public)") do { @@ -1443,6 +1456,10 @@ extension MenuBarItemManager { /// If an item is currently showing its interface, this method waits /// for the interface to close before hiding the items. func rehideTemporarilyShownItems() async { + guard let appState else { + logger.error("Missing AppState, so not rehiding") + return + } guard !temporarilyShownItemContexts.isEmpty else { return } @@ -1463,6 +1480,13 @@ extension MenuBarItemManager { let items = await MenuBarItem.getMenuBarItems(option: .activeSpace) var failedContexts = [TemporarilyShownItemContext]() + appState.hidEventManager.stopAll() + defer { + appState.hidEventManager.startAll() + } + + await eventSleep(for: .milliseconds(250)) + logger.debug("Rehiding temporarily shown items") MouseHelpers.hideCursor() diff --git a/Ice/MenuBar/MenuBarManager.swift b/Ice/MenuBar/MenuBarManager.swift index b90678ea2..3cc746eec 100644 --- a/Ice/MenuBar/MenuBarManager.swift +++ b/Ice/MenuBar/MenuBarManager.swift @@ -119,8 +119,8 @@ final class MenuBarManager: ObservableObject { let appState, case .focusedApp = appState.settings.general.rehideStrategy, let hiddenSection = section(withName: .hidden), - let screen = appState.eventManager.bestScreen(appState: appState), - !appState.eventManager.isMouseInsideMenuBar(appState: appState, screen: screen) + let screen = appState.hidEventManager.bestScreen(appState: appState), + !appState.hidEventManager.isMouseInsideMenuBar(appState: appState, screen: screen) { Task { try await Task.sleep(for: .seconds(0.1)) From 6d74d25c33a9ab04307c1f222fbe68ad71847234 Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Sat, 20 Sep 2025 01:48:17 -0600 Subject: [PATCH 77/80] Bump version and build numbers --- Ice.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Ice.xcodeproj/project.pbxproj b/Ice.xcodeproj/project.pbxproj index 7afb0513a..ae05a79d2 100644 --- a/Ice.xcodeproj/project.pbxproj +++ b/Ice.xcodeproj/project.pbxproj @@ -417,7 +417,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1120; + CURRENT_PROJECT_VERSION = 1121; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_APP_SANDBOX = NO; @@ -433,7 +433,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = "0.11.13-dev.2"; + MARKETING_VERSION = "0.11.13-dev.2a"; PRODUCT_BUNDLE_IDENTIFIER = com.jordanbaird.Ice; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -450,7 +450,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1120; + CURRENT_PROJECT_VERSION = 1121; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; ENABLE_APP_SANDBOX = NO; @@ -466,7 +466,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = "0.11.13-dev.2"; + MARKETING_VERSION = "0.11.13-dev.2a"; PRODUCT_BUNDLE_IDENTIFIER = com.jordanbaird.Ice; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; From 07a4630f7f18b78ae2adf89627606b014e5e68ad Mon Sep 17 00:00:00 2001 From: Florian Fackler Date: Mon, 4 May 2026 11:41:54 +0200 Subject: [PATCH 78/80] fix: disable code signing for local development --- AGENTS.md | 58 +++++++++++++++++++++++++++++++++++ Ice.xcodeproj/project.pbxproj | 2 ++ 2 files changed, 60 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..b762229d4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# AGENTS.md + +## Project Overview + +Ice is a macOS menu bar management tool built with Swift/SwiftUI. Requires macOS 14+. Single Xcode project (no SPM, no multi-target). Not sandboxed. + +## Developer Commands + +- **Build**: Open `Ice.xcodeproj` in Xcode and run (⌘R) +- **Lint**: `swiftlint --strict` (CI runs this on ubuntu via `norio-nomura/action-swiftlint`) +- **No test suite**: No test targets exist in the project +- **No pre-commit hooks** + +## Architecture + +- **Entry point**: `Ice/Main/IceApp.swift` (`@main` SwiftUI App) +- **Central state**: `Ice/Main/AppState.swift` — single source of truth, passed to all views/managers +- **Lifecycle**: `Ice/Main/AppDelegate.swift` (NSApplicationDelegate, assigned via `@NSApplicationAdaptor`) +- **Startup flow**: `IceApp.init()` → `MigrationManager.migrateAll()` → `AppDelegate.performSetup()` (after permission check) + +### Key Directories + +| Directory | Purpose | +|---|---| +| `MenuBar/` | Core hiding/showing logic, layout, search, appearance, spacing | +| `Settings/` | Settings panes and settings manager hierarchy | +| `Bridging/` | Private CGS* API wrappers (window server connection, spaces, process responsivity) | +| `Bridging/Shims/` | `Private.swift` (private C function declarations), `Deprecated.swift` | +| `Hotkeys/` | Keyboard shortcut management | +| `Permissions/` | Permission checking (accessibility, screen recording, etc.) | +| `Swizzling/` | Runtime method swizzling (NSSplitViewItem) | +| `Updates/` | Sparkle framework auto-updates | +| `Utilities/` | Shared types: Logger, Defaults, Extensions, MigrationManager | + +## Important Context + +- **Private APIs**: `Bridging/` uses private CGS* functions (CGSSetConnectionProperty, CGSGetWindowList, etc.). These are declared in `Bridging/Shims/Private.swift`. Changes to window/menu bar manipulation likely touch this layer. +- **Not sandboxed**: `Ice.entitlements` has `com.apple.security.app-sandbox = false`. This is required for private API access. +- **Logger**: Custom `Logger` wrapper around `os.Logger` with subsystem `com.jordanbaird.Ice`. Use `Logger(category:)` with a static per-file extension (see any file for pattern). +- **Defaults**: UserDefaults-based persistence via `Defaults.swift`. New settings need a `DefaultsKey` entry. +- **Migration**: `MigrationManager` handles version-to-version data migrations. New breaking changes need a migration step. + +## SwiftLint Config + +- Many strictness rules disabled (complexity, file/function length, naming, tuple size) +- Opt-in rules enabled: multiline argument/parameter formatting, modifier order (acl first), trailing commas, indentation width, closure spacing +- Custom rule: `@objc dynamic` ordering, no tabs (4-space indent required) +- File header required: `// Ice //` comment block + +## CI + +Single workflow (`.github/workflows/lint.yml`): SwiftLint `--strict` on push/PR to `main` for `*.swift` changes. + +## Notes + +- Active development — many roadmap features not yet implemented (see README) +- Homebrew cask: `brew install --cask jordanbaird-ice` +- Uses Sparkle for updates (feed: `jordanbaird.github.io/ice-releases/appcast.xml`) diff --git a/Ice.xcodeproj/project.pbxproj b/Ice.xcodeproj/project.pbxproj index ae05a79d2..b0969157b 100644 --- a/Ice.xcodeproj/project.pbxproj +++ b/Ice.xcodeproj/project.pbxproj @@ -319,6 +319,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; + CODE_SIGNING_ALLOWED = NO; DEVELOPMENT_TEAM = K2ATHQPJDP; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -385,6 +386,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + CODE_SIGNING_ALLOWED = NO; DEVELOPMENT_TEAM = K2ATHQPJDP; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; From 0780b4773fa93f96044fe3e4f9470dc286f75152 Mon Sep 17 00:00:00 2001 From: Florian Fackler Date: Mon, 4 May 2026 12:12:50 +0200 Subject: [PATCH 79/80] fix: macOS 26.4 compatibility and permission handling --- Ice.xcodeproj/project.pbxproj | 4 ++-- Ice/Ice.entitlements | 10 +++++++++ .../MenuBarItemServiceConnection.swift | 22 ++++++++++++++++--- Ice/Resources/Info.plist | 4 ++++ MenuBarItemService/Listener.swift | 19 +++++++++++----- Shared/Bridging/Bridging.swift | 14 +++++++----- 6 files changed, 58 insertions(+), 15 deletions(-) create mode 100644 Ice/Ice.entitlements diff --git a/Ice.xcodeproj/project.pbxproj b/Ice.xcodeproj/project.pbxproj index b0969157b..8653ac6e2 100644 --- a/Ice.xcodeproj/project.pbxproj +++ b/Ice.xcodeproj/project.pbxproj @@ -415,7 +415,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = ""; + CODE_SIGN_ENTITLEMENTS = Ice/Ice.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; @@ -448,7 +448,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = ""; + CODE_SIGN_ENTITLEMENTS = Ice/Ice.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; diff --git a/Ice/Ice.entitlements b/Ice/Ice.entitlements new file mode 100644 index 000000000..311b32bd2 --- /dev/null +++ b/Ice/Ice.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + + diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift index 97bd98c64..155073f0b 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift @@ -104,9 +104,25 @@ extension MenuBarItemService { logger.warning("Session was cancelled with error \(error.localizedDescription)") self.session = nil } - session.setPeerRequirement(.isFromSameTeam()) - session.setTargetQueue(queue) - try session.activate() + // Try with same-team requirement first, fall back to no requirement + do { + session.setPeerRequirement(.isFromSameTeam()) + session.setTargetQueue(queue) + try session.activate() + } catch { + logger.warning("Failed to activate session with same-team requirement (\(error)), trying without requirement") + let fallbackSession = try XPCSession(xpcService: name, options: .inactive) { [weak self] error in + guard let self else { + return + } + logger.warning("Session was cancelled with error \(error.localizedDescription)") + self.session = nil + } + fallbackSession.setTargetQueue(queue) + try fallbackSession.activate() + self.session = fallbackSession + return fallbackSession + } self.session = session return session } diff --git a/Ice/Resources/Info.plist b/Ice/Resources/Info.plist index b1b2d9fa2..49ccecf2f 100644 --- a/Ice/Resources/Info.plist +++ b/Ice/Resources/Info.plist @@ -6,5 +6,9 @@ https://jordanbaird.github.io/ice-releases/appcast.xml SUPublicEDKey 3nfIGMOD8DALPE8vIdFo2tUOIVc2MVbzhc+2J9JLn+Q= + NSAccessibilityUsageDescription + Ice needs Accessibility permissions to detect and arrange menu bar items. + NSScreenCaptureUsageDescription + Ice needs Screen Recording permissions to capture menu bar item images and modify the menu bar appearance. diff --git a/MenuBarItemService/Listener.swift b/MenuBarItemService/Listener.swift index 15f100e4b..064da0416 100644 --- a/MenuBarItemService/Listener.swift +++ b/MenuBarItemService/Listener.swift @@ -72,14 +72,23 @@ final class Listener { Logger.default.debug("Activating listener") - do { - if #available(macOS 26.0, *) { + if #available(macOS 26.0, *) { + do { try uncheckedActivateWithSameTeamRequirement() - } else { + } catch { + Logger.default.warning("Failed to activate with same-team requirement (\(error)), falling back to no requirement") + do { + try uncheckedActivate() + } catch { + Logger.default.error("Failed to activate listener with error \(error)") + } + } + } else { + do { try uncheckedActivate() + } catch { + Logger.default.error("Failed to activate listener with error \(error)") } - } catch { - Logger.default.error("Failed to activate listener with error \(error)") } } diff --git a/Shared/Bridging/Bridging.swift b/Shared/Bridging/Bridging.swift index 561b9322a..d1aab8bae 100644 --- a/Shared/Bridging/Bridging.swift +++ b/Shared/Bridging/Bridging.swift @@ -114,16 +114,20 @@ extension Bridging { /// Returns the identifier of the display with the active menu bar. static func getActiveMenuBarDisplayID() -> CGDirectDisplayID? { guard let string = CGSCopyActiveMenuBarDisplayIdentifier(getMainConnection()) else { - logger.error("CGSCopyActiveMenuBarDisplayIdentifier returned nil") - return nil + logger.warning("CGSCopyActiveMenuBarDisplayIdentifier returned nil, falling back to CGMainDisplayID") + return CGMainDisplayID() } guard let uuid = CFUUIDCreateFromString(nil, string.takeRetainedValue()) else { - logger.error("CFUUIDCreateFromString returned nil") - return nil + logger.warning("CFUUIDCreateFromString returned nil, falling back to CGMainDisplayID") + return CGMainDisplayID() } - return getActiveDisplayList().first { displayID in + guard let displayID = getActiveDisplayList().first(where: { displayID in getDisplayUUID(for: displayID) == uuid + }) else { + logger.warning("No matching display found for UUID, falling back to CGMainDisplayID") + return CGMainDisplayID() } + return displayID } } From 200a9b6468b7cb9cd9b1e2433d9d7ffaa28bde9e Mon Sep 17 00:00:00 2001 From: Florian Fackler Date: Mon, 4 May 2026 12:41:28 +0200 Subject: [PATCH 80/80] fix: prevent crash in ControlItem setup on macOS 26 --- Ice/MenuBar/ControlItem/ControlItem.swift | 12 ++++----- .../MenuBarItemServiceConnection.swift | 26 +++---------------- 2 files changed, 10 insertions(+), 28 deletions(-) diff --git a/Ice/MenuBar/ControlItem/ControlItem.swift b/Ice/MenuBar/ControlItem/ControlItem.swift index a92502701..9cc48f2a8 100644 --- a/Ice/MenuBar/ControlItem/ControlItem.swift +++ b/Ice/MenuBar/ControlItem/ControlItem.swift @@ -78,15 +78,15 @@ final class ControlItem { // status item to be present if its section is enabled. The new solution is to remove // a constraint from the item's content view prevents it from having a length of zero. // Then, we set the length. FIXME: Find a replacement for this. + var foundConstraint: NSLayoutConstraint? = nil if - let constraints = button.window?.contentView?.constraintsAffectingLayout(for: .horizontal), - let constraint = constraints.first(where: Predicates.controlItemConstraint(button: button)) + let window = button.window, + let contentView = window.contentView, + let constraints = try? contentView.constraintsAffectingLayout(for: .horizontal) { - assert(constraints.filter(Predicates.controlItemConstraint(button: button)).count == 1) - self.constraint = constraint - } else { - self.constraint = nil + foundConstraint = constraints.first(where: Predicates.controlItemConstraint(button: button)) } + self.constraint = foundConstraint button.target = controlItem button.action = #selector(controlItem.performAction) diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift index 155073f0b..a0104497a 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift @@ -97,32 +97,14 @@ extension MenuBarItemService { if let session { return session } + // Don't set peer requirement - works with ad-hoc signing let session = try XPCSession(xpcService: name, options: .inactive) { [weak self] error in - guard let self else { - return - } + guard let self else { return } logger.warning("Session was cancelled with error \(error.localizedDescription)") self.session = nil } - // Try with same-team requirement first, fall back to no requirement - do { - session.setPeerRequirement(.isFromSameTeam()) - session.setTargetQueue(queue) - try session.activate() - } catch { - logger.warning("Failed to activate session with same-team requirement (\(error)), trying without requirement") - let fallbackSession = try XPCSession(xpcService: name, options: .inactive) { [weak self] error in - guard let self else { - return - } - logger.warning("Session was cancelled with error \(error.localizedDescription)") - self.session = nil - } - fallbackSession.setTargetQueue(queue) - try fallbackSession.activate() - self.session = fallbackSession - return fallbackSession - } + session.setTargetQueue(queue) + try session.activate() self.session = session return session }