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 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 7f61b6a81..8653ac6e2 100644 --- a/Ice.xcodeproj/project.pbxproj +++ b/Ice.xcodeproj/project.pbxproj @@ -12,24 +12,61 @@ 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, ); }; }; + 71A065092E680C620087DB38 /* Semaphore in Frameworks */ = {isa = PBXBuildFile; productRef = 71A065082E680C620087DB38 /* Semaphore */; }; /* 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 = ( - Info.plist, Resources/Acknowledgements.rtf, + Resources/Info.plist, ); target = 716683292A767E6A006ABF84 /* Ice */; }; /* 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 */ @@ -43,6 +80,15 @@ 175061912B1543DD003144CD /* LaunchAtLogin in Frameworks */, 1787C4272B16890B002F50DF /* AXSwift in Frameworks */, 17F71BB52B880B4500905CBA /* CompactSlider in Frameworks */, + 71A065092E680C620087DB38 /* Semaphore in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7188A6802E27F9ED008F131D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7168EE532E281CBC00FF9830 /* AXSwift in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -52,7 +98,9 @@ 716683212A767E6A006ABF84 = { isa = PBXGroup; children = ( + 7188A69E2E280BB4008F131D /* Shared */, 71BDFBE12C978E2A00EF145F /* Ice */, + 7188A6842E27F9ED008F131D /* MenuBarItemService */, 7166832B2A767E6A006ABF84 /* Products */, ); sourceTree = ""; @@ -61,6 +109,7 @@ isa = PBXGroup; children = ( 7166832A2A767E6A006ABF84 /* Ice.app */, + 7188A6832E27F9ED008F131D /* MenuBarItemService.xpc */, ); name = Products; sourceTree = ""; @@ -76,12 +125,15 @@ 716683272A767E6A006ABF84 /* Frameworks */, 716683282A767E6A006ABF84 /* Resources */, 1720D48F2BB9B60500A7AC63 /* SwiftLint */, + 7188A68D2E27F9ED008F131D /* Embed XPC Services */, ); buildRules = ( ); dependencies = ( + 7188A68B2E27F9ED008F131D /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( + 7188A69E2E280BB4008F131D /* Shared */, 71BDFBE12C978E2A00EF145F /* Ice */, ); name = Ice; @@ -91,11 +143,36 @@ 170423D82B56DE78004A2549 /* Sparkle */, 17F71BB42B880B4500905CBA /* CompactSlider */, 7127A9FE2C4886D100D99DEF /* IfritStatic */, + 71A065082E680C620087DB38 /* Semaphore */, ); productName = Ice; 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 +180,15 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1430; - LastUpgradeCheck = 1640; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; TargetAttributes = { 716683292A767E6A006ABF84 = { CreatedOnToolsVersion = 14.3.1; }; + 7188A6822E27F9ED008F131D = { + CreatedOnToolsVersion = 26.0; + }; }; }; buildConfigurationList = 716683252A767E6A006ABF84 /* Build configuration list for PBXProject "Ice" */; @@ -126,12 +206,14 @@ 170423D72B56DE78004A2549 /* XCRemoteSwiftPackageReference "Sparkle" */, 17F71BB32B880B4500905CBA /* XCRemoteSwiftPackageReference "CompactSlider" */, 7127A9FB2C4881BC00D99DEF /* XCRemoteSwiftPackageReference "Ifrit" */, + 71A065072E680C620087DB38 /* XCRemoteSwiftPackageReference "Semaphore" */, ); productRefGroup = 7166832B2A767E6A006ABF84 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 716683292A767E6A006ABF84 /* Ice */, + 7188A6822E27F9ED008F131D /* MenuBarItemService */, ); }; /* End PBXProject section */ @@ -144,6 +226,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7188A6812E27F9ED008F131D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -176,8 +265,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; @@ -215,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; @@ -233,11 +338,13 @@ 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; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -279,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; @@ -291,10 +399,12 @@ 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; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; }; @@ -309,22 +419,23 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1117; + CURRENT_PROJECT_VERSION = 1121; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_APP_SANDBOX = NO; 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_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", ); - MARKETING_VERSION = 0.11.12; + MARKETING_VERSION = "0.11.13-dev.2a"; PRODUCT_BUNDLE_IDENTIFIER = com.jordanbaird.Ice; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -341,22 +452,23 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1117; + CURRENT_PROJECT_VERSION = 1121; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_APP_SANDBOX = NO; 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_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", ); - MARKETING_VERSION = 0.11.12; + MARKETING_VERSION = "0.11.13-dev.2a"; PRODUCT_BUNDLE_IDENTIFIER = com.jordanbaird.Ice; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -364,6 +476,57 @@ }; 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; + 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; + 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 */ @@ -385,6 +548,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 */ @@ -428,6 +600,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 */ @@ -456,6 +636,16 @@ package = 7127A9FB2C4881BC00D99DEF /* XCRemoteSwiftPackageReference "Ifrit" */; productName = IfritStatic; }; + 7168EE522E281CBC00FF9830 /* AXSwift */ = { + isa = XCSwiftPackageProductDependency; + 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/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 + + + diff --git a/Ice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Ice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e45c27317..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" : "a7567d11f06745371832127a8ce2132148ef6a89fb55ecc72d6c313b688387fa", + "originHash" : "977d7500481760b6dc046c9b6e7def6420058990c9c91809fa741c67a8f83c48", "pins" : [ { "identity" : "axswift", @@ -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" } }, { @@ -37,13 +37,22 @@ "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", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "0ef1ee0220239b3776f433314515fd849025673f", - "version" : "2.6.4" + "revision" : "9a1d2a19d3595fcf8d9c447173f9a1687b3dcadb", + "version" : "2.8.0" } } ], 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Ice/Bridging/Bridging.swift b/Ice/Bridging/Bridging.swift deleted file mode 100644 index d35d7a796..000000000 --- a/Ice/Bridging/Bridging.swift +++ /dev/null @@ -1,277 +0,0 @@ -// -// Bridging.swift -// Ice -// - -import Cocoa - -/// A namespace for bridged functionality. -enum Bridging { } - -// MARK: - CGSConnection - -extension Bridging { - /// Sets a value for the given key in the current 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. - static func setConnectionProperty(_ value: Any?, forKey key: String) { - let result = CGSSetConnectionProperty( - CGSMainConnectionID(), - CGSMainConnectionID(), - key as CFString, - value as CFTypeRef - ) - if result != .success { - Logger.bridging.error("CGSSetConnectionProperty failed with error \(result.logString)") - } - } - - /// Returns the value for the given key in the current 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. - static func getConnectionProperty(forKey key: String) -> Any? { - var value: Unmanaged? - let result = CGSCopyConnectionProperty( - CGSMainConnectionID(), - CGSMainConnectionID(), - key as CFString, - &value - ) - if result != .success { - Logger.bridging.error("CGSCopyConnectionProperty failed with error \(result.logString)") - } - return value?.takeRetainedValue() - } -} - -// MARK: - CGSWindow - -extension Bridging { - /// Returns the frame 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) - guard result == .success else { - Logger.bridging.error("CGSGetScreenRectForWindow failed with error \(result.logString)") - return nil - } - return rect - } -} - -// MARK: Private Window List Helpers -extension Bridging { - private static func getWindowCount() -> Int { - var count: Int32 = 0 - let result = CGSGetWindowCount(CGSMainConnectionID(), 0, &count) - if result != .success { - Logger.bridging.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) - if result != .success { - Logger.bridging.error("CGSGetOnScreenWindowCount failed with error \(result.logString)") - } - return Int(count) - } - - private static func getWindowList() -> [CGWindowID] { - let windowCount = getWindowCount() - var list = [CGWindowID](repeating: 0, count: windowCount) - var realCount: Int32 = 0 - let result = CGSGetWindowList( - CGSMainConnectionID(), - 0, - Int32(windowCount), - &list, - &realCount - ) - guard result == .success else { - Logger.bridging.error("CGSGetWindowList failed with error \(result.logString)") - return [] - } - return [CGWindowID](list[.. [CGWindowID] { - let windowCount = getOnScreenWindowCount() - var list = [CGWindowID](repeating: 0, count: windowCount) - var realCount: Int32 = 0 - let result = CGSGetOnScreenWindowList( - CGSMainConnectionID(), - 0, - Int32(windowCount), - &list, - &realCount - ) - guard result == .success else { - Logger.bridging.error("CGSGetOnScreenWindowList failed with error \(result.logString)") - return [] - } - return [CGWindowID](list[.. [CGWindowID] { - let windowCount = getWindowCount() - var list = [CGWindowID](repeating: 0, count: windowCount) - var realCount: Int32 = 0 - let result = CGSGetProcessMenuBarWindowList( - CGSMainConnectionID(), - 0, - Int32(windowCount), - &list, - &realCount - ) - guard result == .success else { - Logger.bridging.error("CGSGetProcessMenuBarWindowList failed with error \(result.logString)") - return [] - } - return [CGWindowID](list[.. [CGWindowID] { - let onScreenList = Set(getOnScreenWindowList()) - return getMenuBarWindowList().filter(onScreenList.contains) - } -} - -// MARK: Public Window List API -extension Bridging { - /// Options that determine the window identifiers to return in a window list. - struct WindowListOption: OptionSet { - let rawValue: Int - - /// 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 << 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. - static func getWindowList(option: WindowListOption = []) -> [CGWindowID] { - let list = if option.contains(.menuBarItems) { - if option.contains(.onScreen) { - getOnScreenMenuBarWindowList() - } else { - getMenuBarWindowList() - } - } else if option.contains(.onScreen) { - getOnScreenWindowList() - } else { - getWindowList() - } - return if option.contains(.activeSpace) { - list.filter(isWindowOnActiveSpace) - } else { - list - } - } -} - -// MARK: - CGSSpace - -extension Bridging { - /// Options that determine the space identifiers to return in a space list. - enum SpaceListOption { - case allSpaces, visibleSpaces - } - - /// The identifier of the active space. - static var activeSpaceID: CGSSpaceID { - CGSGetActiveSpace(CGSMainConnectionID()) - } - - /// Returns an array of identifiers for the spaces containing the window with - /// the given identifier. - /// - /// - Parameter windowID: An identifier for a window. - 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") - return [] - } - guard let spaceIDs = spaces.takeRetainedValue() as? [CGSSpaceID] else { - Logger.bridging.error("CGSCopySpacesForWindows returned array of unexpected type") - return [] - } - return spaceIDs - } - - /// Returns a Boolean value that indicates whether the window with the - /// given identifier is on the active space. - /// - /// - Parameter windowID: An identifier for a window. - static func isWindowOnActiveSpace(_ windowID: CGWindowID) -> Bool { - getSpaceList(for: windowID, option: .allSpaces).contains(activeSpaceID) - } - - /// Returns a Boolean value that indicates whether the space with the given - /// identifier is a fullscreen space. - /// - /// - Parameter spaceID: An identifier for a space. - static func isSpaceFullscreen(_ spaceID: CGSSpaceID) -> Bool { - let type = CGSSpaceGetType(CGSMainConnectionID(), spaceID) - return type == .fullscreen - } -} - -// MARK: - Process Responsivity - -extension Bridging { - /// Constants that indicate the responsivity of an app. - enum Responsivity { - case responsive, unresponsive, unknown - } - - /// Returns the responsivity of the given process. - /// - /// - Parameter pid: The Unix process identifier of the process to check. - 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)") - return .unknown - } - if CGSEventIsAppUnresponsive(CGSMainConnectionID(), &psn) { - return .unresponsive - } - return .responsive - } -} - -// MARK: - Logger -private extension Logger { - static let bridging = Logger(category: "Bridging") -} 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 deleted file mode 100644 index b1c154c8c..000000000 --- a/Ice/Events/EventManager.swift +++ /dev/null @@ -1,556 +0,0 @@ -// -// EventManager.swift -// Ice -// - -import Cocoa -import Combine - -/// Manager for the various event monitors maintained by the app. -@MainActor -final class EventManager { - /// The shared app state. - private weak var appState: AppState? - - /// Storage for internal observers. - private var cancellables = Set() - - // MARK: Monitors - - /// Monitor for mouse down events. - private(set) lazy var mouseDownMonitor = UniversalEventMonitor( - mask: [.leftMouseDown, .rightMouseDown] - ) { [weak self] event in - guard let self else { - return event - } - switch event.type { - case .leftMouseDown: - handleShowOnClick() - handleSmartRehide(with: event) - case .rightMouseDown: - handleShowRightClickMenu() - default: - break - } - handlePreventShowOnHover(with: event) - return event - } - - /// Monitor for mouse up events. - private(set) lazy var mouseUpMonitor = UniversalEventMonitor( - mask: .leftMouseUp - ) { [weak self] event in - self?.handleLeftMouseUp() - return event - } - - /// Monitor for mouse dragged events. - private(set) lazy var mouseDraggedMonitor = UniversalEventMonitor( - mask: .leftMouseDragged - ) { [weak self] event in - self?.handleLeftMouseDragged(with: event) - return event - } - - /// Monitor for mouse moved events. - private(set) lazy var mouseMovedMonitor = UniversalEventMonitor( - mask: .mouseMoved - ) { [weak self] event in - self?.handleShowOnHover() - return event - } - - /// Monitor for scroll wheel events. - private(set) lazy var scrollWheelMonitor = UniversalEventMonitor( - mask: .scrollWheel - ) { [weak self] event in - self?.handleShowOnScroll(with: event) - return event - } - - // MARK: All Monitors - - /// All monitors maintained by the app. - private lazy var allMonitors = [ - mouseDownMonitor, - mouseUpMonitor, - mouseDraggedMonitor, - mouseMovedMonitor, - scrollWheelMonitor, - ] - - // MARK: Initializers - - /// Creates an event manager with the given app state. - init(appState: AppState) { - self.appState = appState - } - - /// Sets up the manager. - func performSetup() { - startAll() - configureCancellables() - } - - /// Configures the internal observers for the manager. - 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() - } - .store(in: &c) - } - } - - cancellables = c - } - - // MARK: Start/Stop - - /// Starts all monitors. - func startAll() { - for monitor in allMonitors { - monitor.start() - } - } - - /// Stops all monitors. - func stopAll() { - for monitor in allMonitors { - monitor.stop() - } - } -} - -// MARK: - Handlers - -extension EventManager { - - // MARK: Handle Show On Click - - private func handleShowOnClick() { - guard - let appState, - appState.settingsManager.generalSettingsManager.showOnClick, - isMouseInsideEmptyMenuBarSpace - else { - return - } - - Task { - // Short delay helps the toggle action feel more natural. - try? await Task.sleep(for: .milliseconds(50)) - - if NSEvent.modifierFlags == .control { - handleShowRightClickMenu() - } else if - NSEvent.modifierFlags == .option, - appState.settingsManager.advancedSettingsManager.canToggleAlwaysHiddenSection - { - if let alwaysHiddenSection = appState.menuBarManager.section(withName: .alwaysHidden) { - alwaysHiddenSection.toggle() - } - } else { - if let hiddenSection = appState.menuBarManager.section(withName: .hidden) { - hiddenSection.toggle() - } - } - } - } - - // MARK: Handle Smart Rehide - - private func handleSmartRehide(with event: NSEvent) { - guard - let appState, - appState.settingsManager.generalSettingsManager.autoRehide, - case .smart = appState.settingsManager.generalSettingsManager.rehideStrategy - else { - return - } - - if let visibleSection = appState.menuBarManager.section(withName: .visible) { - guard event.window !== visibleSection.controlItem.window else { - return - } - } - - // Make sure clicking the Ice Bar doesn't trigger rehide. - guard event.window !== appState.menuBarManager.iceBarPanel else { - return - } - - // Only continue if a section is currently visible. - guard appState.menuBarManager.sections.contains(where: { !$0.isHidden }) else { - return - } - - // Make sure the mouse is not in the menu bar. - guard !isMouseInsideMenuBar else { - return - } - - Task { - let initialSpaceID = Bridging.activeSpaceID - - // 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 { - for section in appState.menuBarManager.sections { - section.hide() - } - return - } - - // Get the window that the user has clicked into. - guard - let mouseLocation = MouseCursor.locationCoreGraphics, - let windowUnderMouse = WindowInfo.getOnScreenWindows(excludeDesktopWindows: false) - .filter({ $0.layer < CGWindowLevelForKey(.cursorWindow) }) - .first(where: { $0.frame.contains(mouseLocation) && $0.title?.isEmpty == false }), - let owningApplication = windowUnderMouse.owningApplication - else { - return - } - - // 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 - // a regular activation policy. - guard - owningApplication.isActive, - owningApplication.activationPolicy == .regular - else { - return - } - } - - // If all the above checks have passed, hide all sections. - for section in appState.menuBarManager.sections { - section.hide() - } - } - } - - // MARK: Handle Show Right Click Menu - - private func handleShowRightClickMenu() { - guard - let appState, - appState.settingsManager.advancedSettingsManager.showContextMenuOnRightClick, - isMouseInsideEmptyMenuBarSpace, - let mouseLocation = MouseCursor.locationAppKit - else { - return - } - appState.menuBarManager.showRightClickMenu(at: mouseLocation) - } - - // MARK: Handle Prevent Show On Hover - - private func handlePreventShowOnHover(with event: NSEvent) { - guard - let appState, - appState.settingsManager.generalSettingsManager.showOnHover, - !appState.settingsManager.generalSettingsManager.useIceBar, - isMouseInsideMenuBar - else { - return - } - - if isMouseInsideMenuBarItem { - 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() - } - 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() - } - default: - break - } - } 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() - } - } - - // MARK: Handle Left Mouse Up - - private func handleLeftMouseUp() { - guard let appearanceManager = appState?.appearanceManager else { - return - } - appearanceManager.setIsDraggingMenuBarItem(false) - } - - // MARK: Handle Left Mouse Dragged - - private func handleLeftMouseDragged(with event: NSEvent) { - guard - let appState, - event.modifierFlags.contains(.command), - isMouseInsideMenuBar - else { - return - } - - // Notify each overlay panel that a menu bar item is being dragged. - appState.appearanceManager.setIsDraggingMenuBarItem(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 - } - section.controlItem.isVisible = true - } - } - - // MARK: Handle Show On Hover - - private func handleShowOnHover() { - guard let appState else { - return - } - - // Make sure the "ShowOnHover" feature is enabled and not prevented. - guard - appState.settingsManager.generalSettingsManager.showOnHover, - !appState.isShowOnHoverPrevented - else { - return - } - - // Only continue if we have a hidden section (we should). - guard let hiddenSection = appState.menuBarManager.section(withName: .hidden) else { - return - } - - let delay = appState.settingsManager.advancedSettingsManager.showOnHoverDelay - - Task { - if hiddenSection.isHidden { - guard self.isMouseInsideEmptyMenuBarSpace else { - return - } - 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 { - return - } - try? await Task.sleep(for: .seconds(delay)) - // Make sure the mouse is still outside. - guard - !self.isMouseInsideMenuBar, - !self.isMouseInsideIceBar - else { - return - } - hiddenSection.hide() - } - } - } - - // MARK: Handle Show On Scroll - - private func handleShowOnScroll(with event: NSEvent) { - guard let appState else { - return - } - - // 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 { - return - } - - // Only continue if we have a hidden section (we should). - guard let hiddenSection = appState.menuBarManager.section(withName: .hidden) else { - return - } - - let averageDelta = (event.scrollingDeltaX + event.scrollingDeltaY) / 2 - - if averageDelta > 5 { - hiddenSection.show() - } else if averageDelta < -5 { - hiddenSection.hide() - } - } -} - -// MARK: - Helpers - -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 { - return NSScreen.main - } - } - - /// A Boolean value that indicates whether the mouse pointer is within - /// the bounds of the menu bar. - var isMouseInsideMenuBar: Bool { - guard - let screen = bestScreen, - let appState - 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 - } - - /// A Boolean value that indicates whether the mouse pointer is within - /// the bounds of the current application menu. - var isMouseInsideApplicationMenu: Bool { - guard - let mouseLocation = MouseCursor.locationCoreGraphics, - let screen = bestScreen, - let appState, - var applicationMenuFrame = appState.menuBarManager.getApplicationMenuFrame(for: screen.displayID) - else { - return false - } - applicationMenuFrame.size.width += applicationMenuFrame.origin.x - screen.frame.origin.x - applicationMenuFrame.origin.x = screen.frame.origin.x - return applicationMenuFrame.contains(mouseLocation) - } - - /// 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 { - return false - } - let menuBarItems = MenuBarItem.getMenuBarItems(on: screen.displayID, onScreenOnly: true, activeSpaceOnly: true) - return menuBarItems.contains { $0.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 { - guard - let screen = bestScreen, - let mouseLocation = MouseCursor.locationAppKit, - let frameOfNotch = screen.frameOfNotch - else { - return false - } - 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 - } - - /// 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 { - return false - } - 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) - return paddedFrame.contains(mouseLocation) - } - - /// A Boolean value that indicates whether the mouse pointer is within - /// the bounds of the Ice icon. - var isMouseInsideIceIcon: Bool { - guard - let appState, - let visibleSection = appState.menuBarManager.section(withName: .visible), - let iceIconFrame = visibleSection.controlItem.windowFrame, - let mouseLocation = MouseCursor.locationAppKit - else { - return false - } - return iceIconFrame.contains(mouseLocation) - } -} - -// MARK: - Logger -private extension Logger { - static let eventManager = Logger(category: "EventManager") -} 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 81fe93628..000000000 --- a/Ice/Events/EventMonitors/GlobalEventMonitor.swift +++ /dev/null @@ -1,93 +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 { - var subscriber: S? - let monitor: GlobalEventMonitor - - init(mask: NSEvent.EventTypeMask, subscriber: S) { - self.subscriber = subscriber - self.monitor = GlobalEventMonitor(mask: mask) { event in - _ = subscriber.receive(event) - } - 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 814c0488a..000000000 --- a/Ice/Events/EventMonitors/LocalEventMonitor.swift +++ /dev/null @@ -1,94 +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 { - var subscriber: S? - let monitor: LocalEventMonitor - - init(mask: NSEvent.EventTypeMask, subscriber: S) { - self.subscriber = subscriber - self.monitor = LocalEventMonitor(mask: mask) { event in - _ = subscriber.receive(event) - return event - } - 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 98d0e8067..000000000 --- a/Ice/Events/EventMonitors/UniversalEventMonitor.swift +++ /dev/null @@ -1,86 +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 { - var subscriber: S? - let monitor: UniversalEventMonitor - - init(mask: NSEvent.EventTypeMask, subscriber: S) { - self.subscriber = subscriber - self.monitor = UniversalEventMonitor(mask: mask) { event in - _ = subscriber.receive(event) - return event - } - 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 6f00ee308..937b41fc8 100644 --- a/Ice/Events/EventTap.swift +++ b/Ice/Events/EventTap.swift @@ -4,28 +4,31 @@ // import Cocoa +import OSLog -/// A type that receives system events from various locations within the -/// event stream. -@MainActor +/// An object that receives events from a defined point in +/// the event stream. final class EventTap { - /// Constants that specify the possible tapping locations for events. + /// 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. var logString: String { switch self { case .hidEventTap: "HID event tap" @@ -36,228 +39,210 @@ 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() - } + /// Shared logger for event taps. + private static let logger = Logger(category: "EventTap") - /// Enables the event tap with the given timeout. - func enable(timeout: Duration, onTimeout: @escaping () -> Void) { - tap.enable(timeout: timeout, onTimeout: onTimeout) + /// Shared callback for all event taps. + private static let sharedCallback: CGEventTapCallBack = { _, type, event, refcon in + guard let refcon else { + return Unmanaged.passUnretained(event) } - - /// Disables the event tap. - func disable() { - tap.disable() + 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) + } } } - private let runLoop = CFRunLoopGetCurrent() - private let mode: CFRunLoopMode = .commonModes - private nonisolated let callback: (EventTap, CGEventTapProxy, CGEventType, CGEvent) -> Unmanaged? - private var machPort: CFMachPort? private var source: CFRunLoopSource? + private let runLoop: CFRunLoop + 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 enabled. + /// A Boolean value that indicates whether the 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) } - /// Creates a new event tap. + /// 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 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. - /// - kind: The kind of tap to create. - /// - location: The location to listen for events. - /// - 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. + /// - label: A string label that identifies the tap in logging + /// and debugging contexts. + /// - 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 existing taps + /// at `location`. + /// - option: An option that specifies whether the tap is an + /// active filter or a passive listener. + /// - callback: A closure the tap calls to handle received events. init( label: String = #function, - options: CGEventTapOptions, - location: Location, - place: CGEventTapPlacement, types: [CGEventType], - callback: @MainActor @escaping (_ proxy: Proxy, _ type: CGEventType, _ event: CGEvent) -> CGEvent? + location: Location, + placement: CGEventTapPlacement, + option: CGEventTapOptions, + 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 { - Logger.eventTap.error("Error creating mach port for event tap \"\(self.label)\"") - return - } - guard let source = CFMachPortCreateRunLoopSource(nil, machPort, 0) else { - Logger.eventTap.error("Error creating run loop source for event tap \"\(self.label)\"") + self.callback = callback + self.runLoop = CFRunLoopGetMain() + + guard + let machPort = EventTap.createMachPort( + mask: types.reduce(0) { $0 | (1 << $1.rawValue) }, + location: location, + place: placement, + options: option, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ), + let source = CFMachPortCreateRunLoopSource(nil, machPort, 0) + else { + EventTap.logger.error(#"Error creating event tap "\#(label, privacy: .public)""#) return } + self.machPort = machPort 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 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: A string label that identifies the tap in logging + /// and debugging contexts. + /// - 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 existing taps + /// at `location`. + /// - option: An option that specifies whether the tap is an + /// active filter or a passive listener. + /// - callback: A closure the tap calls to handle received events. + convenience init( + label: String = #function, + type: CGEventType, + location: Location, + placement: CGEventTapPlacement, + option: CGEventTapOptions, + callback: @escaping (_ tap: EventTap, _ event: CGEvent) -> CGEvent? + ) { + self.init( + label: label, + types: [type], + location: location, + placement: placement, + option: option, + 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 source { + CFRunLoopRemoveSource(runLoop, source, .commonModes) + } + if let machPort { + CGEvent.tapEnable(tap: machPort, enable: false) + CFMachPortInvalidate(machPort) + } } - private static func createTapMachPort( + /// Creates an event tap mach port. + private static func createMachPort( + mask: CGEventMask, location: Location, place: CGEventTapPlacement, options: CGEventTapOptions, - eventsOfInterest: CGEventMask, - callback: CGEventTapCallBack, - userInfo: UnsafeMutableRawPointer? + userInfo: UnsafeMutableRawPointer ) -> CFMachPort? { - if case .pid(let pid) = location { - return CGEvent.tapCreateForPid( - pid: pid, + func createMachPort(at tapLocation: CGEventTapLocation) -> CFMachPort? { + CGEvent.tapCreate( + tap: tapLocation, place: place, options: options, - eventsOfInterest: eventsOfInterest, - callback: callback, + eventsOfInterest: mask, + callback: sharedCallback, userInfo: userInfo ) } - let tap: CGEventTapLocation? = switch location { - case .hidEventTap: .cghidEventTap - case .sessionEventTap: .cgSessionEventTap - case .annotatedSessionEventTap: .cgAnnotatedSessionEventTap - case .pid: nil - } - - guard let tap else { - return nil + func createMachPort(for pid: pid_t) -> CFMachPort? { + CGEvent.tapCreateForPid( + pid: pid, + place: place, + options: options, + eventsOfInterest: mask, + callback: sharedCallback, + userInfo: userInfo + ) } - 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 { - Logger.eventTap.error("Missing run loop for event tap \"\(self.label)\"") - return - } - guard let source else { - Logger.eventTap.error("Missing run loop source for event tap \"\(self.label)\"") - return + switch location { + case .hidEventTap: + return createMachPort(at: .cghidEventTap) + case .sessionEventTap: + return createMachPort(at: .cgSessionEventTap) + case .annotatedSessionEventTap: + return createMachPort(at: .cgAnnotatedSessionEventTap) + case .pid(let pid): + return createMachPort(for: pid) } - guard let machPort else { - Logger.eventTap.error("Missing mach port for event tap \"\(self.label)\"") - return - } - body(runLoop, source, machPort) } - /// Enables the event tap. + /// Enables the 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 source, let machPort else { return } + CGEvent.tapEnable(tap: machPort, enable: true) + CFRunLoopAddSource(runLoop, source, .commonModes) } - /// Disables the event tap. + /// Disables the 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 source, let machPort else { return } + CFRunLoopRemoveSource(runLoop, source, .commonModes) + CGEvent.tapEnable(tap: machPort, enable: false) } - 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/Events/HIDEventManager.swift b/Ice/Events/HIDEventManager.swift new file mode 100644 index 000000000..16edb49ac --- /dev/null +++ b/Ice/Events/HIDEventManager.swift @@ -0,0 +1,577 @@ +// +// HIDEventManager.swift +// Ice +// + +import Cocoa +import Combine + +/// 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 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. + private weak var appState: AppState? + + /// 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, isEnabled, let appState, let screen = bestScreen(appState: appState) else { + return event + } + switch event.type { + case .leftMouseDown: + handleShowOnClick(appState: appState, screen: screen) + handleSmartRehide(with: event, appState: appState, screen: screen) + case .rightMouseDown: + handleSecondaryContextMenu(appState: appState, screen: screen) + default: + return event + } + handlePreventShowOnHover(with: event, appState: appState, screen: screen) + return event + } + + /// Monitor for mouse up events. + private(set) lazy var mouseUpMonitor = EventMonitor.universal( + for: .leftMouseUp + ) { [weak self] event in + guard let self, isEnabled else { + return event + } + handleMenuBarItemDragStop() + return event + } + + /// Monitor for mouse dragged events. + private(set) lazy var mouseDraggedMonitor = EventMonitor.universal( + for: .leftMouseDragged + ) { [weak self] event in + if let self, isEnabled, let appState, let screen = bestScreen(appState: appState) { + handleMenuBarItemDragStart(with: event, appState: appState, screen: screen) + } + return event + } + + /// Tap for mouse moved events. + private(set) lazy var mouseMovedTap = EventTap( + type: .mouseMoved, + location: .hidEventTap, + placement: .tailAppendEventTap, + option: .listenOnly + ) { [weak self] _, event in + if let self, isEnabled, let appState, let screen = bestScreen(appState: appState) { + handleShowOnHover(appState: appState, screen: screen) + } + return event + } + + /// Monitor for scroll wheel events. + private(set) lazy var scrollWheelMonitor = EventMonitor.universal( + for: .scrollWheel + ) { [weak self] event in + if let self, isEnabled, let appState, let screen = bestScreen(appState: appState) { + handleShowOnScroll(with: event, appState: appState, screen: screen) + } + return event + } + + // MARK: All Monitors + + /// All monitors maintained by the manager. + private lazy var allMonitors: [any EventMonitorProtocol] = [ + mouseDownMonitor, + mouseUpMonitor, + mouseDraggedMonitor, + mouseMovedTap, + scrollWheelMonitor, + ] + + // MARK: Setup + + /// Sets up the manager. + func performSetup(with appState: AppState) { + self.appState = appState + startAll() + configureCancellables() + } + + /// Configures the internal observers for the manager. + private func configureCancellables() { + var c = Set() + + 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.$frame, + appState.$activeSpace.map(\.isFullscreen), + appState.menuBarManager.$isMenuBarHiddenBySystem + ) + .receive(on: DispatchQueue.main) + .sink { [weak self, weak appState] _, isFullscreen, isMenuBarHiddenBySystem in + guard let self, isEnabled, let appState, isFullscreen || isMenuBarHiddenBySystem else { + return + } + if let screen = bestScreen(appState: appState) { + handleShowOnHover(appState: appState, screen: screen) + } + } + .store(in: &c) + } + + cancellables = c + } + + // MARK: Start/Stop + + /// Starts all monitors. + func startAll() { + isEnabled = enabledStateStack.popLast() ?? true + } + + /// Stops all monitors. + func stopAll() { + enabledStateStack.append(isEnabled) + isEnabled = false + } +} + +// MARK: - Handler Methods + +extension HIDEventManager { + + // MARK: Handle Show On Click + + private func handleShowOnClick(appState: AppState, screen: NSScreen) { + guard + appState.settings.general.showOnClick, + isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen) + else { + return + } + + Task { + if NSEvent.modifierFlags == .control { + handleSecondaryContextMenu(appState: appState, screen: screen) + return + } + + let targetSection: MenuBarSection + + if + NSEvent.modifierFlags == .option, + let alwaysHiddenSection = appState.menuBarManager.section(withName: .alwaysHidden), + alwaysHiddenSection.isEnabled + { + targetSection = alwaysHiddenSection + } else if + let hiddenSection = appState.menuBarManager.section(withName: .hidden), + hiddenSection.isEnabled + { + targetSection = hiddenSection + } else { + return + } + + targetSection.toggle() + } + } + + // MARK: Handle Smart Rehide + + private func handleSmartRehide(with event: NSEvent, appState: AppState, screen: NSScreen) { + guard + appState.settings.general.autoRehide, + case .smart = appState.settings.general.rehideStrategy + else { + return + } + + // 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 + } + } + + // 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 { + // Give the window under the mouse a chance to focus. + try await Task.sleep(for: .milliseconds(250)) + + // 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() + } + return + } + + // Get the window that was clicked. + guard + 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 }), + let owningApplication = windowUnderMouse.owningApplication + else { + return + } + + // Note: The Dock is an exception to the following check. + if owningApplication.bundleIdentifier != "com.apple.dock" { + // Only continue if the clicked app is active, and has + // a regular activation policy. + guard + owningApplication.isActive, + owningApplication.activationPolicy == .regular + else { + return + } + } + + // All checks have passed, hide the sections. + for section in appState.menuBarManager.sections { + section.hide() + } + } + } + + // MARK: Handle Secondary Context Menu + + private func handleSecondaryContextMenu(appState: AppState, screen: NSScreen) { + Task { + guard + appState.settings.advanced.enableSecondaryContextMenu, + isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen), + let mouseLocation = MouseHelpers.locationAppKit + else { + return + } + // Delay prevents the menu from immediately closing. + try await Task.sleep(for: .milliseconds(100)) + appState.menuBarManager.showSecondaryContextMenu(at: mouseLocation) + } + } + + // MARK: Handle Menu Bar Item Drag Stop + + private func handleMenuBarItemDragStop() { + if isDraggingMenuBarItem { + isDraggingMenuBarItem = false + } + } + + // MARK: Handle Menu Bar Item Drag Start + + private func handleMenuBarItemDragStart(with event: NSEvent, appState: AppState, screen: NSScreen) { + guard + !isDraggingMenuBarItem, + event.modifierFlags.contains(.command), + isMouseInsideMenuBar(appState: appState, screen: screen) + else { + return + } + + isDraggingMenuBarItem = true + + if appState.settings.advanced.showAllSectionsOnUserDrag { + for section in appState.menuBarManager.sections { + section.controlItem.state = .showSection + } + } + } + + // MARK: Handle Show On Hover + + private func handleShowOnHover(appState: AppState, screen: NSScreen) { + // Make sure the "ShowOnHover" feature is enabled and allowed. + guard + appState.settings.general.showOnHover, + appState.menuBarManager.showOnHoverAllowed + else { + return + } + + // Only continue if we have a hidden section (we should). + guard let hiddenSection = appState.menuBarManager.section(withName: .hidden) else { + return + } + + let delay = appState.settings.advanced.showOnHoverDelay + + 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 isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen) else { + return + } + 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 + !isMouseInsideMenuBar(appState: appState, screen: screen), + !isMouseInsideIceBar(appState: appState) + else { + return + } + hiddenSection.hide() + } + } + } + + // 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 + } + + guard isMouseInsideMenuBar(appState: appState, screen: screen) else { + return + } + + 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 + } + + let averageDelta = (event.scrollingDeltaX + event.scrollingDeltaY) / 2 + + if averageDelta > 5 { + hiddenSection.show() + } else if averageDelta < -5 { + hiddenSection.hide() + } + } +} + +// MARK: - Helper Methods + +extension HIDEventManager { + /// Returns the best screen to use for event manager calculations. + func bestScreen(appState: AppState) -> NSScreen? { + guard + appState.activeSpace.isFullscreen, + 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. + 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 iceIcon = appState.menuBarManager.controlItem(withName: .visible), + let iceIconFrame = iceIcon.frame, + iceIconFrame.maxY <= screen.frame.maxY, + let mouseLocation = MouseHelpers.locationAppKit + else { + 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. + func isMouseInsideApplicationMenu(appState: AppState, screen: NSScreen) -> Bool { + guard + let mouseLocation = MouseHelpers.locationCoreGraphics, + var applicationMenuFrame = screen.getApplicationMenuFrame() + else { + return false + } + applicationMenuFrame.size.width += applicationMenuFrame.origin.x - screen.frame.origin.x + applicationMenuFrame.origin.x = screen.frame.origin.x + return applicationMenuFrame.contains(mouseLocation) + } + + /// 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 = MouseHelpers.locationCoreGraphics else { + return false + } + 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) + } + } + + /// 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 does not have a notch, this property returns `false`. + func isMouseInsideNotch(appState: AppState, screen: NSScreen) -> Bool { + guard + let mouseLocation = MouseHelpers.locationAppKit, + 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. + 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. + func isMouseInsideIceBar(appState: AppState) -> Bool { + guard let mouseLocation = MouseHelpers.locationAppKit else { + return false + } + 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: -15, dy: -15) + return paddedFrame.contains(mouseLocation) + } + + /// A Boolean value that indicates whether the mouse pointer is within + /// the bounds of the Ice icon. + func isMouseInsideIceIcon(appState: AppState) -> Bool { + guard + let visibleSection = appState.menuBarManager.section(withName: .visible), + let iceIconFrame = visibleSection.controlItem.frame, + let mouseLocation = MouseHelpers.locationAppKit + else { + return false + } + 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 EventMonitor: EventMonitorProtocol { } + +extension EventTap: EventMonitorProtocol { + fileprivate func start() { + enable() + } + + fileprivate func stop() { + disable() + } +} diff --git a/Ice/Events/EventMonitors/RunLoopLocalEventMonitor.swift b/Ice/Events/RunLoopLocalEventMonitor.swift similarity index 89% rename from Ice/Events/EventMonitors/RunLoopLocalEventMonitor.swift rename to Ice/Events/RunLoopLocalEventMonitor.swift index 57ecd519f..685363e4a 100644 --- a/Ice/Events/EventMonitors/RunLoopLocalEventMonitor.swift +++ b/Ice/Events/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/Hotkeys/Hotkey.swift b/Ice/Hotkeys/Hotkey.swift index 2f3b6db74..3b2d8918b 100644 --- a/Ice/Hotkeys/Hotkey.swift +++ b/Ice/Hotkeys/Hotkey.swift @@ -4,80 +4,87 @@ // 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. +@MainActor final class Hotkey: ObservableObject { - private weak var appState: AppState? - - private var listener: Listener? - - let action: HotkeyAction - + /// The hotkey's key combination. @Published var keyCombination: KeyCombination? { didSet { enable() } } + /// 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 } - 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 } - func assignAppState(_ appState: AppState) { + /// Performs the initial setup of the hotkey. + func performSetup(with appState: AppState) { self.appState = appState enable() } + /// Enables the hotkey. func enable() { disable() - listener = Listener(hotkey: self, eventKind: .keyDown, appState: appState) + listener = Listener(hotkey: self, eventKind: .keyDown) } + /// Disables the hotkey. 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 - guard let appState else { + let registry = appState.settings.hotkeys.registry + 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 } - self.appState = appState + self.registry = registry self.id = id } @@ -86,47 +93,23 @@ extension Hotkey { } func invalidate() { - guard isValid else { + guard let id else { return } - guard let appState else { - Logger.hotkey.error("Error invalidating hotkey: Missing AppState") + 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 { +extension Hotkey: @MainActor Equatable { static func == (lhs: Hotkey, rhs: Hotkey) -> Bool { lhs.keyCombination == rhs.keyCombination && lhs.action == rhs.action @@ -134,14 +117,9 @@ 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) } } - -// MARK: - Logger -private extension Logger { - static let hotkey = Logger(category: "Hotkey") -} diff --git a/Ice/Hotkeys/HotkeyAction.swift b/Ice/Hotkeys/HotkeyAction.swift index 51a717984..8c354d0bb 100644 --- a/Ice/Hotkeys/HotkeyAction.swift +++ b/Ice/Hotkeys/HotkeyAction.swift @@ -13,11 +13,10 @@ enum HotkeyAction: String, Codable, CaseIterable { // Other case enableIceBar = "EnableIceBar" - case showSectionDividers = "ShowSectionDividers" 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 { @@ -26,7 +25,7 @@ enum HotkeyAction: String, Codable, CaseIterable { 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 { @@ -35,14 +34,12 @@ enum HotkeyAction: String, Codable, CaseIterable { 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() + appState.menuBarManager.searchPanel.toggle() case .enableIceBar: - appState.settingsManager.generalSettingsManager.useIceBar.toggle() - case .showSectionDividers: - appState.settingsManager.advancedSettingsManager.showSectionDividers.toggle() + appState.settings.general.useIceBar.toggle() case .toggleApplicationMenus: appState.menuBarManager.toggleApplicationMenus() } diff --git a/Ice/Hotkeys/HotkeyRegistry.swift b/Ice/Hotkeys/HotkeyRegistry.swift index e731c5ceb..9e0fba2c9 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 { @@ -119,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 @@ -129,21 +131,21 @@ final class HotkeyRegistry { } guard let keyCombination = hotkey.keyCombination else { - Logger.hotkeyRegistry.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.hotkeyRegistry.error("Hotkey event handler installation failed with status \(status)") + 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.hotkeyRegistry.error("Hotkey already registered for id \(id)") + Logger.hotkeys.error("Hotkey already registered for id \(id, privacy: .public)") return nil } @@ -159,12 +161,12 @@ final class HotkeyRegistry { ) guard status == noErr else { - Logger.hotkeyRegistry.error("Hotkey registration failed with status \(status)") + Logger.hotkeys.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.hotkeys.error("Hotkey registration failed due to invalid EventHotKeyRef") return nil } @@ -185,12 +187,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.hotkeys.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.hotkeys.error("Hotkey unregistration failed with status \(status, privacy: .public)") return } registration.hotKeyRef = nil @@ -236,7 +238,7 @@ final class HotkeyRegistry { let hotKeyRef else { registrations.removeValue(forKey: registration.hotKeyID.id) - Logger.hotkeyRegistry.error("Hotkey registration failed with status \(status)") + Logger.hotkeys.error("Hotkey registration failed with status \(status, privacy: .public)") continue } @@ -284,8 +286,3 @@ final class HotkeyRegistry { return noErr } } - -// MARK: - Logger -private extension Logger { - static let hotkeyRegistry = Logger(category: "HotkeyRegistry") -} 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 ba16f50f4..ad4082552 100644 --- a/Ice/Hotkeys/KeyCombination.swift +++ b/Ice/Hotkeys/KeyCombination.swift @@ -5,13 +5,22 @@ import Carbon.HIToolbox import Cocoa +import OSLog 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) { @@ -31,11 +40,11 @@ private func getSystemReservedKeyCombinations() -> [KeyCombination] { let status = CopySymbolicHotKeys(&symbolicHotkeys) guard status == noErr else { - Logger.keyCombination.error("CopySymbolicHotKeys returned invalid status: \(status)") + Logger.hotkeys.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.hotkeys.error("Failed to retrieve symbolic hotkeys") return [] } @@ -54,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)) @@ -83,8 +81,3 @@ extension KeyCombination: Codable { try container.encode(modifiers.rawValue) } } - -// MARK: - Logger -private extension Logger { - static let keyCombination = Logger(category: "KeyCombination") -} 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/AppDelegate.swift b/Ice/Main/AppDelegate.swift index ee2377277..be67ceee9 100644 --- a/Ice/Main/AppDelegate.swift +++ b/Ice/Main/AppDelegate.swift @@ -3,65 +3,70 @@ // Ice // +import OSLog import SwiftUI @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { - private weak var appState: AppState? + /// The shared app state. + let appState = AppState() // MARK: NSApplicationDelegate Methods func applicationWillFinishLaunching(_ notification: Notification) { - guard let appState else { - Logger.appDelegate.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() } func applicationDidFinishLaunching(_ notification: Notification) { - guard let appState else { - Logger.appDelegate.warning("Missing app state in applicationDidFinishLaunching") - return + // 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 } - // Dismiss the windows. - appState.dismissSettingsWindow() - appState.dismissPermissionsWindow() + // Allow hiding the mouse while the app is in the background + // to make menu bar item movement less jarring. + Bridging.setConnectionProperty(true, forKey: "SetsCursorInBackground") - // 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 - } + #if DEBUG + // Don't perform setup if running as a preview. + if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { + return } - - // 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() - } + #endif + + // Depending on the permissions state, either perform setup + // or prompt to grant permissions. + switch appState.permissions.permissionsState { + case .hasAll: + appState.permissions.logger.debug("Passed all permissions checks") + appState.performSetup(hasPermissions: true) + case .hasRequired: + appState.permissions.logger.debug("Passed required permissions checks") + appState.performSetup(hasPermissions: true) + case .missing: + appState.permissions.logger.debug("Failed required permissions checks") + appState.performSetup(hasPermissions: false) } } + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { + Logger.default.debug("Handling reopen") + openSettingsWindow() + return true + } + 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.default.debug("All windows closed - deactivating with accessory activation policy") + appState.deactivate(withPolicy: .accessory) + } return false } @@ -71,30 +76,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.appDelegate.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.appDelegate.error("Failed to open settings window") - return - } - // Small delay makes this more reliable. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + // Delay makes this more reliable for some reason. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [appState] in appState.activate(withPolicy: .regular) - appState.openSettingsWindow() + appState.openWindow(.settings) } } } - -// MARK: - Logger -private extension Logger { - static let appDelegate = Logger(category: "AppDelegate") -} diff --git a/Ice/Main/AppState.swift b/Ice/Main/AppState.swift index d06d8efa3..fa0601e80 100644 --- a/Ice/Main/AppState.swift +++ b/Ice/Main/AppState.swift @@ -4,151 +4,170 @@ // import Combine +import OSLog 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.isSpaceFullscreen(Bridging.activeSpaceID) + /// Information for the active space. + @Published private(set) var activeSpace = SpaceInfo.activeSpace() - /// 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) + /// Model for the app's settings. + let settings = AppSettings() - /// Manager for menu bar items. - private(set) lazy var itemManager = MenuBarItemManager(appState: self) + /// Model for the app's permissions. + let permissions = AppPermissions() - /// Manager for the state of the menu bar. - private(set) lazy var menuBarManager = MenuBarManager(appState: self) + /// Model for app-wide navigation. + let navigationState = AppNavigationState() - /// Manager for app permissions. - private(set) lazy var permissionsManager = PermissionsManager(appState: self) + /// Manager for the state of the menu bar. + 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 input events received by the app. + let hidEventManager = HIDEventManager() - /// Model for app-wide navigation. - let navigationState = AppNavigationState() + /// Manager for app updates. + let updatesManager = UpdatesManager() - /// The app's hotkey registry. - nonisolated let hotkeyRegistry = HotkeyRegistry() + /// Manager for user notifications. + let userNotificationManager = UserNotificationManager() - /// The app's delegate. - private(set) weak var appDelegate: AppDelegate? + /// Storage for internal observers. + private var cancellables = Set() - /// The window that contains the settings interface. - private(set) weak var settingsWindow: NSWindow? + /// Logger for the app state. + private let logger = Logger(category: "AppState") - /// The window that contains the permissions interface. - private(set) weak var permissionsWindow: NSWindow? + /// Async setup actions, run once on first access. + private lazy var setupTask = Task { + permissions.stopAllChecks() - /// A Boolean value that indicates whether the "ShowOnHover" feature is prevented. - private(set) var isShowOnHoverPrevented = false + settings.performSetup(with: self) + menuBarManager.performSetup(with: self) - /// Storage for internal observers. - private var cancellables = Set() + if #available(macOS 26.0, *) { + await MenuBarItemService.Connection.shared.start() + } + + appearanceManager.performSetup(with: self) + hidEventManager.performSetup(with: self) + await itemManager.performSetup(with: self) + imageCache.performSetup(with: self) + updatesManager.performSetup(with: self) + userNotificationManager.performSetup(with: self) + + configureCancellables() + } - /// 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 - }() - - /// 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 { + 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)) + activate(withPolicy: .regular) + dismissWindow(.settings) // Shouldn't be open anyway. + openWindow(.permissions) + } + } } /// Configures the internal observers for the app state. private func configureCancellables() { var c = Set() - Publishers.Merge3( - NSWorkspace.shared.notificationCenter - .publisher(for: NSWorkspace.activeSpaceDidChangeNotification) - .mapToVoid(), - // 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(), - // Clicking into a fullscreen space from another space is also ignored. - UniversalEventMonitor - .publisher(for: .leftMouseDown) - .delay(for: 0.1, scheduler: DispatchQueue.main) - .mapToVoid() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self else { - return + // 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) } - isActiveSpaceFullscreen = Bridging.isSpaceFullscreen(Bridging.activeSpaceID) - } - .store(in: &c) + .store(in: &c) NSWorkspace.shared.publisher(for: \.frontmostApplication) .receive(on: DispatchQueue.main) - .sink { [weak self] frontmostApplication in - guard let self else { - return - } - navigationState.isAppFrontmost = frontmostApplication == .current + .map { $0 == .current } + .removeDuplicates() + .sink { [weak self] isFrontmost in + self?.navigationState.isAppFrontmost = isFrontmost } .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 - } - .store(in: &c) - } else { - Logger.appState.warning("No settings window!") - } + publisherForWindow(.settings) + .removeNil() + .flatMap { $0.publisher(for: \.isVisible) } + .replaceEmpty(with: false) + .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) + .removeDuplicates() + .sink { [weak self] isPresented in + self?.navigationState.isSettingsPresented = isPresented + } + .store(in: &c) + + hidEventManager.$isDraggingMenuBarItem + .removeDuplicates() + .sink { [weak self] isDragging in + self?.isDraggingMenuBarItem = isDragging + } + .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 { - 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) @@ -158,12 +177,12 @@ final class AppState: ObservableObject { self?.objectWillChange.send() } .store(in: &c) - permissionsManager.objectWillChange + permissions.objectWillChange .sink { [weak self] in self?.objectWillChange.send() } .store(in: &c) - settingsManager.objectWillChange + settings.objectWillChange .sink { [weak self] in self?.objectWillChange.send() } @@ -177,133 +196,70 @@ 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.appState.warning("Multiple attempts made to assign app delegate") - return + /// 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: + permissions.accessibility.hasPermission + case .screenRecording: + permissions.screenRecording.hasPermission } - self.appDelegate = appDelegate } - /// 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!") - return - } - settingsWindow = window - configureCancellables() - } - - /// 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!") - return - } - permissionsWindow = window - configureCancellables() - } - - /// Opens the settings window. - func openSettingsWindow() { - with(EnvironmentValues()) { environment in - environment.openWindow(id: Constants.settingsWindowID) - } - } - - /// Dismisses the settings window. - func dismissSettingsWindow() { - with(EnvironmentValues()) { environment in - environment.dismissWindow(id: Constants.settingsWindowID) + /// Returns a publisher for the window with the given identifier. + func publisherForWindow(_ id: IceWindowIdentifier) -> some Publisher { + 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) } } - /// Opens the permissions window. - func openPermissionsWindow() { - with(EnvironmentValues()) { environment in - environment.openWindow(id: Constants.permissionsWindowID) + /// Opens the window with the given identifier. + func openWindow(_ id: IceWindowIdentifier) { + // Async prevents conflicts with SwiftUI. + DispatchQueue.main.async { + self.logger.debug("Opening window with id: \(id, privacy: .public)") + EnvironmentValues().openWindow(id: id) } } - /// Dismisses the permissions window. - func dismissPermissionsWindow() { - with(EnvironmentValues()) { environment in - environment.dismissWindow(id: Constants.permissionsWindowID) + /// Dismisses the window with the given identifier. + func dismissWindow(_ id: IceWindowIdentifier) { + // Async prevents conflicts with SwiftUI. + DispatchQueue.main.async { + self.logger.debug("Dismissing window with id: \(id, privacy: .public)") + EnvironmentValues().dismissWindow(id: id) } } - /// 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() - } + /// Activates the app and sets its activation policy. + func activate(withPolicy policy: NSApplication.ActivationPolicy? = nil) { + if let policy { NSApp.setActivationPolicy(policy) } - - 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() - } + // NSApplication.activate(ignoringOtherApps:) is deprecated, with + // 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 } + NSRunningApplication.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() + /// Deactivates the app and sets its activation policy. + func deactivate(withPolicy policy: NSApplication.ActivationPolicy? = nil) { + if let policy { + NSApp.setActivationPolicy(policy) } - NSApp.setActivationPolicy(policy) - } - - /// Prevents the "ShowOnHover" feature. - func preventShowOnHover() { - isShowOnHoverPrevented = true + NSApp.deactivate() } - - /// Allows the "ShowOnHover" feature. - func allowShowOnHover() { - isShowOnHoverPrevented = false - } -} - -// 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..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.migrateAll(appState: appState) - 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/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/Updates/UpdatesManager.swift b/Ice/Main/Updates.swift similarity index 92% rename from Ice/Updates/UpdatesManager.swift rename to Ice/Main/Updates.swift index ee5f80d72..39a115485 100644 --- a/Ice/Updates/UpdatesManager.swift +++ b/Ice/Main/Updates.swift @@ -1,5 +1,5 @@ // -// UpdatesManager.swift +// Updates.swift // Ice // @@ -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() } @@ -85,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 } @@ -140,6 +135,3 @@ extension UpdatesManager: @preconcurrency SPUStandardUserDriverDelegate { appState.userNotificationManager.removeDeliveredNotifications(with: [.updateCheck]) } } - -// MARK: UpdatesManager: BindingExposable -extension UpdatesManager: BindingExposable { } 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/MenuBarShape.swift b/Ice/MenuBar/Appearance/Configurations/MenuBarShapes.swift similarity index 66% rename from Ice/MenuBar/Appearance/MenuBarShape.swift rename to Ice/MenuBar/Appearance/Configurations/MenuBarShapes.swift index b80355149..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 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 - /// 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 92% rename from Ice/MenuBar/Appearance/MenuBarTintKind.swift rename to Ice/MenuBar/Appearance/Configurations/MenuBarTintKind.swift index 9d665f63d..332a29a61 100644 --- a/Ice/MenuBar/Appearance/MenuBarTintKind.swift +++ b/Ice/MenuBar/Appearance/Configurations/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: "None" case .solid: "Solid" case .gradient: "Gradient" } diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift index e777c935a..c1839dae5 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditor.swift @@ -8,122 +8,130 @@ import SwiftUI struct MenuBarAppearanceEditor: View { enum Location { case settings - case popover(closePopover: () -> Void) + case panel } @EnvironmentObject var appState: AppState - @EnvironmentObject var appearanceManager: MenuBarAppearanceManager + @ObservedObject var appearanceManager: MenuBarAppearanceManager + @Environment(\.dismissWindow) private var dismissWindow + @State private var isResetPromptPresented = false let location: Location - private var mainFormPadding: EdgeInsets { - with(EdgeInsets(all: 20)) { insets in - switch location { - case .settings: break - case .popover: insets.top = 0 - } - } - } - var body: some View { - VStack(alignment: .leading, spacing: 0) { - stackHeader - stackBody - } - } - - @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) + if #available(macOS 26.0, *) { + bodyContent + .safeAreaBar(edge: .bottom, spacing: 0) { + bottomBar + } + } else { + VStack(spacing: 0) { + bodyContent + bottomBar } - .padding(20) } } @ViewBuilder - private var stackBody: some View { + private var bodyContent: some View { if appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults { cannotEdit + } else if #available(macOS 26.0, *) { + mainForm + .scrollEdgeEffectStyle(.hard, for: .vertical) } else { mainForm } } + @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) { + IceForm { + if + case .settings = location, + appState.settings.advanced.enableSecondaryContextMenu + { + CalloutBox( + "Tip: You can also edit these settings by right-clicking in an empty area of the menu bar.", + systemImage: "lightbulb" + ) + } IceSection { 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 } - 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") - } - } + } + } + + @ViewBuilder + private var bottomBar: some View { + HStack { + if case .panel = location { + Button("Done") { + dismissWindow() } } + + Spacer() + if !appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults, 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) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) } } + .buttonBorderShape(.capsule) + .padding(10) } @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") - } - - @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) + Toggle("Use dynamic appearance", isOn: $appearanceManager.configuration.isDynamic) + .annotation("Apply different settings based on the current system appearance.") } @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 ) } } @@ -146,7 +154,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 @@ -156,21 +164,22 @@ private struct UnlabeledPartialEditor: View { .labelsHidden() switch configuration.tintKind { - case .none: + case .noTint: EmptyView() case .solid: - CustomColorPicker( + ColorPicker( + 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) @@ -190,13 +199,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 - ) - } + ColorPicker( + "Border Color", + selection: $configuration.borderColor, + supportsOpacity: true + ) } } @@ -216,7 +223,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 @@ -241,86 +248,82 @@ 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: appearanceManager.configuration.lightModeConfiguration) - case .dark: - PreviewButton(configuration: appearanceManager.configuration.darkModeConfiguration) - } - } - @ViewBuilder 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) } } 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 832a5e577..93b27b492 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift @@ -6,110 +6,126 @@ 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], backing: .buffered, defer: false ) - self.appState = appState + self.title = "Menu Bar Appearance" + self.titlebarAppearsTransparent = true + self.allowsToolTipsWhenApplicationIsInactive = true self.isFloatingPanel = true - self.backgroundColor = .clear + self.hidesOnDeactivate = false + self.isMovableByWindowBackground = false + self.collectionBehavior = [.fullScreenAuxiliary, .moveToActiveSpace] + } + + /// Sets up the panel. + func performSetup(with appState: AppState) { + self.appState = appState + 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) + + 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 } - /// 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 panel's position for display on the given screen. + private func updatePosition(for screen: NSScreen) { + let originX = screen.visibleFrame.midX - frame.width / 2 + let originY = screen.visibleFrame.maxY + setFrameTopLeftPoint(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) { + updatePosition(for: screen) + makeKeyAndOrderFront(nil) } } -// MARK: - MenuBarAppearanceEditorPopover +// MARK: - MenuBarAppearanceEditorHostingView -/// 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? - - private(set) lazy var mouseDownMonitor = GlobalEventMonitor(mask: .leftMouseDown) { [weak self] _ in - self?.performClose(self) - } - - @ViewBuilder - private var contentView: some View { - if let appState { - MenuBarAppearanceEditor( - location: .popover(closePopover: { [weak self] in - self?.performClose(self) - }) - ) - .environmentObject(appState) - .environmentObject(appState.appearanceManager) - } +private final class MenuBarAppearanceEditorHostingView: NSHostingView { + 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( + appearanceManager: appState.appearanceManager, + location: .panel + ) + .environmentObject(appState) + } } diff --git a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarShapePicker.swift b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarShapePicker.swift index 4e935530a..47b85ec07 100644 --- a/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarShapePicker.swift +++ b/Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarShapePicker.swift @@ -6,50 +6,45 @@ 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) { - ForEach(MenuBarShapeKind.allCases, id: \.self) { shape in - switch shape { - case .none: - Text("None").tag(shape) - case .full: - Text("Full").tag(shape) - case .split: - Text("Split").tag(shape) - } + IcePicker("Shape Kind", selection: $configuration.shapeKind) { + ForEach(MenuBarShapeKind.allCases) { shapeKind in + Text(shapeKind.localized).tag(shapeKind) } } } @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 +148,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 +198,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 74cda2f4d..8422ea58a 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 @@ -31,15 +32,11 @@ 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 - - /// Creates a manager with the given app state. - init(appState: AppState) { - self.appState = appState - } + let menuBarInsetAmount: CGFloat = if #available(macOS 26.0, *) { 3.5 } else { 5 } /// Performs initial setup of the manager. - func performSetup() { + func performSetup(with appState: AppState) { + self.appState = appState loadInitialState() configureCancellables() } @@ -51,7 +48,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 +77,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) @@ -114,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 @@ -144,21 +141,4 @@ 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 -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..99d4cc1b1 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,12 +52,12 @@ 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 - /// 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() @@ -92,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() @@ -139,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 { @@ -178,10 +180,10 @@ final class MenuBarOverlayPanel: NSPanel { Publishers.Merge( publisher(for: \.isOnActiveSpace) .receive(on: DispatchQueue.main) - .mapToVoid(), - UniversalEventMonitor.publisher(for: .leftMouseUp) + .replace(with: ()), + EventMonitor.publish(events: .leftMouseUp, scope: .universal) .filter { [weak self] _ in self?.isOnActiveSpace ?? false } - .mapToVoid() + .replace(with: ()) ) .debounce(for: 0.05, scheduler: DispatchQueue.main) .sink { [weak self] in @@ -227,11 +229,10 @@ final class MenuBarOverlayPanel: NSPanel { // Must be run async, or this will not remove the flags. self.updateFlags.removeAll() } - let windows = WindowInfo.getOnScreenWindows() - guard let owningDisplay = self.validate(for: .updates, with: windows) else { - return + let windows = WindowInfo.createWindows(option: .onScreen) + if validate(for: .updates, with: windows) { + performUpdates(for: flags, windows: windows, screen: owningScreen) } - performUpdates(for: flags, windows: windows, display: owningDisplay) } .store(in: &c) @@ -253,78 +254,74 @@ 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 { - Logger.overlayPanel.debug("No app state. \(actionMessage)") - return nil + MenuBarOverlayPanel.logger.debug("No app state. \(actionMessage, privacy: .public)") + return false } guard !appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults else { - Logger.overlayPanel.debug("Menu bar is hidden by system. \(actionMessage)") - return nil + MenuBarOverlayPanel.logger.debug("Menu bar is hidden by system. \(actionMessage, privacy: .public)") + return false } - guard !appState.isActiveSpaceFullscreen else { - Logger.overlayPanel.debug("Active space is fullscreen. \(actionMessage)") - return nil + guard !appState.activeSpace.isFullscreen else { + MenuBarOverlayPanel.logger.debug("Active space is fullscreen. \(actionMessage, privacy: .public)") + return false } - let owningDisplay = owningScreen.displayID - guard appState.menuBarManager.hasValidMenuBar(in: windows, for: owningDisplay) else { - Logger.overlayPanel.debug("No valid menu bar found. \(actionMessage)") - return nil + guard appState.menuBarManager.hasValidMenuBar(in: windows, for: owningScreen.displayID) else { + MenuBarOverlayPanel.logger.debug("No valid menu bar found. \(actionMessage, privacy: .public)") + 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 /// 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 } - let wallpaper = ScreenCapture.captureWindow(wallpaperWindow.windowID, screenBounds: menuBarWindow.frame) + 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) } } /// Shows the panel. private func show() { - guard - let appState, - !appState.isPreview - else { + guard let appState else { return } guard appState.appearanceManager.overlayPanels.contains(self) else { - Logger.overlayPanel.warning("Overlay panel \(self) not retained") + MenuBarOverlayPanel.logger.warning("Overlay panel \(self) not retained") return } @@ -392,6 +389,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. // @@ -401,19 +410,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 - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - 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 + section.controlItem.$onScreenFrame .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.needsDisplay = true @@ -422,17 +419,6 @@ private final class MenuBarOverlayPanelContentView: NSView { } } - // 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 @@ -448,8 +434,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 } @@ -570,12 +556,12 @@ 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) - 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 - width += item.frame.width + let totalWidth = itemWindows.reduce(into: 0) { width, item in + width += item.bounds.width } var position = rect.maxX - totalWidth if shouldInset { @@ -629,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) { @@ -637,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) } } @@ -654,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( @@ -675,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: [ @@ -756,7 +742,7 @@ private final class MenuBarOverlayPanelContentView: NSView { } let borderPath = switch fullConfiguration.shapeKind { - case .none: + case .noShape: NSBezierPath(rect: drawableBounds) case .full: pathForFullShape( @@ -786,8 +772,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 d35f356c9..9cc48f2a8 100644 --- a/Ice/MenuBar/ControlItem/ControlItem.swift +++ b/Ice/MenuBar/ControlItem/ControlItem.swift @@ -6,73 +6,152 @@ 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" + /// 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 .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 } - /// The control item's hiding state (`@Published`). - @Published var state = HidingState.hideItems + /// 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) { + ControlItemDefaults.preflightSetup(for: controlItem) + + self.statusItem = NSStatusBar.system.statusItem(withLength: 0) + 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 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. + var foundConstraint: NSLayoutConstraint? = nil + if + let window = button.window, + let contentView = window.contentView, + let constraints = try? contentView.constraintsAffectingLayout(for: .horizontal) + { + foundConstraint = constraints.first(where: Predicates.controlItemConstraint(button: button)) + } + self.constraint = foundConstraint - /// A Boolean value that indicates whether the control item is visible (`@Published`). - @Published var isVisible = true + button.target = controlItem + button.action = #selector(controlItem.performAction) + button.sendAction(on: [.leftMouseDown, .rightMouseUp]) + } else { + self.constraint = nil + } + } - /// The frame of the control item's window (`@Published`). - @Published private(set) var windowFrame: CGRect? + deinit { + removeStatusItem() + } - /// The shared app state. - private weak var appState: AppState? + /// 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 = ControlItemDefaults[.preferredPosition, autosaveName] + NSStatusBar.system.removeStatusItem(statusItem) + ControlItemDefaults[.preferredPosition, autosaveName] = cached + } + } - /// The control item's underlying status item. - private let statusItem: NSStatusItem + /// The control item's hiding state (`@Published`). + @Published var state = HidingState.hideSection - /// A horizontal constraint for the control item's content view. - private let constraint: NSLayoutConstraint? + /// 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. - private let identifier: Identifier + let identifier: Identifier + + /// Lazy 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 menu bar section associated with the control item. - private weak var section: MenuBarSection? { - appState?.menuBarManager.sections.first { $0.controlItem === self } - } - - /// The control item's window. - var window: NSWindow? { - statusItem.button?.window + /// The control item's underlying status item. + private var statusItem: NSStatusItem { + storage.statusItem } - /// The identifier of the control item's window. - var windowID: CGWindowID? { - guard let window else { - return nil - } - return CGWindowID(window.windowNumber) + /// A horizontal constraint for the control item's content view. + private var constraint: NSLayoutConstraint? { + storage.constraint } /// 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 @@ -81,58 +160,24 @@ final class ControlItem { statusItem.isVisible } - /// Creates a control item with the given identifier and app state. - init(identifier: Identifier, appState: AppState) { - 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 - } + /// The corresponding section name for the control item. + var sectionName: MenuBarSection.Name { + switch identifier { + case .visible: .visible + case .hidden: .hidden + case .alwaysHidden: .alwaysHidden } + } - self.statusItem = NSStatusBar.system.statusItem(withLength: 0) - self.statusItem.autosaveName = autosaveName + /// Creates a control item with the given identifier. + init(identifier: Identifier) { 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 - } else { - self.constraint = nil - } - - configureStatusItem() } - /// 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. + func performSetup(with appState: AppState) { + self.appState = appState + configureCancellables() } /// Configures the internal observers for the control item. @@ -140,286 +185,319 @@ 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) - Publishers.CombineLatest($isVisible, $state) - .sink { [weak self] (isVisible, state) in + statusItem.publisher(for: \.isVisible) + .receive(on: DispatchQueue.main) + .sink { [weak self] isVisible in guard let self, - let section + let menuBarManager = appState?.menuBarManager, + let section = menuBarManager.section(withName: sectionName), + let hotkey = section.hotkey 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 + hotkey.enable() } else { - statusItem.length = 0 - constraint?.isActive = false - if let window { - var size = window.frame.size - size.width = 1 - window.setContentSize(size) - } + hotkey.disable() } } .store(in: &c) - constraint?.publisher(for: \.isActive) - .removeDuplicates() - .sink { [weak self] isActive in - self?.isVisible = isActive + 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) - statusItem.publisher(for: \.isVisible) + $window.removeNil() + .flatMap { $0.publisher(for: \.frame) } + .removeDuplicates() .receive(on: DispatchQueue.main) - .sink { [weak self] isVisible in - guard - let self, - let appState, - let section - else { - return - } - - let manager = appState.settingsManager.hotkeySettingsManager - - let hotkey: Hotkey? = switch section.name { - case .visible: nil - case .hidden: manager.hotkey(withAction: .toggleHiddenSection) - case .alwaysHidden: manager.hotkey(withAction: .toggleAlwaysHiddenSection) - } - - guard let hotkey else { - return - } + .sink { [weak self] frame in + self?.frame = frame + } + .store(in: &c) - if isVisible { - hotkey.enable() - } else { - hotkey.disable() - } + $window.removeNil() + .flatMap { $0.publisher(for: \.screen) } + .receive(on: DispatchQueue.main) + .sink { [weak self] screen in + self?.screen = screen } .store(in: &c) - window?.publisher(for: \.frame) - .sink { [weak self] frame in - guard - let self, - let screen = window?.screen, - screen.frame.intersects(frame) - else { + $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 } - windowFrame = frame + if screenFrame.intersects(frame) { + onScreenFrame = frame + } else { + onScreenFrame = nil + } } .store(in: &c) if let appState { - appState.settingsManager.generalSettingsManager.$showIceIcon + appState.$isDraggingMenuBarItem + .removeDuplicates() .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 + .sink { [weak self] isDragging 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 + if isDragging { + updateStatusItem() } - updateStatusItem(with: state) } .store(in: &c) - appState.settingsManager.generalSettingsManager.$useIceBar - .receive(on: DispatchQueue.main) - .sink { [weak self] useIceBar in - guard - let self, - let button = statusItem.button - else { - return + if identifier == .visible { + appState.settings.general.$showIceIcon + .combineLatest(statusItem.publisher(for: \.isVisible)) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] shouldShow, _ in + guard let self else { + return + } + if shouldShow { + addToMenuBar() + } else { + removeFromMenuBar() + } } - if useIceBar { - button.sendAction(on: [.leftMouseDown, .rightMouseUp]) - } else { - button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + .store(in: &c) + + appState.settings.general.$iceIcon + .combineLatest(appState.settings.general.$customIceIconIsTemplate) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateStatusItem() } - } - .store(in: &c) + .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 == .alwaysHidden { + appState.settings.advanced.$enableAlwaysHiddenSection + .combineLatest(statusItem.publisher(for: \.isVisible)) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] shouldEnable, _ in + guard let self else { + return + } + if shouldEnable { + 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 - } - if enable { - addToMenuBar() - } else { - removeFromMenuBar() + if isSectionDivider { + appState.settings.advanced.$sectionDividerStyle + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateStatusItem() } - } - .store(in: &c) + .store(in: &c) + } } cancellables = c } - /// Sets the initial configuration for the status item. - private func configureStatusItem() { - defer { - configureCancellables() - updateStatusItem(with: state) - } - guard let button = statusItem.button else { - return - } - button.target = self - button.action = #selector(performAction) - } - - /// 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 section, let button = statusItem.button else { return } - switch section.name { + button.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize) + button.title = "" + button.image = nil + + switch identifier { case .visible: - isVisible = true - // Enable the cell, as it may have been previously disabled. - button.cell?.isEnabled = true - let icon = appState.settingsManager.generalSettingsManager.iceIcon - // We can usually just set the image directly from the icon. - button.image = switch state { - case .hideItems: icon.hidden.nsImage(for: appState) - case .showItems: icon.visible.nsImage(for: appState) + updateStatusItemVisibility(true) + button.appearsDisabled = false + + let icon = appState.settings.general.iceIcon + + // We can usually just create the image directly from the icon. + var image = switch state { + case .showSection: icon.visible.nsImage(for: appState) + case .hideSection: icon.hidden.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 - 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 + case .showSection: + switch appState.settings.advanced.sectionDividerStyle { + case .noDivider: + updateStatusItemVisibility(false) + button.appearsDisabled = true + button.isHighlighted = false + + if appState.isDraggingMenuBarItem && appState.settings.advanced.showAllSectionsOnUserDrag { + // We still want a subtle marker between sections. + button.title = "|" + } + case .chevron: + updateStatusItemVisibility(true) + button.appearsDisabled = false + + button.image = switch identifier { + case .hidden: + ControlItemImage.builtin(.chevronLarge).nsImage(for: appState) + case .alwaysHidden: + ControlItemImage.builtin(.chevronSmall).nsImage(for: appState) + case .visible: nil + } } + case .hideSection: + updateStatusItemVisibility(true) + button.appearsDisabled = true + button.isHighlighted = false } } } + /// Updates the visibility of the status item. + /// + /// 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 + } + + if isVisible { + constraint?.isActive = true + statusItem.length = identifier.length(for: state) + } else { + 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 { + let size = withMutableCopy(of: window.frame.size) { $0.width = shouldShow ? 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 preferred position. Cache and restore it. + let autosaveName = statusItem.autosaveName as String + let cached = ControlItemDefaults[.preferredPosition, autosaveName] + statusItem.isVisible = false + ControlItemDefaults[.preferredPosition, autosaveName] = cached + } + /// Performs the control item's action. @objc private func performAction() { guard - let appState, + let menuBarManager = appState?.menuBarManager, let event = NSApp.currentEvent else { return } + switch event.type { - case .leftMouseDown, .leftMouseUp: - if NSEvent.modifierFlags == .control { - statusItem.showMenu(createMenu(with: appState)) - } else if - NSEvent.modifierFlags == .option, - appState.settingsManager.advancedSettingsManager.canToggleAlwaysHiddenSection - { - if let alwaysHiddenSection = appState.menuBarManager.section(withName: .alwaysHidden) { - alwaysHiddenSection.toggle() + case .leftMouseDown: + let modifierFlags = NSEvent.modifierFlags + + // Running this from a Task seems to improve the visual + // responsiveness of the status item's button. + Task { + if modifierFlags == .control { + showMenu() + return + } + + if + modifierFlags == .option, + let section = menuBarManager.section(withName: .alwaysHidden), + section.isEnabled + { + section.toggle() + return + } + + if + let section = menuBarManager.section(withName: sectionName), + section.isEnabled + { + section.toggle() } - } else { - section?.toggle() } case .rightMouseUp: - statusItem.showMenu(createMenu(with: appState)) + showMenu() default: - break + return } } /// 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") @@ -439,7 +517,6 @@ final class ControlItem { action: #selector(showSearchPanel), keyEquivalent: "" ) - searchItem.target = self if let hotkey = hotkey(withAction: .searchMenuBarItems), let keyCombination = hotkey.keyCombination @@ -447,47 +524,33 @@ 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( - title: "\(section.isHidden ? "Show" : "Hide") the \(name.displayString) Section", + title: "\(section.isHidden ? "Show" : "Hide") \(name.displayString) Section", action: #selector(toggleMenuBarSection), keyEquivalent: "" ) - item.target = self - Self.sectionStorage.weakSet(section, for: item) - 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 - } + 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 menu.addItem(item) } @@ -514,22 +577,26 @@ 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) { - Self.sectionStorage.value(for: menuItem)?.toggle() + guard let section = menuItem.representedObject as? MenuBarSection else { + return + } + section.toggle() } /// Opens the menu bar search panel. @objc private func showSearchPanel() { - guard - let appState, - let screen = MenuBarSearchPanel.defaultScreen - else { - return - } - Task { - await appState.menuBarManager.searchPanel.show(on: screen) - } + appState?.menuBarManager.searchPanel.show() } /// Opens the settings window and checks for app updates. @@ -539,39 +606,94 @@ final class ControlItem { } appState.updatesManager.checkForUpdates() } +} - /// Adds the control item to the menu bar. - func addToMenuBar() { - guard !isAddedToMenuBar else { - return +// 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) } - statusItem.isVisible = true } - /// Removes the control item from the menu bar. - func removeFromMenuBar() { - guard isAddedToMenuBar else { + /// 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 } - // 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 + 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 + } } } -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: - 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: - Logger -private extension Logger { - /// The logger to use for control items. - static let controlItem = Logger(category: "ControlItem") +// 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/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/UI/IceBar/IceBar.swift b/Ice/MenuBar/IceBar/IceBar.swift similarity index 58% rename from Ice/UI/IceBar/IceBar.swift rename to Ice/MenuBar/IceBar/IceBar.swift index 40c689829..bf92e00f7 100644 --- a/Ice/UI/IceBar/IceBar.swift +++ b/Ice/MenuBar/IceBar/IceBar.swift @@ -4,27 +4,32 @@ // import Combine +import OSLog 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,66 +42,67 @@ 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() - // 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 } + /// Updates the panel's frame origin for display on the given screen. private func updateOrigin(for screen: NSScreen) { guard let appState else { return @@ -112,12 +118,12 @@ final class IceBarPanel: NSPanel { switch iceBarLocation { case .dynamic: - if appState.eventManager.isMouseInsideEmptyMenuBarSpace { + if appState.hidEventManager.isMouseInsideEmptyMenuBarSpace(appState: appState, screen: screen) { return getOrigin(for: .mousePointer) } return getOrigin(for: .iceIcon) case .mousePointer: - guard let location = MouseCursor.locationAppKit else { + guard let location = MouseHelpers.locationAppKit else { return getOrigin(for: .iceIcon) } @@ -135,52 +141,75 @@ final class IceBarPanel: NSPanel { guard lowerBound <= upperBound, - let section = appState.menuBarManager.section(withName: .visible), - let windowID = section.controlItem.windowID, - // Bridging.getWindowFrame is more reliable than ControlItem.windowFrame, - // i.e. if the control item is offscreen. - let itemFrame = Bridging.getWindowFrame(for: windowID) + 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) 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) } } - setFrameOrigin(getOrigin(for: appState.settingsManager.generalSettingsManager.iceBarLocation)) + setFrameOrigin(getOrigin(for: appState.settings.general.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 caches. appState.navigationState.isIceBarPresented = true currentSection = section - await appState.itemManager.cacheItemsIfNeeded() - - if ScreenCapture.cachedCheckPermissions() { + let cacheTask = Task(timeout: .seconds(1)) { + await appState.itemManager.cacheItemsIfNeeded() await appState.imageCache.updateCache() } - contentView = IceBarHostingView(appState: appState, colorManager: colorManager, screen: screen, section: section) { [weak self] in - self?.close() + do { + try await cacheTask.value + } catch { + Logger.default.error("Cache update failed when showing IceBarPanel - \(error)") } + contentView = IceBarHostingView( + 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, + let section = appState?.menuBarManager.section(withName: name) + { + section.hide() + } + close() + } + override func close() { super.close() contentView = nil @@ -191,27 +220,25 @@ final class IceBarPanel: NSPanel { // MARK: - IceBarHostingView -private final class IceBarHostingView: NSHostingView { - override var safeAreaInsets: NSEdgeInsets { - NSEdgeInsets() - } +private final class IceBarHostingView: NSHostingView { + override var safeAreaInsets: NSEdgeInsets { NSEdgeInsets() } init( 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) - .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) @@ -220,7 +247,7 @@ private final class IceBarHostingView: NSHostingView { } @available(*, unavailable) - required init(rootView: AnyView) { + required init(rootView: IceBarContentView) { fatalError("init(rootView:) has not been implemented") } @@ -232,17 +259,16 @@ 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 let screen: NSScreen let section: MenuBarSection.Name - let closePanel: () -> Void private var items: [MenuBarItem] { itemManager.itemCache.managedItems(for: section) @@ -253,28 +279,36 @@ 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? { - guard let menuBarHeight = imageCache.menuBarHeight ?? screen.getMenuBarHeight() else { + 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 } - 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) } } @@ -288,7 +322,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) @@ -301,7 +335,7 @@ private struct IceBarContentView: View { } } .padding(5) - .frame(maxWidth: imageCache.screen?.frame.width) + .frame(maxWidth: screen.frame.width) .fixedSize() .onFrameChange(update: $frame) } @@ -313,9 +347,10 @@ 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() + appState.activate(withPolicy: .regular) + appState.openWindow(.settings) } label: { Text("Open Ice Settings") } @@ -326,6 +361,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) @@ -333,11 +375,17 @@ private struct IceBarContentView: View { ScrollView(.horizontal) { HStack(spacing: 0) { ForEach(items, id: \.windowID) { item in - IceBarItemView(item: item, closePanel: closePanel) + IceBarItemView( + imageCache: imageCache, + itemManager: itemManager, + menuBarManager: menuBarManager, + item: item, + section: section + ) } } } - .environment(\.isScrollEnabled, frame.width == imageCache.screen?.frame.width) + .environment(\.isScrollEnabled, frame.width == screen.frame.width) .defaultScrollAnchor(.trailing) .scrollIndicatorsFlash(trigger: scrollIndicatorsFlashTrigger) .task { @@ -350,50 +398,52 @@ private struct IceBarContentView: View { // MARK: - IceBarItemView private struct IceBarItemView: View { - @EnvironmentObject var imageCache: MenuBarItemImageCache - @EnvironmentObject var itemManager: MenuBarItemManager + @ObservedObject var imageCache: MenuBarItemImageCache + @ObservedObject var itemManager: MenuBarItemManager + @ObservedObject 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 { [weak itemManager, weak menuBarManager] in + guard let itemManager, let menuBarManager else { return } - closePanel() + menuBarManager.section(withName: section)?.hide() Task { try await Task.sleep(for: .milliseconds(25)) - itemManager.tempShowItem(item, clickWhenFinished: true, mouseButton: .left) + if Bridging.isWindowOnScreen(item.windowID) { + try await itemManager.click(item: item, with: .left) + } else { + 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 } - closePanel() + menuBarManager.section(withName: section)?.hide() Task { try await Task.sleep(for: .milliseconds(25)) - itemManager.tempShowItem(item, clickWhenFinished: true, mouseButton: .right) + if Bridging.isWindowOnScreen(item.windowID) { + try await itemManager.click(item: item, with: .right) + } else { + await itemManager.temporarilyShow(item: item, clickingWith: .right) + } } } } 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 { @@ -401,7 +451,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) @@ -425,7 +479,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 @@ -438,10 +496,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 @@ -458,7 +512,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 } @@ -469,7 +523,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 } @@ -483,7 +537,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/UI/IceBar/IceBarColorManager.swift b/Ice/MenuBar/IceBar/IceBarColorManager.swift similarity index 54% rename from Ice/UI/IceBar/IceBarColorManager.swift rename to Ice/MenuBar/IceBar/IceBarColorManager.swift index da77cd19a..317b36949 100644 --- a/Ice/UI/IceBar/IceBarColorManager.swift +++ b/Ice/MenuBar/IceBar/IceBarColorManager.swift @@ -3,8 +3,8 @@ // Ice // -import Cocoa import Combine +import SwiftUI final class IceBarColorManager: ObservableObject { @Published private(set) var colorInfo: MenuBarAverageColorInfo? @@ -15,7 +15,7 @@ final class IceBarColorManager: ObservableObject { private var cancellables = Set() - init(iceBarPanel: IceBarPanel) { + func performSetup(with iceBarPanel: IceBarPanel) { self.iceBarPanel = iceBarPanel configureCancellables() } @@ -38,37 +38,53 @@ 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) + .throttle(for: 0.1, scheduler: DispatchQueue.main, latest: true) + .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 .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 @@ -82,7 +98,9 @@ final class IceBarColorManager: ObservableObject { } updateWindowImage(for: screen) if iceBarPanel.isVisible { - updateColorInfo(with: iceBarPanel.frame, screen: screen) + withAnimation { + self.updateColorInfo(with: iceBarPanel.frame, screen: screen) + } } } .store(in: &c) @@ -92,38 +110,50 @@ final class IceBarColorManager: ObservableObject { } private func updateWindowImage(for screen: NSScreen) { + let windows = WindowInfo.createWindows(option: .onScreen) let displayID = screen.displayID - if - let window = WindowInfo.getMenuBarWindow(for: displayID), - let image = ScreenCapture.captureWindow(window.windowID, option: .nominalResolution) - { - windowImage = image - } else { - windowImage = nil + + guard + let menuBarWindow = WindowInfo.menuBarWindow(from: windows, for: displayID), + let wallpaperWindow = WindowInfo.wallpaperWindow(from: windows, for: displayID) + else { + return + } + + guard let image = ScreenCapture.captureWindows( + with: [menuBarWindow.windowID, wallpaperWindow.windowID], + screenBounds: withMutableCopy(of: wallpaperWindow.bounds) { $0.size.height = 1 }, + option: .nominalResolution + ) else { + return } + + windowImage = image } private func updateColorInfo(with frame: CGRect, screen: NSScreen) { - guard let windowImage else { - colorInfo = nil + guard let image = windowImage else { return } - let imageBounds = CGRect(x: 0, y: 0, width: windowImage.width, height: windowImage.height) + 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) + .insetBy(dx: -150, 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 } + // Just use `menuBarWindow` as the source for now, regardless + // of whether its image contributed to the average. colorInfo = MenuBarAverageColorInfo(color: averageColor, source: .menuBarWindow) } 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/MenuBar/LayoutBar/LayoutBar.swift b/Ice/MenuBar/LayoutBar/LayoutBar.swift new file mode 100644 index 000000000..f05f0973d --- /dev/null +++ b/Ice/MenuBar/LayoutBar/LayoutBar.swift @@ -0,0 +1,56 @@ +// +// LayoutBar.swift +// Ice +// + +import SwiftUI + +struct LayoutBar: View { + private struct Representable: NSViewRepresentable { + let appState: AppState + let section: MenuBarSection.Name + + func makeNSView(context: Context) -> LayoutBarScrollView { + LayoutBarScrollView(appState: appState, section: section) + } + + func updateNSView(_ nsView: LayoutBarScrollView, context: Context) { } + } + + @EnvironmentObject var appState: AppState + @ObservedObject var imageCache: MenuBarItemImageCache + + let section: MenuBarSection.Name + + private var backgroundShape: some InsettableShape { + if #available(macOS 26.0, *) { + RoundedRectangle(cornerRadius: 12, style: .continuous) + } else { + RoundedRectangle(cornerRadius: 9, style: .circular) + } + } + + var body: some View { + mainContent + .frame(height: 48) + .frame(maxWidth: .infinity) + .menuBarItemContainer(appState: appState) + .containerShape(backgroundShape) + .clipShape(backgroundShape) + .contentShape([.interaction, .focusEffect], backgroundShape) + .overlay { + backgroundShape + .strokeBorder(.quaternary) + } + } + + @ViewBuilder + 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/UI/LayoutBar/LayoutBarContainer.swift b/Ice/MenuBar/LayoutBar/LayoutBarContainer.swift similarity index 96% rename from Ice/UI/LayoutBar/LayoutBarContainer.swift rename to Ice/MenuBar/LayoutBar/LayoutBarContainer.swift index aa172beaf..e83eedd85 100644 --- a/Ice/UI/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/UI/LayoutBar/LayoutBarItemView.swift b/Ice/MenuBar/LayoutBar/LayoutBarItemView.swift similarity index 85% rename from Ice/UI/LayoutBar/LayoutBarItemView.swift rename to Ice/MenuBar/LayoutBar/LayoutBarItemView.swift index 74cb6f819..17b602e21 100644 --- a/Ice/UI/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/UI/LayoutBar/LayoutBarPaddingView.swift b/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift similarity index 60% rename from Ice/UI/LayoutBar/LayoutBarPaddingView.swift rename to Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift index beab4f766..eaee759d0 100644 --- a/Ice/UI/LayoutBar/LayoutBarPaddingView.swift +++ b/Ice/MenuBar/LayoutBar/LayoutBarPaddingView.swift @@ -5,27 +5,13 @@ import Cocoa import Combine +import OSLog /// A Cocoa view that manages the menu bar layout interface. 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 - /// 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 } @@ -36,25 +22,17 @@ 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) + 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), ]) @@ -97,18 +75,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(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 } - } - if let targetItem { - move(item: draggingSource.item, to: .leftOfItem(targetItem)) - } else { - Logger.layoutBar.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 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) + } + if let targetItem { + move(item: draggingSource.item, to: .leftOfItem(targetItem)) + } else { + 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 @@ -131,18 +111,13 @@ final class LayoutBarPaddingView: NSView { Task { try await Task.sleep(for: .milliseconds(25)) do { - try await appState.itemManager.slowMove(item: item, to: destination) - appState.itemManager.removeTempShownItemFromCache(with: item.info) + try await appState.itemManager.move(item: item, to: destination) + appState.itemManager.removeTemporarilyShownItemFromCache(with: item.tag) } 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/UI/LayoutBar/LayoutBarScrollView.swift b/Ice/MenuBar/LayoutBar/LayoutBarScrollView.swift similarity index 53% rename from Ice/UI/LayoutBar/LayoutBarScrollView.swift rename to Ice/MenuBar/LayoutBar/LayoutBarScrollView.swift index 6d5e0e6d8..9532c55cf 100644 --- a/Ice/UI/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,36 +23,22 @@ 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) + self.documentView = paddingView self.hasHorizontalScroller = true - self.horizontalScroller = HorizontalScroller() - - self.autohidesScrollers = true - + self.hasVerticalScroller = false self.verticalScrollElasticity = .none - self.horizontalScrollElasticity = .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), ]) } @@ -74,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 4d68bd44b..8c7140f1b 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItem.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItem.swift @@ -5,236 +5,370 @@ import Cocoa -// MARK: - MenuBarItem +/// A structural representation of a menu bar item. +struct MenuBarItem: CustomStringConvertible { + /// The tag associated with this item. + let tag: MenuBarItemTag -/// A representation of an item in the menu bar. -struct MenuBarItem { - /// The item's window. - let window: WindowInfo + /// 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? /// 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. + /// A Boolean value that indicates whether this item can be moved. var isMovable: Bool { - let immovableItems = Set(MenuBarItemInfo.immovableItems) - return !immovableItems.contains(info) + 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 { - let nonHideableItems = Set(MenuBarItemInfo.nonHideableItems) - return !nonHideableItems.contains(info) + tag.canBeHidden } - /// The process identifier of the application that owns the item. - var ownerPID: pid_t { - window.ownerPID + /// A Boolean value that indicates whether this item is one of Ice's + /// control items. + var isControlItem: Bool { + tag.isControlItem } - /// The name of the application that owns the item. - /// - /// This may have a value when ``owningApplication`` does not have - /// a localized name. - var ownerName: String? { - window.ownerName + /// 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 + /// Control Center. To get the actual application that created the + /// item, use ``sourceApplication``. var owningApplication: NSRunningApplication? { - window.owningApplication + NSRunningApplication(processIdentifier: ownerPID) } - /// A name associated with the item that is suited for display to - /// the user. + /// 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 + } + return NSRunningApplication(processIdentifier: sourcePID) + } + + // TODO: Generate this once, during initialization. + /// A name associated with the item, suited for display. var displayName: String { - var fallback: String { "Unknown" } - guard let owningApplication else { - return ownerName ?? title ?? fallback + /// 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]{2})([A-Z])/) { $0.output.1 + " " + $0.output.2 } } - var bestName: String { - owningApplication.localizedName ?? - ownerName ?? - owningApplication.bundleIdentifier ?? - fallback + + guard !isControlItem else { + return Constants.displayName } + + 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 } - // by default, use the application name, but handle a few special cases - return switch MenuBarItemInfo.Namespace(owningApplication.bundleIdentifier) { + + lazy var bestName = sourceName ?? title + + guard !isBentoBox else { + if tag == .controlCenter { + return bestName + } + return title + } + + // Most items use their computed "best name", but we handle + // a few special cases for system items. + let displayName = switch tag.namespace { + case .passwords, .weather, .textInputMenuAgent: + // "PasswordsMenuBarExtra" -> "Passwords" + // "WeatherMenu" -> "Weather" + // "TextInputMenuAgent" -> "Text Input" + toTitleCase(bestName.replacing(/Menu.*/, with: "")) 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 + if let match = title.prefixMatch(of: /Hearing/) { + // Changed from "Hearing" to "Hearing_GlowE" in macOS 15.4 + toTitleCase(match.output) + } else { + toTitleCase(title) } case .systemUIServer: - switch title { - case "TimeMachine.TMMenuExtraHost"/*Sonoma*/, "TimeMachineMenuExtra.TMMenuExtraHost"/*Sequoia*/: "Time Machine" - default: 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) } - case MenuBarItemInfo.Namespace("com.apple.Passwords.MenuBarExtra"): "Passwords" default: 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 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 { + "\(displayName) (\(tag))" } /// A string to use for logging purposes. var logString: String { - String(describing: info) + "<\(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.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) + 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.isOnScreen = itemWindow.isOnScreen } - /// Creates a menu bar item with the given window identifier. + /// Creates a menu bar item without checks. /// - /// 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) + /// 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.isOnScreen = itemWindow.isOnScreen } } -// 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 titlePredicate: (MenuBarItem) -> Bool = { _ in true } - var boundsPredicate: (CGWindowID) -> Bool = { _ in true } - - if onScreenOnly { - option.insert(.onScreen) - } - if activeSpaceOnly { - option.insert(.activeSpace) - titlePredicate = { $0.title != "" } - } + /// - 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.MenuBarWindowListOption = .itemsOnly + var displayBoundsPredicate: (CGWindowID) -> Bool = { _ in true } + if let display { + bridgingOption.insert(.onScreen) let displayBounds = CGDisplayBounds(display) - boundsPredicate = { windowID in - guard let windowFrame = Bridging.getWindowFrame(for: windowID) else { - return false - } - return displayBounds.intersects(windowFrame) + displayBoundsPredicate = { windowID in + Bridging.windowIntersectsDisplayBounds(windowID, displayBounds) } + } else if option.contains(.onScreen) { + bridgingOption.insert(.onScreen) + } + if option.contains(.activeSpace) { + bridgingOption.insert(.activeSpace) } - return Bridging.getWindowList(option: option).lazy - .filter(boundsPredicate) - .compactMap { windowID in - MenuBarItem(windowID: windowID) + return Bridging.getMenuBarWindowList(option: bridgingOption) + .reversed().compactMap { windowID in + guard + displayBoundsPredicate(windowID), + let window = WindowInfo(windowID: windowID) + else { + return nil + } + return window } - .filter(titlePredicate) - .sortedByOrderInMenuBar() + } + + /// 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) 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 + /// 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. + /// + /// - 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) async -> [MenuBarItem] { + if #available(macOS 26.0, *) { + await getMenuBarItemsExperimental(on: display, option: option) + } else { + getMenuBarItemsLegacyMethod(on: display, option: option) + } } } // 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.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(isOnScreen) + } +} + +// MARK: - MenuBarItemTag Helper + +private extension MenuBarItemTag { + /// 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: MenuBarItemInfo Unchecked Item Window Initializer -private extension MenuBarItemInfo { - /// Creates a simplified item from the given window. +// MARK: - MenuBarItemTag.Namespace Helper + +private extension MenuBarItemTag.Namespace { + private static var uuidCache = [CGWindowID: UUID]() + + /// 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) { - if let bundleIdentifier = itemWindow.owningApplication?.bundleIdentifier { - self.namespace = Namespace(bundleIdentifier) + // 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 = .optional(app.bundleIdentifier ?? itemWindow.ownerName ?? app.localizedName) } else { - self.namespace = .null + self = .optional(itemWindow.ownerName) } - if let title = itemWindow.title { - self.title = title + } + + /// 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 = .optional(app.bundleIdentifier ?? app.localizedName) + } else if let uuid = Self.uuidCache[itemWindow.windowID] { + self = .uuid(uuid) } else { - self.title = "" + 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 8b13e895e..fb51a94ed 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemImageCache.swift @@ -5,17 +5,52 @@ import Cocoa import Combine +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]() - /// The screen of the cached item images. - private(set) var screen: NSScreen? + /// Logger for the menu bar item image cache. + private let logger = Logger(category: "MenuBarItemImageCache") - /// The height of the menu bar of the cached item images. - private(set) var menuBarHeight: CGFloat? + /// Queue to run cache operations. + private let queue = DispatchQueue(label: "MenuBarItemImageCache", qos: .background) + + /// Image capture options. + private let captureOption: CGWindowImageOption = [.boundsIgnoreFraming, .bestResolution] /// The shared app state. private weak var appState: AppState? @@ -23,14 +58,12 @@ 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 - } + // MARK: Setup /// Sets up the cache. @MainActor - func performSetup() { + func performSetup(with appState: AppState) { + self.appState = appState configureCancellables() } @@ -42,19 +75,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) @@ -62,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) @@ -74,150 +105,168 @@ final class MenuBarItemImageCache: ObservableObject { cancellables = c } - /// Logs a reason for skipping the cache. - private func logSkippingCache(reason: String) { - Logger.imageCache.debug("Skipping menu bar item image cache as \(reason)") - } + // 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(with: 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 + } - var images = [MenuBarItemInfo: CGImage]() - let backingScaleFactor = screen.backingScaleFactor - let displayBounds = CGDisplayBounds(screen.displayID) - let option: CGWindowImageOption = [.boundsIgnoreFraming, .bestResolution] - let defaultItemThickness = NSStatusBar.system.thickness * backingScaleFactor + 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 + ) - var itemInfos = [CGWindowID: MenuBarItemInfo]() - var itemFrames = [CGWindowID: CGRect]() - var windowIDs = [CGWindowID]() - var frame = CGRect.null + guard + let image = compositeImage.cropping(to: cropRect), + !image.isTransparent() + else { + result.excluded.append(item) + continue + } + + result.images[item.tag] = CapturedImage(cgImage: image, scale: scale) + } + + 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 - // Use the most up-to-date window frame. - let itemFrame = Bridging.getWindowFrame(for: windowID), - itemFrame.minY == displayBounds.minY + let image = ScreenCapture.captureWindow(with: item.windowID, option: captureOption), + !image.isTransparent() else { + result.excluded.append(item) continue } - itemInfos[windowID] = item.info - itemFrames[windowID] = itemFrame - windowIDs.append(windowID) - frame = frame.union(itemFrame) + result.images[item.tag] = CapturedImage(cgImage: image, scale: scale) } - if - let compositeImage = ScreenCapture.captureWindows(windowIDs, option: option), - CGFloat(compositeImage.width) == frame.width * backingScaleFactor - { - for windowID in windowIDs { - guard - let itemInfo = itemInfos[windowID], - let itemFrame = itemFrames[windowID] - else { - continue - } + return result + } - 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 - ) + /// 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 { + // Use individual capture after a move operation, since composite capture + // doesn't account for overlapping items. + if await appState.itemManager.lastMoveOperationOccurred(within: .seconds(2)) { + logger.debug("Capturing individually due to recent item movement") + return individualCapture(items, scale: scale) + } - guard let itemImage = compositeImage.cropping(to: frame) else { - continue - } + let compositeResult = compositeCapture(items, scale: scale) - images[itemInfo] = itemImage - } - } else { - Logger.imageCache.warning("Composite image capture failed. Attempting to capturing items individually.") - - for windowID in windowIDs { - guard - let itemInfo = itemInfos[windowID], - let itemFrame = itemFrames[windowID] - else { - continue - } + if compositeResult.excluded.isEmpty { + return compositeResult // All items captured successfully. + } - let frame = CGRect( - x: 0, - y: ((itemFrame.height * backingScaleFactor) / 2) - (defaultItemThickness / 2), - width: itemFrame.width * backingScaleFactor, - height: defaultItemThickness - ) + logger.notice( + """ + Some items were excluded from composite capture. Attempting to capture \ + excluded items individually: \(compositeResult.excluded, privacy: .public) + """ + ) - guard - let itemImage = ScreenCapture.captureWindow(windowID, option: option), - let croppedImage = itemImage.cropping(to: frame) - else { - continue - } + var individualResult = individualCapture(compositeResult.excluded, scale: scale) - images[itemInfo] = croppedImage - } - } + // 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 individualResult + } - return images + /// 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, scale: CGFloat, appState: AppState) async -> [MenuBarItemTag: CapturedImage] { + let items = await appState.itemManager.itemCache.managedItems(for: section) + 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 } - /// 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, - let screen = NSScreen.main + await appState.hasPermission(.screenRecording) else { return } - var newImages = [MenuBarItemInfo: CGImage]() + 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 { guard await !appState.itemManager.itemCache[section].isEmpty else { continue } - let sectionImages = await createImages(for: section, screen: screen) + + let sectionImages = await captureImages(for: section, scale: scale, appState: appState) + guard !sectionImages.isEmpty else { - Logger.imageCache.warning("Update image cache failed for \(section.logString)") + logger.warning("Failed item image cache for \(section.logString, privacy: .public)") 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. @@ -230,27 +279,17 @@ final class MenuBarItemImageCache: ObservableObject { let isSearchPresented = await appState.navigationState.isSearchPresented if !isIceBarPresented && !isSearchPresented { - guard await appState.navigationState.isAppFrontmost else { - logSkippingCache(reason: "Ice Bar not visible, app not frontmost") - return - } - guard await appState.navigationState.isSettingsPresented else { - logSkippingCache(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") + guard + await appState.navigationState.isAppFrontmost, + await appState.navigationState.isSettingsPresented, + await appState.navigationState.settingsNavigationIdentifier == .menuBarLayout + else { 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") + guard await !appState.itemManager.lastMoveOperationOccurred(within: .seconds(1)) else { + logger.debug("Skipping item image cache due to recent item movement") return } @@ -268,6 +307,7 @@ final class MenuBarItemImageCache: ObservableObject { let isSettingsPresented = await appState.navigationState.isSettingsPresented var sectionsNeedingDisplay = [MenuBarSection.Name]() + if isSettingsPresented || isSearchPresented { sectionsNeedingDisplay = MenuBarSection.Name.allCases } else if @@ -279,10 +319,24 @@ final class MenuBarItemImageCache: ObservableObject { await updateCache(sections: sectionsNeedingDisplay) } -} -// MARK: - Logger + // MARK: Cache Failed -private extension Logger { - static let imageCache = Logger(category: "MenuBarItemImageCache") + /// 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/MenuBarItemInfo.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemInfo.swift deleted file mode 100644 index 405a35cae..000000000 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemInfo.swift +++ /dev/null @@ -1,220 +0,0 @@ -// -// MenuBarItemInfo.swift -// Ice -// - -/// A simplified version of a menu bar item. -struct MenuBarItemInfo: Hashable, CustomStringConvertible { - /// The namespace of the item. - let namespace: Namespace - - /// 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 - } - - var description: String { - namespace.rawValue + ":" + title - } - - /// Creates a simplified item with the given namespace and title. - init(namespace: Namespace, title: String) { - self.namespace = namespace - self.title = title - } -} - -// MARK: MenuBarItemInfo Constants -extension MenuBarItemInfo { - /// An array of items whose movement is prevented by macOS. - static let immovableItems = [clock, siri, controlCenter] - - /// 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" - ) -} - -// MARK: MenuBarItemInfo: Codable -extension MenuBarItemInfo: 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([namespace.rawValue, title].joined(separator: ":")) - } -} - -// MARK: - MenuBarItemInfo.Namespace - -extension MenuBarItemInfo { - /// 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 - } - } - - /// 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) - } - - /// 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 to initialize the namespace with. - init(_ value: String?) { - self = value.map { Namespace($0) } ?? .null - } - } -} - -// MARK: MenuBarItemInfo.Namespace Constants -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. - static let controlCenter = Self("com.apple.controlcenter") - - /// The namespace for menu bar items owned by the System UI Server. - static let systemUIServer = Self("com.apple.systemuiserver") - - /// The namespace for special items. - static let special = Self("Special") - - /// 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..de9f636e3 100644 --- a/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -5,178 +5,58 @@ import Cocoa import Combine +import OSLog +import Semaphore /// 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. - guard item.canBeHidden else { - 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 - } - } - - return true - } - } + /// The current cache of menu bar items. + @Published private(set) var itemCache = ItemCache(displayID: nil) - /// 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 }) { - return section - } - return nil - } + /// Logger for the menu bar item manager. + private nonisolated let logger = Logger.menuBarItemManager - /// Accesses the items in the given section. - subscript(section: MenuBarSection.Name) -> [MenuBarItem] { - get { items[section, default: []] } - set { items[section] = newValue } - } - } + /// Semaphore to prevent overlapping event operations. + private nonisolated let eventSemaphore = AsyncSemaphore(value: 1) - /// Context for a temporarily shown menu bar item. - private struct TempShownItemContext { - /// The information associated with the item. - let info: MenuBarItemInfo + /// Actor for managing menu bar item cache operations. + private let cacheActor = CacheActor() - /// The destination to return the item to. - let returnDestination: MoveDestination + /// Contexts for temporarily shown menu bar items. + private var temporarilyShownItemContexts = [TemporarilyShownItemContext]() - /// The window of the item's shown interface. - let shownInterfaceWindow: WindowInfo? + /// A timer for rehiding temporarily shown menu bar items. + private var rehideTimer: Timer? - /// 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 - } - } - } + /// Timestamp of the most recent menu bar item move operation. + private var lastMoveOperationTimestamp: ContinuousClock.Instant? - /// The manager's menu bar item cache. - @Published private(set) var itemCache = ItemCache() - - /// The shared app state. - private(set) weak var appState: AppState? + /// Cached timeouts for move operations. + private var moveOperationTimeouts = [MenuBarItemTag: Duration]() /// Storage for internal observers. private var cancellables = Set() - /// Cached window identifiers for the most recent items. - private var cachedItemWindowIDs = [CGWindowID]() - - /// Context values for the current temporarily shown items. - private var tempShownItemContexts = [TempShownItemContext]() - - /// A timer that determines when to rehide the temporarily shown items. - private var tempShownItemsTimer: Timer? - - /// 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 { - itemMoveCount > 0 - } - - /// 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 - } - - /// 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 - } + /// The shared app state. + private(set) weak var appState: AppState? /// Sets up the manager. - func performSetup() { - configureCancellables() + func performSetup(with appState: AppState) async { + self.appState = appState + await cacheItemsRegardless() + 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 + 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 } @@ -186,883 +66,1004 @@ final class MenuBarItemManager: ObservableObject { } .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) - 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 } + + /// 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 + } } -// MARK: - Cache Items +// MARK: - Item Cache 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") - } + /// 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.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 + } - /// Logs a reason for skipping the cache. - private func logSkippingCache(reason: String) { - Logger.itemManager.debug("Skipping menu bar item cache as \(reason)") + /// Clears the list of cached menu bar item window identifiers. + func clearCachedItemWindowIDs() { + cachedItemWindowIDs.removeAll() + } } - /// 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] - ) { - Logger.itemManager.debug("Caching menu bar items") - - let predicates = Predicates.sectionPredicates( - hiddenControlItem: hiddenControlItem, - alwaysHiddenControlItem: alwaysHiddenControlItem - ) + /// Cache for menu bar items. + struct ItemCache: Hashable { + /// Storage for cached menu bar items, keyed by section. + private var storage = [MenuBarSection.Name: [MenuBarItem]]() - var cache = ItemCache() - var tempShownItems = [(MenuBarItem, MoveDestination)]() - - for item in otherItems { - if let context = tempShownItemContexts.first(where: { $0.info == item.info }) { - // 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 { - logNotCachedWarning(for: item) + /// 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 + guard let items = storage[section] else { + return + } + result.append(contentsOf: items) } } - for (item, destination) in tempShownItems { - switch destination { - case .leftOfItem(let targetItem): - switch targetItem.info { - case .hiddenControlItem: - cache[.hidden].append(item) - case .alwaysHiddenControlItem: - cache[.alwaysHidden].append(item) - default: - if - 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) - } - } - case .rightOfItem(let targetItem): - switch targetItem.info { - case .hiddenControlItem: - cache[.visible].insert(item, at: 0) - case .alwaysHiddenControlItem: - cache[.hidden].insert(item, at: 0) - default: - if - 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) - } + /// 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 } - itemCache = cache - } + /// 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 - /// 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") - return - } catch { - guard !itemHasRecentlyMoved else { - logSkippingCache(reason: "an item was recently moved") + if targetTag == .hiddenControlItem { + switch destination { + case .leftOfItem: + self[.hidden].append(item) + case .rightOfItem: + self[.visible].insert(item, at: 0) + } return } - } - let itemWindowIDs = Bridging.getWindowList(option: [.menuBarItems, .activeSpace]) - if cachedItemWindowIDs == itemWindowIDs { - logSkippingCache(reason: "item windows have not changed") - return - } else { - cachedItemWindowIDs = itemWindowIDs - } + if targetTag == .alwaysHiddenControlItem { + switch destination { + case .leftOfItem: + self[.alwaysHidden].append(item) + case .rightOfItem: + self[.hidden].insert(item, at: 0) + } + return + } - var items = MenuBarItem.getMenuBarItems(onScreenOnly: false, activeSpaceOnly: true) + guard case (let section, var index)? = address(for: targetTag) else { + return + } - let hiddenControlItem = items.firstIndex(matching: .hiddenControlItem).map { items.remove(at: $0) } - let alwaysHiddenControlItem = items.firstIndex(matching: .alwaysHiddenControlItem).map { items.remove(at: $0) } + if case .rightOfItem = destination { + let range = self[section].startIndex...self[section].endIndex + index = (index + 1).clamped(to: range) + } - guard let hiddenControlItem else { - Logger.itemManager.warning("Missing control item for hidden section") - Logger.itemManager.debug("Clearing menu bar item cache") - itemCache.clear() - return + self[section].insert(item, at: index) } - do { - if let alwaysHiddenControlItem { - try await enforceControlItemOrder( - hiddenControlItem: hiddenControlItem, - alwaysHiddenControlItem: alwaysHiddenControlItem - ) - } - uncheckedCacheItems( - hiddenControlItem: hiddenControlItem, - alwaysHiddenControlItem: alwaysHiddenControlItem, - otherItems: items - ) - } catch { - Logger.itemManager.error("Error enforcing control item order: \(error)") - Logger.itemManager.debug("Clearing menu bar item cache") - itemCache.clear() + /// Accesses the items in the given section. + subscript(section: MenuBarSection.Name) -> [MenuBarItem] { + get { storage[section, default: []] } + set { storage[section] = newValue } } } -} -// MARK: - Menu Bar Item Events - + /// 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? -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 frame check timed out. - case frameCheckTimeout - - /// 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 .frameCheckTimeout: "frameCheckTimeout" - case .otherTimeout: "otherTimeout" - } + init?(items: inout [MenuBarItem]) { + guard let hidden = items.removeFirst(matching: .hiddenControlItem) else { + return nil } + self.hidden = hidden + self.alwaysHidden = items.removeFirst(matching: .alwaysHiddenControlItem) + } + } + + /// Context maintained during a menu bar item cache operation. + private struct CacheContext { + let controlItems: ControlItemPair + + var cache: ItemCache + var temporarilyShownItems = [(MenuBarItem, MoveDestination)]() + var shouldClearCachedItemWindowIDs = false - /// A string to use for logging purposes. - var logString: String { - "\(self) (rawValue: \(rawValue))" + 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) + } + + func bestBounds(for item: MenuBarItem) -> CGRect { + 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 + } + return true + } + + mutating func findSection(for item: MenuBarItem) -> MenuBarSection.Name? { + lazy var itemBounds = bestBounds(for: item) + 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 + } + } } } + } - /// The error code of this error. - let code: ErrorCode + /// 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? + ) async { + var context = CacheContext(controlItems: controlItems, displayID: displayID) + + for item in items where context.isValidForCaching(item) { + if item.sourcePID == nil { + logger.warning("Missing sourcePID for \(item.logString, privacy: .public)") + context.shouldClearCachedItemWindowIDs = true + } - /// The error's menu bar item. - let item: MenuBarItem + 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.temporarilyShownItems.append((item, temp.returnDestination)) + continue + } - /// 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 .frameCheckTimeout: - "Frame check timed out for \"\(item.displayName)\"" - case .otherTimeout: - "Operation timed out for \"\(item.displayName)\"" + if let section = context.findSection(for: item) { + context.cache[section].append(item) + continue } - } - /// 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: ", ")))" + logger.warning("Couldn't find section for caching \(item.logString, privacy: .public)") + context.shouldClearCachedItemWindowIDs = true } - /// Description of the error for display purposes. - var errorDescription: String? { - message + for (item, destination) in context.temporarilyShownItems { + context.cache.insert(item, at: destination) } - /// Suggestion for recovery from the error. - var recoverySuggestion: String? { - "Please try again. If the error persists, please file a bug report." + if context.shouldClearCachedItemWindowIDs { + logger.info("Clearing cached menu bar item windowIDs") + await cacheActor.clearCachedItemWindowIDs() // Ensure next cache isn't skipped. } - } -} - -// MARK: - Async Waiters -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. - private func waitWithTask(timeout: Duration?, operation: @escaping @Sendable () async throws -> Void) async throws { - let task = if let timeout { - Task(timeout: timeout, operation: operation) - } else { - Task(operation: operation) + guard itemCache != context.cache else { + logger.debug("Not updating menu bar item cache, as items haven't changed") + return } - try await task.value + + itemCache = context.cache + logger.debug("Updated menu bar item cache") } - /// Waits asynchronously for all menu bar items to stop moving. + /// Caches the current menu bar items, regardless of whether the + /// items have changed since the previous cache. /// - /// - 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 + /// 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 } - while await isMovingItem { - try Task.checkCancellation() - try await Task.sleep(for: .milliseconds(10)) - } - } - } - /// 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 { + guard !lastMoveOperationOccurred(within: .seconds(1)) else { + logger.debug("Skipping menu bar item cache due to recent item movement") return } - while true { - try Task.checkCancellation() - guard let date = await lastMouseMoveStartDate else { - break - } - if Date.now.timeIntervalSince(date) > threshold { - break - } - try await Task.sleep(for: .milliseconds(10)) - } - } - } - /// Waits asynchronously until no modifier keys are pressed. - /// - /// - Parameter timeout: Amount of time to wait before throwing an error. - func waitForNoModifiersPressed(timeout: Duration? = nil) async throws { - try await waitWithTask(timeout: timeout) { - // Return early if no flags are pressed. - if NSEvent.modifierFlags.isEmpty { + let displayID = Bridging.getActiveMenuBarDisplayID() + var items = await MenuBarItem.getMenuBarItems(option: .activeSpace) + + 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") + itemCache = ItemCache(displayID: nil) return } - var cancellable: AnyCancellable? + await enforceControlItemOrder(controlItems: controlItems) + await uncheckedCacheItems(items: items, controlItems: controlItems, displayID: displayID) + } + } - await withCheckedContinuation { continuation in - cancellable = Publishers.Merge( - UniversalEventMonitor.publisher(for: .flagsChanged), - RunLoopLocalEventMonitor.publisher(for: .flagsChanged, mode: .eventTracking) - ) - .removeDuplicates() - .sink { _ in - if NSEvent.modifierFlags.isEmpty { - cancellable?.cancel() - continuation.resume() - } - } - } + /// 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 { + let itemWindowIDs = Bridging.getMenuBarWindowList(option: [.itemsOnly, .activeSpace]) + if await cacheActor.cachedItemWindowIDs != itemWindowIDs { + await cacheItemsRegardless(itemWindowIDs) } } } -// MARK: - Move Items +// MARK: - Event Helpers extension MenuBarItemManager { - /// A destination that a menu bar item can be moved to. - enum MoveDestination { - /// The menu bar item will be moved to the left of the given menu bar item. - case leftOfItem(MenuBarItem) + /// 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) - /// The menu bar item will be moved to the right of the given menu bar item. - case rightOfItem(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))" + } + } - /// A string to use for logging purposes. - var logString: String { + var errorDescription: String? { switch self { - case .leftOfItem(let item): "left of \(item.logString)" - case .rightOfItem(let item): "right of \(item.logString)" + 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)\"" } } - } - /// Returns the current frame 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)") - return nil + var recoverySuggestion: String? { + if case .itemNotMovable = self { return nil } + return "Please try again. If the error persists, please file a bug report." } - return frame } - /// Returns the end point for moving an item to the given destination. + /// Returns a Boolean value that indicates whether the user has + /// paused input for at least the given duration. /// - /// - 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 currentFrame = getCurrentFrame(for: targetItem) else { - throw EventError(code: .invalidItem, item: targetItem) - } - return CGPoint(x: currentFrame.minX, y: currentFrame.midY) - case .rightOfItem(let targetItem): - guard let currentFrame = getCurrentFrame(for: targetItem) else { - throw EventError(code: .invalidItem, item: targetItem) + /// - 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 user to pause input. + private nonisolated func waitForUserToPauseInput() async throws { + let waitTask = Task { + while true { + try Task.checkCancellation() + if hasUserPausedInput(for: .milliseconds(50)) { + break + } + try await Task.sleep(for: .milliseconds(250)) } - return CGPoint(x: currentFrame.maxX, y: currentFrame.midY) + } + do { + try await waitTask.value + } catch { + throw EventError.cannotComplete } } - /// 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 currentFrame = getCurrentFrame(for: item) else { - throw EventError(code: .invalidItem, item: item) + /// 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 + } } - return CGPoint(x: currentFrame.midX, y: currentFrame.midY) } - /// Returns the target item for the given destination. + /// Waits for the given duration between event operations. /// - /// - 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 + /// 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 { + let task = Task { + try? await Task.sleep(for: duration) } + await task.value } - /// 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 currentFrame = getCurrentFrame(for: item) else { - throw EventError(code: .invalidItem, item: item) - } - switch destination { - case .leftOfItem(let targetItem): - guard let currentTargetFrame = getCurrentFrame(for: targetItem) else { - throw EventError(code: .invalidItem, item: targetItem) - } - return currentFrame.maxX == currentTargetFrame.minX - case .rightOfItem(let targetItem): - guard let currentTargetFrame = getCurrentFrame(for: targetItem) else { - throw EventError(code: .invalidItem, item: targetItem) + /// 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 = Bridging.getWindowBounds(for: item.windowID) else { + throw EventError.missingItemBounds(item) } - return currentFrame.minX == currentTargetFrame.maxX + return bounds } + return try await task.value } - /// 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 - } + /// Returns the current mouse location. + private nonisolated func getMouseLocation() throws -> CGPoint { + guard let location = MouseHelpers.locationCoreGraphics else { + throw EventError.missingMouseLocation } - return true + return location } - /// 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.itemManager.debug("Posting \(event.type.logString) to \(location.logString)") - 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) + /// 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 an event source for a menu bar item event operation. + private nonisolated func getEventSource( + with stateID: CGEventSourceStateID = .hidSystemState + ) throws -> CGEventSource { + enum Context { + static var cache = [CGEventSourceStateID: CGEventSource]() + } + if let source = Context.cache[stateID] { + return source } + guard let source = CGEventSource(stateID: stateID) else { + throw EventError.invalidEventSource + } + Context.cache[stateID] = source + return source } - /// Posts an event to the given event tap location and waits until it is - /// received before returning. + /// 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 = 0 + } + + /// Posts an event to the given menu bar item 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 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, - to location: EventTap.Location, - item: MenuBarItem + to item: MenuBarItem, + timeout: Duration, + repeating count: Int = 1 ) 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 - } - - // Reenable the tap if disabled by the system. - if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { - proxy.enable() - return nil - } + MouseHelpers.hideCursor() + defer { + MouseHelpers.showCursor() + } - // Verify that the received event was the sent event. - guard eventsMatch([rEvent, event], by: CGEventField.menuBarItemEventFields) else { - return nil + guard + let entryEvent = CGEvent.uniqueNullEvent(), + let exitEvent = CGEvent.uniqueNullEvent() + else { + throw EventError.eventCreationFailure(item) + } + + let pid = getEventPID(for: item) + event.setTargetPID(pid) + + let firstLocation = EventTap.Location.pid(pid) + 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 (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", + type: .null, + location: firstLocation, + placement: .headInsertEventTap, + option: .defaultTap + ) { tap, rEvent in + if rEvent.matches(entryEvent, byIntegerFields: [.eventSourceUserData]) { + count -= 1 + event.post(to: secondLocation) + return nil + } + if rEvent.matches(exitEvent, byIntegerFields: [.eventSourceUserData]) { + tap.disable() + continuation.resume() + return nil + } + return rEvent } - // 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))") - return nil + // 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, + location: secondLocation, + placement: .tailAppendEventTap, + 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 } - Logger.itemManager.debug("Received \(type.logString) at \(location.logString) (item: \(item.logString))") + // Keep the taps alive. + eventTaps.append(eventTap1) + eventTaps.append(eventTap2) - // Disable the tap and resume the continuation. - proxy.disable() - continuation.resume() - - return nil - } - - eventTap.enable(timeout: .milliseconds(50)) { - Logger.itemManager.error("Event tap \"\(eventTap.label)\" timed out (item: \(item.logString))") - eventTap.disable() - continuation.resume(throwing: EventError(code: .eventOperationTimeout, item: item)) + Task { + await withTaskCancellationHandler { + eventTap1.enable() + eventTap2.enable() + entryEvent.post(to: firstLocation) + } onCancel: { + eventTap1.disable() + eventTap2.disable() + continuation.resume(throwing: CancellationError()) + } + } } - - // Post the event to the location. - postEvent(event, to: location) + } + do { + try await timeoutTask.value + } catch is TaskTimeoutError { + throw EventError.eventOperationTimeout(item) + } catch { + throw EventError.cannotComplete } } - /// 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 and + /// respond to an event during a move operation. /// /// - 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. - private func scrombleEvent( + /// - 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, - from firstLocation: EventTap.Location, - to secondLocation: EventTap.Location, - item: MenuBarItem + item: MenuBarItem, + timeout: Duration, + repeating count: Int = 1 ) async throws { - // Create a null event and assign it unique user data. - guard let nullEvent = CGEvent(source: nil) 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 - } + MouseHelpers.hideCursor() + defer { + MouseHelpers.showCursor() + } - // Reenable the tap if disabled by the system. - if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { - proxy.enable() - return nil + guard + let entryEvent = CGEvent.uniqueNullEvent(), + let exitEvent = CGEvent.uniqueNullEvent() + else { + throw EventError.eventCreationFailure(item) + } + + let pid = getEventPID(for: item) + event.setTargetPID(pid) + + let firstLocation = EventTap.Location.pid(pid) + 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 (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", + type: .null, + location: firstLocation, + placement: .headInsertEventTap, + option: .defaultTap + ) { tap, rEvent in + if rEvent.matches(entryEvent, byIntegerFields: [.eventSourceUserData]) { + count -= 1 + event.post(to: secondLocation) + return nil + } + if rEvent.matches(exitEvent, byIntegerFields: [.eventSourceUserData]) { + tap.disable() + continuation.resume() + return nil + } + return rEvent } - // Verify that this is the null event. - guard rEvent.getIntegerValueField(.eventSourceUserData) == nullUserData else { - return nil + // 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, + 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) + rEvent.setTargetPID(pid) + return rEvent } - // Disable the tap and post the real event to the second location. - proxy.disable() - postEvent(event, to: secondLocation) - - return nil - } - - // 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 + // 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 } - // Reenable the tap if disabled by the system. - if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout { - proxy.enable() - return nil - } + // Keep the taps alive. + eventTaps.append(eventTap1) + eventTaps.append(eventTap2) + eventTaps.append(eventTap3) - // Verify that the received event was the sent event. - guard eventsMatch([rEvent, event], by: CGEventField.menuBarItemEventFields) else { - return nil + Task { + await withTaskCancellationHandler { + eventTap1.enable() + eventTap2.enable() + eventTap3.enable() + entryEvent.post(to: firstLocation) + } onCancel: { + eventTap1.disable() + eventTap2.disable() + eventTap3.disable() + continuation.resume(throwing: CancellationError()) + } } + } + } + do { + try await timeoutTask.value + } catch is TaskTimeoutError { + throw EventError.eventOperationTimeout(item) + } catch { + throw EventError.cannotComplete + } + } +} - // 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))") - return nil - } +// MARK: - Moving Items - // Disable the tap, post the event to the first location, and resume - // the continuation. - proxy.disable() - postEvent(event, to: firstLocation) - continuation.resume() +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) - return nil + /// The destination's target item. + var targetItem: MenuBarItem { + switch self { + case .leftOfItem(let item), .rightOfItem(let item): item } + } - // 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))") - eventTap1.disable() - eventTap2.disable() - continuation.resume(throwing: EventError(code: .eventOperationTimeout, item: item)) + /// 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)" } + } + } - // Post the null event to the first location. - postEvent(nullEvent, to: firstLocation) + /// 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) } - /// 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. - /// - /// - 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. - private func scrombleEvent( - _ event: CGEvent, - from firstLocation: EventTap.Location, - to secondLocation: EventTap.Location, - waitingForFrameChangeOf item: MenuBarItem - ) async throws { - guard let currentFrame = getCurrentFrame(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") - // This will be slow, but subsequent events will have a better chance of succeeding. - try await Task.sleep(for: .milliseconds(50)) - return + /// 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 } - try await scrombleEvent(event, from: firstLocation, to: secondLocation, item: item) - try await waitForFrameChange(of: item, initialFrame: currentFrame, timeout: .milliseconds(50)) + return getDefaultMoveOperationTimeout(for: item) } - /// Waits for a menu bar item's frame to change from an initial frame. - /// - /// - Parameters: - /// - item: The item whose frame should be observed. - /// - initialFrame: An initial frame to compare the item's frame 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 { } + /// Updates the cached timeout for move operations associated + /// with the given item. + private func updateMoveOperationTimeout(_ timeout: Duration, for item: MenuBarItem) { + let current = getMoveOperationTimeout(for: item) + let average = (timeout + current) / 2 + let clamped = average.clamped(min: .milliseconds(25), max: .milliseconds(150)) + moveOperationTimeouts[item.tag] = clamped + } - let frameCheckTask = Task(timeout: timeout) { - while true { - try Task.checkCancellation() - guard let currentFrame = await self.getCurrentFrame(for: item) else { - throw FrameCheckCancellationError() - } - if currentFrame != initialFrame { - Logger.itemManager.debug("Menu bar item frame for \(item.logString) has changed to \(NSStringFromRect(currentFrame))") - return - } + /// 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: + 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 } - } - 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") - // This will be slow, but subsequent events will have a better chance of succeeding. - try await Task.sleep(for: .milliseconds(50)) - } catch is TaskTimeoutError { - throw EventError(code: .frameCheckTimeout, item: item) + return (start, end) + case .rightOfItem: + 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) } } - /// 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) + /// 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 { + case .leftOfItem: itemBounds.maxX == targetBounds.minX + case .rightOfItem: itemBounds.minX == targetBounds.maxX } - 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.itemManager.debug("Attempting to wake up \(item.logString)") - - guard let source = CGEventSource(stateID: .hidSystemState) else { - throw EventError(code: .invalidEventSource, item: item) + /// Waits for a menu bar item to respond to a series of previously + /// posted move events. + /// + /// - Parameters: + /// - 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 waitForMoveEventResponse( + from item: MenuBarItem, + initialOrigin: CGPoint, + timeout: Duration + ) async throws -> CGPoint { + MouseHelpers.hideCursor() + defer { + MouseHelpers.showCursor() } - guard let currentFrame = getCurrentFrame(for: item) else { - throw EventError(code: .invalidItem, item: item) + let responseTask = Task.detached { + while true { + try Task.checkCancellation() + let origin = try await self.getCurrentBounds(for: item).origin + if origin != initialOrigin { + return origin + } + } } - - guard - let mouseDownEvent = CGEvent.menuBarItemEvent( - type: .move(.leftMouseDown), - location: CGPoint(x: currentFrame.midX, y: currentFrame.midY), - item: item, - pid: item.ownerPID, - source: source - ), - let mouseUpEvent = CGEvent.menuBarItemEvent( - type: .move(.leftMouseUp), - location: CGPoint(x: currentFrame.midX, y: currentFrame.midY), - item: item, - pid: item.ownerPID, - source: source + let timeoutTask = Task(timeout: timeout) { + try await withTaskCancellationHandler { + try await responseTask.value + } onCancel: { + responseTask.cancel() + } + } + do { + let origin = try await timeoutTask.value + logger.debug( + """ + Item responded to events with new origin: \ + \(String(describing: origin), privacy: .public) + """ ) - else { - throw EventError(code: .eventCreationFailure, item: item) + return origin + } catch let error as EventError { + throw error + } catch is TaskTimeoutError { + throw EventError.itemResponseTimeout(item) + } catch { + throw EventError.cannotComplete } - - try await scrombleEvent( - mouseDownEvent, - from: .pid(item.ownerPID), - to: .sessionEventTap, - item: item - ) - try await scrombleEvent( - mouseUpEvent, - from: .pid(item.ownerPID), - to: .sessionEventTap, - item: item - ) } - /// Moves a menu bar item to the given destination, without restoring the mouse - /// pointer to its initial location. + /// Creates and posts a series of events to move 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. - private func moveItemWithoutRestoringMouseLocation(_ item: MenuBarItem, to destination: MoveDestination) async throws { - itemMoveCount += 1 + /// - item: The menu bar item to move. + /// - destination: The destination to move the menu bar item. + private func postMoveEvents(item: MenuBarItem, destination: MoveDestination) async throws { + try await eventSemaphore.waitUnlessCancelled() defer { - itemMoveCount -= 1 + eventSemaphore.signal() } - guard item.isMovable else { - throw EventError(code: .notMovable, item: item) - } - guard let source = CGEventSource(stateID: .hidSystemState) else { - throw EventError(code: .invalidEventSource, item: item) - } + var itemOrigin = try await getCurrentBounds(for: item).origin + let targetPoints = try await getTargetPoints(forMoving: item, to: destination) + let mouseLocation = try getMouseLocation() + let source = try getEventSource() - 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) + try permitLocalEvents() guard - let mouseDownEvent = CGEvent.menuBarItemEvent( - type: .move(.leftMouseDown), - location: startPoint, + let mouseDown = CGEvent.menuBarItemEvent( item: item, - pid: item.ownerPID, - source: source + source: source, + type: .move(.mouseDown), + location: targetPoints.start ), - let mouseUpEvent = CGEvent.menuBarItemEvent( - type: .move(.leftMouseUp), - location: endPoint, - item: targetItem, - pid: item.ownerPID, - source: source - ), - let fallbackEvent = CGEvent.menuBarItemEvent( - type: .move(.leftMouseUp), - location: fallbackPoint, - item: item, - pid: item.ownerPID, - source: source + let mouseUp = CGEvent.menuBarItemEvent( + item: destination.targetItem, + source: source, + type: .move(.mouseUp), + location: targetPoints.end ) else { - throw EventError(code: .eventCreationFailure, item: item) + throw EventError.eventCreationFailure(item) } - try permitAllEvents( - for: .combinedSessionState, - during: [ - .eventSuppressionStateRemoteMouseDrag, - .eventSuppressionStateSuppressionInterval, - ], - suppressionInterval: 0, - item: item - ) + var timeout = getMoveOperationTimeout(for: item) + logger.debug("Move operation timeout: \(timeout)") - lastItemMoveStartDate = .now + lastMoveOperationTimestamp = .now + MouseHelpers.hideCursor() + defer { + MouseHelpers.warpCursor(to: mouseLocation) + MouseHelpers.showCursor() + lastMoveOperationTimestamp = .now + updateMoveOperationTimeout(timeout, for: item) + } do { try await scrombleEvent( - mouseDownEvent, - from: .pid(item.ownerPID), - to: .sessionEventTap, - waitingForFrameChangeOf: item + mouseDown, + item: item, + timeout: timeout + ) + itemOrigin = try await waitForMoveEventResponse( + from: item, + initialOrigin: itemOrigin, + timeout: timeout ) try await scrombleEvent( - mouseUpEvent, - from: .pid(item.ownerPID), - to: .sessionEventTap, - waitingForFrameChangeOf: item + mouseUp, + item: item, + timeout: timeout, + repeating: 2 // Double mouse up prevents invalid item state. ) + itemOrigin = try await waitForMoveEventResponse( + from: item, + initialOrigin: itemOrigin, + timeout: timeout + ) + timeout -= timeout / 4 } catch { do { - Logger.itemManager.debug("Posting fallback event for moving \(item.logString)") - // Catch this, as we still want to throw the existing error if the fallback fails. - try await postEventAndWaitToReceive( - fallbackEvent, - to: .sessionEventTap, - item: item + logger.warning("Move events failed, posting fallback") + try await scrombleEvent( + mouseUp, + item: item, + timeout: .milliseconds(100), // Fixed timeout for fallback. + repeating: 2 // Double mouse up prevents invalid item state. ) } catch { - Logger.itemManager.error("Failed to post fallback event for moving \(item.logString)") + // Catch this for logging purposes only. We want to propagate + // the original error. + logger.error("Fallback failed with error: \(error, privacy: .public)") } + timeout += timeout / 2 throw error } } @@ -1070,477 +1071,587 @@ 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. + /// - item: The menu bar item to move. + /// - destination: The destination to move the item to. 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") - return - } - - do { - // Order of these waiters matters, as the modifiers could be released - // while the mouse is still moving. - try await waitForNoModifiersPressed() - try await waitForMouseToStopMoving() - } catch { - throw EventError(code: .couldNotComplete, item: item) + guard item.isMovable else { + throw EventError.itemNotMovable(item) } - - Logger.itemManager.info("Moving \(item.logString) to \(destination.logString)") - guard let appState else { - throw EventError(code: .invalidAppState, item: item) - } - guard let cursorLocation = MouseCursor.locationCoreGraphics else { - throw EventError(code: .invalidCursorLocation, item: item) - } - guard let initialFrame = getCurrentFrame(for: item) else { - throw EventError(code: .invalidItem, item: item) + throw EventError.cannotComplete } - appState.eventManager.stopAll() + try await waitForUserToPauseInput() + + appState.hidEventManager.stopAll() defer { - appState.eventManager.startAll() + appState.hidEventManager.startAll() } - MouseCursor.hide() + try await waitForMoveOperationBuffer() - defer { - MouseCursor.warp(to: cursorLocation) - MouseCursor.show() - } + logger.log( + """ + Moving \(item.logString, privacy: .public) to \ + \(destination.logString, privacy: .public) + """ + ) - // 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 newFrame = getCurrentFrame(for: item) else { - throw EventError(code: .invalidItem, item: item) - } - if newFrame != initialFrame { - Logger.itemManager.info("Successfully moved \(item.logString)") - break - } else { - throw EventError(code: .couldNotComplete, item: item) - } - } catch where n < 5 { - Logger.itemManager.warning("Attempt \(n) to move \(item.logString) failed (error: \(error))") - try await wakeUpItem(item) - Logger.itemManager.info("Retrying move of \(item.logString)") - continue - } + guard try await !itemHasCorrectPosition(item: item, for: destination) else { + logger.debug("Item has correct position, cancelling move") + return } - } - /// 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 { - itemMoveCount += 1 + MouseHelpers.hideCursor() defer { - itemMoveCount -= 1 + MouseHelpers.showCursor() } - try await move(item: item, to: destination) - let waitTask = Task(timeout: timeout) { - while true { - try Task.checkCancellation() - if try await self.itemHasCorrectPosition(item: item, for: destination) { + + let maxAttempts = 8 + for n in 1...maxAttempts { + guard !Task.isCancelled else { + throw EventError.cannotComplete + } + do { + if try await itemHasCorrectPosition(item: item, for: destination) { + logger.debug("Item has correct position, finished with move") return } + try await postMoveEvents(item: item, destination: destination) + logger.debug("Attempt \(n, privacy: .public) succeeded, finished with move") + return + } catch { + 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 } } - do { - try await waitTask.value - } catch is TaskTimeoutError { - throw EventError(code: .otherTimeout, item: item) - } } } -// MARK: - Click Items +// MARK: - Clicking Items 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) + /// 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) } - guard let currentFrame = getCurrentFrame(for: item) else { - throw EventError(code: .invalidItem, item: item) + } + + /// 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. + private func postClickEvents(item: MenuBarItem, mouseButton: CGMouseButton) async throws { + try await eventSemaphore.waitUnlessCancelled() + defer { + eventSemaphore.signal() } - let buttonStates = mouseButton.buttonStates - let clickPoint = CGPoint(x: currentFrame.midX, y: currentFrame.midY) + let clickPoint = try await getCurrentBounds(for: item).center + let mouseLocation = try getMouseLocation() + let source = try getEventSource() + + try permitLocalEvents() + + let clickTypes = getClickSubtypes(for: mouseButton) + let timeout = Duration.milliseconds(250) guard - let mouseDownEvent = CGEvent.menuBarItemEvent( - type: .click(buttonStates.down), - location: clickPoint, + let mouseDown = CGEvent.menuBarItemEvent( item: item, - pid: item.ownerPID, - source: source + source: source, + type: .click(clickTypes.down), + location: clickPoint ), - let mouseUpEvent = CGEvent.menuBarItemEvent( - type: .click(buttonStates.up), - location: clickPoint, + let mouseUp = CGEvent.menuBarItemEvent( item: item, - pid: item.ownerPID, - source: source - ), - let fallbackEvent = CGEvent.menuBarItemEvent( - type: .click(buttonStates.up), - location: clickPoint, - item: item, - pid: item.ownerPID, - source: source + source: source, + type: .click(clickTypes.up), + location: clickPoint ) else { - throw EventError(code: .eventCreationFailure, item: item) + throw EventError.eventCreationFailure(item) } - try permitAllEvents( - for: .combinedSessionState, - during: [ - .eventSuppressionStateRemoteMouseDrag, - .eventSuppressionStateSuppressionInterval, - ], - suppressionInterval: 0, - item: item - ) - - MouseCursor.hide() - + MouseHelpers.hideCursor() defer { - MouseCursor.warp(to: cursorLocation) - MouseCursor.show() + MouseHelpers.warpCursor(to: mouseLocation) + MouseHelpers.showCursor() } do { - Logger.itemManager.info("Clicking \(item.logString) with \(mouseButton.logString)") - try await postEventAndWaitToReceive( - mouseDownEvent, - to: .sessionEventTap, - item: item + try await postEventWithBarrier( + mouseDown, + to: item, + timeout: timeout ) - try await postEventAndWaitToReceive( - mouseUpEvent, - to: .sessionEventTap, - item: item + try await postEventWithBarrier( + mouseUp, + to: item, + timeout: timeout, + repeating: 2 // Double mouse up prevents invalid item state. ) } catch { do { - Logger.itemManager.debug("Posting fallback event for clicking \(item.logString)") - // Catch this, as we still want to throw the existing error if the fallback fails. - try await postEventAndWaitToReceive( - fallbackEvent, - 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. ) } catch { - Logger.itemManager.error("Failed to post fallback event for clicking \(item.logString)") + // 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.cannotComplete + } + + try await waitForUserToPauseInput() + + logger.log( + """ + Clicking \(item.logString, privacy: .public) with \ + \(mouseButton.logString, privacy: .public) + """ + ) + + appState.hidEventManager.stopAll() + defer { + appState.hidEventManager.startAll() + } + + 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 + } + } + } } -// MARK: - Temporarily Show Items +// MARK: - Temporarily Showing Items 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 items.indices.contains(index + 1) { - return .leftOfItem(items[index + 1]) - } else if items.indices.contains(index - 1) { - return .rightOfItem(items[index - 1]) + /// Context for a temporarily shown menu bar item. + private final class TemporarilyShownItemContext { + /// 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. + var 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 window = shownInterfaceWindow, + let current = WindowInfo(windowID: window.windowID) + else { + // Window no longer exists, so assume closed. + return false + } + 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 current.isOnScreen + } + + init(tag: MenuBarItemTag, returnDestination: MoveDestination) { + self.tag = tag + self.returnDestination = returnDestination + } + } + + /// Gets the destination to return the given item to after it is + /// temporarily shown. + private func getReturnDestination(for item: MenuBarItem, in items: [MenuBarItem]) -> MoveDestination? { + 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 } - /// 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)") - 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 } - Logger.itemManager.debug("Rehide timer fired") + logger.debug("Rehide timer fired") Task { - await self.rehideTempShownItems() + await self.rehideTemporarilyShownItems() } } } /// 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 location 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) { - if - let latest = MenuBarItem(windowID: item.windowID), - latest.isOnScreen - { - if clickWhenFinished { - Task { - do { - try await click(item: latest, with: mouseButton) - } catch { - Logger.itemManager.error("ERROR: \(error)") - } - } - } + /// - 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 appState, - let screen = NSScreen.main, - let applicationMenuFrame = appState.menuBarManager.getApplicationMenuFrame(for: screen.displayID) - else { - Logger.itemManager.warning("No application menu frame, so not showing \(item.logString)") + guard let screen = NSScreen.screenWithActiveMenuBar else { + logger.error("No active menu bar screen, so not showing \(item.logString, privacy: .public)") return } - Logger.itemManager.info("Temporarily showing \(item.logString)") + guard let applicationMenuFrame = screen.getApplicationMenuFrame() else { + logger.error("No application menu frame, so not showing \(item.logString, privacy: .public)") + return + } - var items = MenuBarItem.getMenuBarItems(onScreenOnly: false, activeSpaceOnly: true) + var items = await MenuBarItem.getMenuBarItems(option: .activeSpace) guard let destination = getReturnDestination(for: item, in: items) else { - Logger.itemManager.warning("No return destination for \(item.logString)") + logger.error("No return destination for \(item.logString, privacy: .public)") return } - // Remove all items up to the hidden control item. - items.trimPrefix { $0.info != .hiddenControlItem } - // Remove the hidden control item. - items.removeFirst() - // Remove all offscreen items. - items.trimPrefix { !$0.isOnScreen } - - let maxX = if let rightArea = screen.auxiliaryTopRightArea { - max(rightArea.minX + 20, applicationMenuFrame.maxX) - } else { - applicationMenuFrame.maxX + // Remove all items up to and including the hidden control item. + if let index = items.firstIndex(matching: .hiddenControlItem) { + items.removeSubrange(...index) } + 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.frame.minX - item.frame.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)") let alert = NSAlert() alert.messageText = "Not enough room to show \"\(item.displayName)\"" alert.runModal() return } - let initialWindows = WindowInfo.getOnScreenWindows() + appState.hidEventManager.stopAll() + defer { + appState.hidEventManager.startAll() + } - 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)") - } - } else { - do { - try await move(item: item, to: .leftOfItem(targetItem)) - } catch { - Logger.itemManager.error("ERROR: \(error)") - } - } + logger.debug("Temporarily showing \(item.logString, privacy: .public)") - try? await Task.sleep(for: .milliseconds(100)) + do { + try await move(item: item, to: .leftOfItem(targetItem)) + } catch { + logger.error("Error showing item: \(error, privacy: .public)") + return + } - let currentWindows = WindowInfo.getOnScreenWindows() + let context = TemporarilyShownItemContext(tag: item.tag, returnDestination: destination) + temporarilyShownItemContexts.append(context) - let shownInterfaceWindow = currentWindows.first { currentWindow in - currentWindow.ownerPID == item.ownerPID && - !initialWindows.contains { initialWindow in - currentWindow.windowID == initialWindow.windowID - } - } + rehideTimer?.invalidate() + defer { + runRehideTimer() + } - let context = TempShownItemContext( - info: item.info, - returnDestination: destination, - shownInterfaceWindow: shownInterfaceWindow - ) - tempShownItemContexts.append(context) - runTempShownItemTimer(for: appState.settingsManager.advancedSettingsManager.tempShowInterval) + await eventSleep(for: .milliseconds(100)) + let idsBeforeClick = Set(Bridging.getWindowList(option: .onScreen)) + + do { + try await click(item: item, with: mouseButton) + } catch { + logger.error("Error clicking item: \(error, privacy: .public)") + return + } + + await eventSleep(for: .milliseconds(250)) + let windowsAfterClick = WindowInfo.createWindows(option: .onScreen) + + context.shownInterfaceWindow = windowsAfterClick.first { window in + window.ownerPID == item.sourcePID && !idsBeforeClick.contains(window.windowID) } } /// 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. - func rehideTempShownItems() async { - itemMoveCount += 1 - defer { - itemMoveCount -= 1 + /// 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 !tempShownItemContexts.isEmpty else { + guard !temporarilyShownItemContexts.isEmpty else { return } - - guard !isMouseButtonDown else { - Logger.itemManager.debug("Mouse button is down, so waiting to rehide") - runTempShownItemTimer(for: 3) + guard !temporarilyShownItemContexts.contains(where: { $0.isShowingInterface }) else { + logger.debug("Menu bar item interface is shown, so waiting to rehide") + runRehideTimer(for: 3) return } - guard !tempShownItemContexts.contains(where: { $0.isShowingInterface }) else { - Logger.itemManager.debug("Menu bar item interface is shown, so waiting to rehide") - runTempShownItemTimer(for: 3) + guard hasUserPausedInput(for: .milliseconds(250)) else { + logger.debug("Found recent user input, so waiting to rehide") + runRehideTimer(for: 1) return } - Logger.itemManager.info("Rehiding temporarily shown items") + var currentContexts = temporarilyShownItemContexts + temporarilyShownItemContexts.removeAll() + + let items = await MenuBarItem.getMenuBarItems(option: .activeSpace) + var failedContexts = [TemporarilyShownItemContext]() - var failedContexts = [TempShownItemContext]() + appState.hidEventManager.stopAll() + defer { + appState.hidEventManager.startAll() + } - let items = MenuBarItem.getMenuBarItems(onScreenOnly: false, activeSpaceOnly: true) + await eventSleep(for: .milliseconds(250)) - MouseCursor.hide() + logger.debug("Rehiding temporarily shown items") + MouseHelpers.hideCursor() defer { - MouseCursor.show() + MouseHelpers.showCursor() } - while let context = tempShownItemContexts.popLast() { - guard let item = items.first(where: { $0.info == context.info }) else { + while let context = currentContexts.popLast() { + guard let item = items.first(matching: context.tag) else { continue } do { try await move(item: item, to: context.returnDestination) } catch { - Logger.itemManager.error("Failed to rehide \(item.logString) (error: \(error))") - failedContexts.append(context) + context.rehideAttempts += 1 + logger.warning( + """ + Attempt \(context.rehideAttempts, privacy: .public) to rehide \ + \(item.logString, privacy: .public) failed with error: \ + \(error, privacy: .public) + """ + ) + if context.rehideAttempts < 3 { + 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. + context.rehideAttempts = 0 + failedContexts.append(context) + } } } if failedContexts.isEmpty { - tempShownItemsTimer?.invalidate() - tempShownItemsTimer = nil + logger.debug("All items were successfully rehidden") } else { - tempShownItemContexts = failedContexts - Logger.itemManager.warning("Some items failed to rehide") - runTempShownItemTimer(for: 3) + logger.error( + """ + Some items failed to rehide: \ + \(failedContexts.map { $0.tag }, privacy: .public) + """ + ) + temporarilyShownItemContexts.append(contentsOf: failedContexts.reversed()) + runRehideTimer(for: 3) } } - /// 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 } + /// 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( + """ + Removing temporarily shown item from cache: \ + \(tag, privacy: .public) + """ + ) + temporarilyShownItemContexts.remove(at: index) + } } } -// 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 !isMouseButtonDown else { - Logger.itemManager.debug("Mouse button is down, so will not enforce control item order") - return - } - guard !mouseHasRecentlyMoved else { - Logger.itemManager.debug("Mouse has recently moved, so will not enforce control item order") + /// 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 + let alwaysHidden = controlItems.alwaysHidden, + hidden.bounds.maxX <= alwaysHidden.bounds.minX + else { return } - if hiddenControlItem.frame.maxX <= alwaysHiddenControlItem.frame.minX { - Logger.itemManager.info("Arranging menu bar items") - try await slowMove(item: alwaysHiddenControlItem, to: .leftOfItem(hiddenControlItem)) + + do { + logger.debug("Control items have incorrect order") + try await move(item: alwaysHidden, to: .leftOfItem(hidden)) + } catch { + logger.error("Error enforcing control item order: \(error, privacy: .public)") } } } -// MARK: - Menu Bar Item Event Helper Types - -/// Button states for menu bar item events. -private enum MenuBarItemEventButtonState { - case leftMouseDown - case leftMouseUp - case rightMouseDown - case rightMouseUp - case otherMouseDown - case otherMouseUp -} +// MARK: - MenuBarItemEventType /// Event types for menu bar item events. private enum MenuBarItemEventType { /// The event type for moving a menu bar item. - case move(MenuBarItemEventButtonState) - + case move(MoveSubtype) /// The event type for clicking a menu bar item. - case click(MenuBarItemEventButtonState) + case click(ClickSubtype) - /// The button state of this event type. - var buttonState: MenuBarItemEventButtonState { + var cgEventType: CGEventType { switch self { - case .move(let state), .click(let state): state + case .move(let subtype): subtype.cgEventType + case .click(let subtype): subtype.cgEventType } } - /// This event type's equivalent CGEventType. - var cgEventType: CGEventType { - switch buttonState { - case .leftMouseDown: .leftMouseDown - case .leftMouseUp: .leftMouseUp - case .rightMouseDown: .rightMouseDown - case .rightMouseUp: .rightMouseUp - case .otherMouseDown: .otherMouseDown - case .otherMouseUp: .otherMouseUp + var cgEventFlags: CGEventFlags { + switch self { + case .move(.mouseDown): .maskCommand + case .move, .click: [] } } - /// The event flags for this event type. - var cgEventFlags: CGEventFlags { + var cgMouseButton: CGMouseButton { switch self { - case .move(.leftMouseDown): .maskCommand - case .move, .click: [] + case .move: .left + case .click(let subtype): subtype.cgMouseButton + } + } + + // MARK: Subtypes + + /// Subtype for menu bar item move events. + enum MoveSubtype { + case mouseDown + case mouseUp + + var cgEventType: CGEventType { + switch self { + case .mouseDown: .leftMouseDown + case .mouseUp: .leftMouseUp + } } } - /// The mouse button for this event type. - var mouseButton: CGMouseButton { - switch buttonState { - case .leftMouseDown, .leftMouseUp: .left - case .rightMouseDown, .rightMouseUp: .right - case .otherMouseDown, .otherMouseUp: .center + /// Subtype for menu bar item click events. + enum ClickSubtype { + 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 .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 + } } } } @@ -1551,7 +1662,7 @@ 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. + /// Fields that can be used to compare menu bar item events. static let menuBarItemEventFields: [CGEventField] = [ .eventSourceUserData, .mouseEventWindowUnderMousePointer, @@ -1613,59 +1724,116 @@ private extension CGMouseButton { @unknown default: "unknown mouse button" } } - - /// The equivalent down and up button states for menu bar item click events. - var buttonStates: (down: MenuBarItemEventButtonState, up: MenuBarItemEventButtonState) { - switch self { - case .left: (.leftMouseDown, .leftMouseUp) - case .right: (.rightMouseDown, .rightMouseUp) - default: (.otherMouseDown, .otherMouseUp) - } - } } -// MARK: - CGEvent Constructor +// MARK: - CGEvent Helpers 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. - /// - 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(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 { + /// - 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 + ) -> CGEvent? { + guard let event = CGEvent( + mouseEventSource: source, + mouseType: type.cgEventType, + mouseCursorPosition: location, + mouseButton: type.cgMouseButton + ) else { + return nil + } + event.setFlags(for: type) + event.setUserData(ObjectIdentifier(event)) + event.setWindowID(item.windowID, for: type) + event.setClickState(for: type) + 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 + } + + /// 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 \(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) + } + } - event.flags = type.cgEventFlags + /// 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) + } + } + 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 setFlags(for type: MenuBarItemEventType) { + flags = type.cgEventFlags + } + + 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) + setIntegerValueField(.mouseEventWindowUnderMousePointer, value: windowID) + setIntegerValueField(.mouseEventWindowUnderMousePointerThatCanHandleThisEvent, value: windowID) + + if case .move = type { + setIntegerValueField(.windowID, value: windowID) } + } - return event + private func setClickState(for type: MenuBarItemEventType) { + if case .click(let subtype) = type { + setIntegerValueField(.mouseEventClickState, value: subtype.clickState) + } } } -// MARK: - Logger +// MARK: - Logger Helpers private extension Logger { - /// The logger to use for the menu bar item manager. - static let itemManager = Logger(category: "MenuBarItemManager") + /// Logger for the menu bar item manager. + static let menuBarItemManager = Logger(category: "MenuBarItemManager") } diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemServiceConnection.swift new file mode 100644 index 000000000..a0104497a --- /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 service. + 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, + attributes: .concurrent + ) + 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 + } + // 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 } + logger.warning("Session was cancelled with error \(error.localizedDescription)") + self.session = nil + } + 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 { $0.send(request: request) } + } + } +} diff --git a/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift b/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift new file mode 100644 index 000000000..9f5363716 --- /dev/null +++ b/Ice/MenuBar/MenuBarItems/MenuBarItemTag.swift @@ -0,0 +1,245 @@ +// +// MenuBarItemTag.swift +// Ice +// + +import CoreGraphics +import Foundation + +// MARK: - MenuBarItemTag + +/// 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 item identified by this tag. + let title: String + + /// A Boolean value that indicates whether the item identified + /// by this tag can be moved. + var isMovable: Bool { + !MenuBarItemTag.immovableItems.contains(self) + } + + /// A Boolean value that indicates whether the item identified + /// by this tag can be hidden. + var canBeHidden: Bool { + !MenuBarItemTag.nonHideableItems.contains(self) && + !(namespace.isUUID && title == "AudioVideoModule") + } + + /// 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 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 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 { + var result = String(describing: namespace) + if !title.isEmpty { + result.append(":\(title)") + } + return result + } + + /// Creates a tag with the given namespace and title. + init(namespace: Namespace, title: String) { + self.namespace = namespace + self.title = title + } + + /// Creates a tag for the control item with the given identifier. + private init(controlItem identifier: ControlItem.Identifier) { + self.init(namespace: .ice, title: identifier.rawValue) + } +} + +// MARK: MenuBarItemTag Constants + +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) { + items.append(siri) + } + return items + }() + + // 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 + // 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 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) + } + return items + }() + + /// An array of tags for items representing Ice's control items. + static let controlItems = ControlItem.Identifier.allCases.map { $0.tag } + + // MARK: Control Items + + /// The tag for Ice's control item for the "Visible" section. + static let visibleControlItem = MenuBarItemTag(controlItem: .visible) + + /// The tag for Ice's control item for the "Hidden" section. + static let hiddenControlItem = MenuBarItemTag(controlItem: .hidden) + + /// The tag for Ice's control item for the "Always-Hidden" section. + static let alwaysHiddenControlItem = MenuBarItemTag(controlItem: .alwaysHidden) + + // 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") + + /// 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") + } + + /// The tag for the system "FaceTime" item. + static let faceTime = MenuBarItemTag(namespace: .controlCenter, title: "FaceTime") + + /// The tag for the system "Music Recognition" item. + static let musicRecognition = MenuBarItemTag(namespace: .controlCenter, title: "MusicRecognition") + + /// 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 "Siri" item. + static let siri = MenuBarItemTag(namespace: .systemUIServer, title: "Siri") + + /// 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") + } +} + +// MARK: - MenuBarItemTag.Namespace + +extension MenuBarItemTag { + /// A type that represents a menu bar item namespace. + enum Namespace: Hashable, CustomStringConvertible { + /// The `null` namespace. + case null + /// A namespace represented by a string. + case string(String) + /// A namespace represented by a UUID. + case uuid(UUID) + + /// A textual representation of the namespace. + var description: String { + switch self { + case .null: "null" + case .string(let string): string + case .uuid(let uuid): uuid.uuidString + } + } + + /// 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: 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 + } + } +} + +// MARK: MenuBarItemTag.Namespace Constants +extension MenuBarItemTag.Namespace { + /// The namespace for the "Ice" process. + static let ice = string(Constants.bundleIdentifier) + + /// The namespace for the "Control Center" process. + static let controlCenter = string("com.apple.controlcenter") + + /// The namespace for the "PasswordsMenuBarExtra" process. + static let passwords = string("com.apple.Passwords.MenuBarExtra") + + /// The namespace for the "screencaptureui" process. + static let screenCaptureUI = string("com.apple.screencaptureui") + + /// The namespace for the "SystemUIServer" process. + static let systemUIServer = string("com.apple.systemuiserver") + + /// The namespace for the "TextInputMenuAgent" process. + static let textInputMenuAgent = string("com.apple.TextInputMenuAgent") + + /// The namespace for the "WeatherMenu" process. + static let weather = string("com.apple.weather.menu") +} diff --git a/Ice/MenuBar/MenuBarManager.swift b/Ice/MenuBar/MenuBarManager.swift index 5feed0ac5..3cc746eec 100644 --- a/Ice/MenuBar/MenuBarManager.swift +++ b/Ice/MenuBar/MenuBarManager.swift @@ -3,8 +3,8 @@ // Ice // -import AXSwift import Combine +import OSLog import SwiftUI /// Manager for the state of the menu bar. @@ -22,6 +22,15 @@ 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 + + /// Reference to the settings window. + @Published private var settingsWindow: NSWindow? + + /// Logger for the menu bar manager. + private let logger = Logger(category: "MenuBarManager") + /// The shared app state. private weak var appState: AppState? @@ -31,53 +40,39 @@ 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() - /// 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 - } + /// The panel that contains a portable version of the menu bar + /// appearance editor interface + let appearanceEditorPanel = MenuBarAppearanceEditorPanel() - /// Initializes a new menu bar manager instance. - init(appState: AppState) { - self.iceBarPanel = IceBarPanel(appState: appState) - self.searchPanel = MenuBarSearchPanel(appState: appState) - self.appState = appState + /// The managed sections in the menu bar. + let sections = [ + MenuBarSection(name: .visible), + MenuBarSection(name: .hidden), + MenuBarSection(name: .alwaysHidden), + ] + + /// A Boolean value that indicates whether at least one of the manager's + /// sections is visible. + var hasVisibleSection: Bool { + sections.contains { !$0.isHidden } } /// 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.menuBarManager.warning("Sections already initialized") - return - } - - guard let appState else { - Logger.menuBarManager.error("Error initializing menu bar sections: Missing app state") - return + iceBarPanel.performSetup(with: appState) + searchPanel.performSetup(with: appState) + appearanceEditorPanel.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. @@ -122,9 +117,10 @@ 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), - !appState.eventManager.isMouseInsideMenuBar + let screen = appState.hidEventManager.bestScreen(appState: appState), + !appState.hidEventManager.isMouseInsideMenuBar(appState: appState, screen: screen) { Task { try await Task.sleep(for: .seconds(0.1)) @@ -134,16 +130,18 @@ final class MenuBarManager: ObservableObject { } .store(in: &c) - appState?.settingsWindow?.publisher(for: \.isVisible) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.updateAverageColorInfo() + appState?.publisherForWindow(.settings) + .sink { [weak self] window in + self?.settingsWindow = window } .store(in: &c) - Timer.publish(every: 5, on: .main, in: .default) - .autoconnect() - .sink { [weak self] _ in + $settingsWindow + .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) @@ -152,67 +150,66 @@ 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 } // 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.settings.advanced.hideApplicationMenus, + !appState.settings.general.useIceBar, !isMenuBarHiddenBySystem, - !appState.isActiveSpaceFullscreen, - appState.settingsWindow?.isVisible == false + !appState.activeSpace.isFullscreen, + !appState.navigationState.isSettingsPresented else { return } - if sections.contains(where: { $0.controlItem.state == .showItems }) { + if sections.contains(where: { $0.controlItem.state == .showSection }) { guard let screen = NSScreen.main else { 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 } - // Get all items. - var items = MenuBarItem.getMenuBarItems(on: displayID, onScreenOnly: false, activeSpaceOnly: true) - - // Filter the items down according to the currently enabled/shown sections. - if - let alwaysHiddenSection = section(withName: .alwaysHidden), - alwaysHiddenSection.isEnabled - { - if alwaysHiddenSection.controlItem.state == .hideItems { - if let alwaysHiddenControlItem = items.firstIndex(matching: .alwaysHiddenControlItem).map({ items.remove(at: $0) }) { - items.trimPrefix { $0.frame.maxX <= alwaysHiddenControlItem.frame.minX } + Task { + // Get all items. + var items = await MenuBarItem.getMenuBarItems(on: screen.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.frame.maxX <= hiddenControlItem.frame.minX } - } - } - // Get the leftmost item on the screen. - guard let leftmostItem = items.min(by: { $0.frame.minX < $1.frame.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.frame.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() @@ -227,46 +224,35 @@ final class MenuBarManager: ObservableObject { /// of the menu bar. func updateAverageColorInfo() { guard - canUpdateAverageColorInfo, - let screen = appState?.settingsWindow?.screen + let settingsWindow, + settingsWindow.isVisible, + let screen = settingsWindow.screen else { return } - let image: CGImage? - let source: MenuBarAverageColorInfo.Source - - let windows = WindowInfo.getOnScreenWindows(excludeDesktopWindows: false) + let windows = WindowInfo.createWindows(option: .onScreen) 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 - } else { + 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 @@ -276,66 +262,26 @@ 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 { - return false - } - let position = menuBarWindow.frame.origin - do { - let uiElement = try systemWideElement.elementAtPosition(Float(position.x), Float(position.y)) - return try uiElement?.role() == .menuBar - } catch { - return false - } - } - - /// 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(Float(displayBounds.origin.x), Float(displayBounds.origin.y)), - let role = try? menuBar.role(), - role == .menuBar, - let items: [UIElement] = try? menuBar.arrayAttribute(.children)?.filter({ (try? $0.attribute(.enabled)) == true }) + let window = WindowInfo.menuBarWindow(from: windows, for: display), + let element = AXHelpers.element(at: window.bounds.origin) else { - return nil - } - - let itemFrames = items.lazy.compactMap { try? $0.attribute(.frame) as CGRect? } - let applicationMenuFrame = itemFrames.reduce(.null, CGRectUnion) - - 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 false } - - return applicationMenuFrame + return AXHelpers.role(for: element) == .menuBar } - /// 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( + 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()) @@ -352,10 +298,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 } @@ -363,10 +309,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 } @@ -380,41 +326,49 @@ 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") + /// 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. func section(withName name: MenuBarSection.Name) -> MenuBarSection? { sections.first { $0.name == name } } -} -// MARK: MenuBarManager: BindingExposable -extension MenuBarManager: BindingExposable { } + /// 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: - 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 -} -// MARK: - Logger -private extension Logger { - /// Logger to use for the menu bar manager. - static let menuBarManager = Logger(category: "MenuBarManager") + /// 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 b1157d377..6dab9fc80 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. @@ -47,16 +52,16 @@ 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 { - appState?.settingsManager.generalSettingsManager.useIceBar ?? false + appState?.settings.general.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. @@ -64,7 +69,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 @@ -74,27 +79,27 @@ 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 { 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 + return controlItem.state == .hideSection case .alwaysHidden: - if iceBarPanel?.currentSection == .alwaysHidden { + if menuBarManager?.iceBarPanel.currentSection == .alwaysHidden { return false } - return controlItem.state == .hideItems + return controlItem.state == .hideSection } } @@ -107,135 +112,125 @@ 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) { + /// 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 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: .visible) 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. func show() { - guard - let appState, - isHidden - else { + 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) - } - for section in appState.menuBarManager.sections { - section.controlItem.state = .hideItems + + 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 { + switch section.name { + case .visible: + section.controlItem.state = .showSection + case .hidden, .alwaysHidden: + section.controlItem.state = .hideSection } } - case .alwaysHidden where useIceBar: - Task { - if let screenForIceBar { - await iceBarPanel?.show(section: .alwaysHidden, on: screenForIceBar) - } - for section in appState.menuBarManager.sections { - section.controlItem.state = .hideItems + + if let screen = screenForIceBar { + Task { + switch name { + case .visible, .hidden: + await menuBarManager.iceBarPanel.show(section: .hidden, on: screen) + case .alwaysHidden: + await menuBarManager.iceBarPanel.show(section: .alwaysHidden, on: screen) + } + startRehideChecks() } } - 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 + + 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 } - 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 + for section in menuBarManager.sections { + section.controlItem.state = .showSection } - 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() + + menuBarManager.iceBarPanel.close() // Make sure Ice Bar is always closed. + menuBarManager.showOnHoverAllowed = true + switch name { - case _ where useIceBar: - for section in appState.menuBarManager.sections { - section.controlItem.state = .hideItems - } - case .visible: - guard - let hiddenSection = appState.menuBarManager.section(withName: .hidden), - let alwaysHiddenSection = appState.menuBarManager.section(withName: .alwaysHidden) - else { - return - } - controlItem.state = .hideItems - hiddenSection.controlItem.state = .hideItems - alwaysHiddenSection.controlItem.state = .hideItems - case .hidden: - guard - let visibleSection = appState.menuBarManager.section(withName: .visible), - let alwaysHiddenSection = appState.menuBarManager.section(withName: .alwaysHidden) - else { - return + case _ where useIceBar, .visible, .hidden: + for section in menuBarManager.sections { + section.controlItem.state = .hideSection } - controlItem.state = .hideItems - visibleSection.controlItem.state = .hideItems - alwaysHiddenSection.controlItem.state = .hideItems case .alwaysHidden: - controlItem.state = .hideItems + controlItem.state = .hideSection } - appState.allowShowOnHover() + stopRehideChecks() } /// Toggles the visibility of the section. func toggle() { - if isHidden { - show() - } else { - hide() - } + if isHidden { show() } else { hide() } } /// Starts running checks to determine when to rehide the section. @@ -245,13 +240,13 @@ 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 } - rehideMonitor = UniversalEventMonitor(mask: .mouseMoved) { [weak self] event in + rehideMonitor = EventMonitor.universal(for: .mouseMoved) { [weak self] event in guard let self, let screen = NSScreen.main @@ -261,7 +256,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 @@ -299,11 +294,3 @@ final class MenuBarSection { rehideMonitor = nil } } - -// MARK: MenuBarSection: BindingExposable -extension MenuBarSection: BindingExposable { } - -// MARK: - Logger -private extension Logger { - static let menuBarSection = Logger(category: "MenuBarSection") -} diff --git a/Ice/MenuBar/Search/MenuBarSearchModel.swift b/Ice/MenuBar/Search/MenuBarSearchModel.swift new file mode 100644 index 000000000..922415c13 --- /dev/null +++ b/Ice/MenuBar/Search/MenuBarSearchModel.swift @@ -0,0 +1,76 @@ +// +// MenuBarSearchModel.swift +// Ice +// + +import Cocoa +import Combine +import Ifrit + +@MainActor +final class MenuBarSearchModel: ObservableObject { + enum ItemID: Hashable { + case header(MenuBarSection.Name) + case item(MenuBarItemTag) + } + + @Published var searchText = "" + @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 + } + + guard + 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: .menuBarWindow) + + if averageColorInfo != info { + averageColorInfo = info + } + } +} diff --git a/Ice/MenuBar/Search/MenuBarSearchPanel.swift b/Ice/MenuBar/Search/MenuBarSearchPanel.swift index 1730cd8c3..bf4ace8f1 100644 --- a/Ice/MenuBar/Search/MenuBarSearchPanel.swift +++ b/Ice/MenuBar/Search/MenuBarSearchPanel.swift @@ -5,24 +5,23 @@ 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? /// 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] + private lazy var mouseDownMonitor = EventMonitor.universal( + for: [.leftMouseDown, .rightMouseDown, .otherMouseDown] ) { [weak self, weak appState] event in guard let self, @@ -31,15 +30,15 @@ final class MenuBarSearchPanel: NSPanel { else { return event } - if !appState.itemManager.isMovingItem { + if !appState.itemManager.lastMoveOperationOccurred(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() @@ -48,25 +47,35 @@ 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 } - /// 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() + model.performSetup(with: self) } /// Configures the internal observers for the panel. @@ -93,44 +102,45 @@ final class MenuBarSearchPanel: NSPanel { } /// Shows the search panel on the given screen. - func show(on screen: NSScreen) async { + func show(on screen: NSScreen? = nil) { guard let appState else { return } + guard let screen = screen ?? defaultScreen else { + Logger.default.error("Missing screen for search panel") + return + } + // 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, panel: self) - hostingView.setFrameSize(hostingView.intrinsicContentSize) - setFrame(hostingView.frame, display: true) + let hostingView = MenuBarSearchHostingView(appState: appState, model: model, 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 { - if isVisible { - close() - } else if let screen = MenuBarSearchPanel.defaultScreen { - await show(on: screen) - } + func toggle() { + if isVisible { close() } else { show() } } /// Dismisses the search panel. @@ -150,12 +160,16 @@ private final class MenuBarSearchHostingView: NSHostingView { init( appState: AppState, + model: MenuBarSearchModel, + displayID: CGDirectDisplayID, panel: MenuBarSearchPanel ) { super.init( - rootView: MenuBarSearchContentView(closePanel: { [weak panel] in panel?.close() }) + rootView: MenuBarSearchContentView { [weak panel] in panel?.close() } + .environmentObject(appState) .environmentObject(appState.itemManager) .environmentObject(appState.imageCache) + .environmentObject(model) .erasedToAnyView() ) } @@ -172,27 +186,56 @@ private final class MenuBarSearchHostingView: NSHostingView { } private struct MenuBarSearchContentView: View { - private typealias ListItem = SectionedListItem - - private enum ItemID: Hashable { - case header(MenuBarSection.Name) - case item(MenuBarItemInfo) - } + private typealias ListItem = SectionedListItem @EnvironmentObject var itemManager: MenuBarItemManager - @State private var searchText = "" - @State private var displayedItems = [SectionedListItem]() - @State private var selection: ItemID? + @EnvironmentObject var model: MenuBarSearchModel @FocusState private var searchFieldIsFocused: Bool - private let fuse = Fuse(threshold: 0.5) - let closePanel: () -> Void + private var hasItems: Bool { + !itemManager.itemCache.managedItems.isEmpty + } + + private var bottomBarPadding: CGFloat { + if #available(macOS 26.0, *) { 7 } else { 5 } + } + var body: some View { VStack(spacing: 0) { - TextField(text: $searchText, prompt: Text("Search menu bar items…")) { - Text("Search menu bar items…") + searchField + mainContent + bottomBar + } + .background { + VisualEffectView(material: .sheet, blendingMode: .behindWindow) + .opacity(0.5) + } + .frame(width: 600, height: 400) + .fixedSize() + .task { + searchFieldIsFocused = true + } + .onChange(of: model.searchText, initial: true) { + updateDisplayedItems() + selectFirstDisplayedItem() + } + .onChange(of: itemManager.itemCache, initial: true) { + updateDisplayedItems() + if model.selection == nil { + selectFirstDisplayedItem() + } + } + } + + @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) @@ -202,102 +245,131 @@ private struct MenuBarSearchContentView: View { .focused($searchFieldIsFocused) Divider() + } + } - SectionedList(selection: $selection, items: $displayedItems) + @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) + } + } - Divider() - .offset(y: 1) - .zIndex(1) - - HStack { - SettingsButton { - closePanel() - itemManager.appState?.appDelegate?.openSettingsWindow() - } + @ViewBuilder + private var bottomBar: some View { + HStack { + SettingsButton { + closePanel() + itemManager.appState?.activate(withPolicy: .regular) + itemManager.appState?.openWindow(.settings) + } - Spacer() + Spacer() - if - let selection, - let item = menuBarItem(for: selection) - { - ShowItemButton(item: item) { - performAction(for: item) - } + if + let selection = model.selection, + let item = menuBarItem(for: selection) + { + ShowItemButton(item: item) { + performAction(for: item) } } - .padding(5) - .background(.thinMaterial) - } - .background { - VisualEffectView(material: .sheet, blendingMode: .behindWindow) - .opacity(0.5) - } - .frame(width: 600, height: 400) - .fixedSize() - .task { - searchFieldIsFocused = true } - .onChange(of: searchText, initial: true) { - updateDisplayedItems() - selectFirstDisplayedItem() - } - .onChange(of: itemManager.itemCache, initial: true) { - updateDisplayedItems() + .padding(bottomBarPadding) + .background(.thinMaterial) + .buttonStyle(BottomBarButtonStyle()) + .overlay(alignment: .top) { + Divider() } } private func selectFirstDisplayedItem() { - selection = displayedItems.first { $0.isSelectable }?.id + model.selection = model.displayedItems.first { $0.isSelectable }?.id } 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 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 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 + } - for item in itemManager.itemCache.managedItems(for: section).reversed() { - let listItem = ListItem.item(id: .item(item.info)) { - performAction(for: item) - } content: { - MenuBarSearchItemView(item: item) + 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: 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)) } - } - 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 { - return searchItem + let selectableItems = searchItems.filter { $0.listItem.isSelectable } + let fuseResults = model.fuse.searchSync( + model.searchText, + in: selectableItems.map { $0.title } + ) + let maxFuseScore = Double(fuseResults.count) + + 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) + } + + let matchScore = Double(match.score.value) + let averageScore = (matchScore + fuseScore) / 2 + + return ScoredItem(listItem, averageScore) } - return nil - } - let results = fuse.searchSync(searchText, in: selectableItems.map { $0.title }) - displayedItems = results.map { selectableItems[$0.index].listItem } + .sorted { $0.score > $1.score } + .map { $0.listItem } } } - private func menuBarItem(for selection: ItemID) -> MenuBarItem? { + private func menuBarItem(for selection: MenuBarSearchModel.ItemID) -> MenuBarItem? { switch selection { - case .item(let info): - itemManager.itemCache.managedItems.first { $0.info == info } + case .item(let tag): + return itemManager.itemCache.managedItems.first(matching: tag) case .header: - nil + return nil } } @@ -305,50 +377,12 @@ private struct MenuBarSearchContentView: View { closePanel() Task { try await Task.sleep(for: .milliseconds(25)) - itemManager.tempShowItem(item, clickWhenFinished: true, mouseButton: .left) - } - } -} - -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 - - init(action: @escaping () -> Void, @ViewBuilder content: () -> Content) { - self.action = action - self.content = content() - } - - var body: some View { - content - .padding(3) - .background { - RoundedRectangle(cornerRadius: 5, style: .circular) - .fill(.regularMaterial) - .brightness(0.25) - .opacity(isPressed ? 0.5 : isHovering ? 0.25 : 0) - } - .contentShape(Rectangle()) - .onHover { hovering in - isHovering = hovering + if Bridging.isWindowOnScreen(item.windowID) { + try await itemManager.click(item: item, with: .left) + } else { + await itemManager.temporarilyShow(item: item, clickingWith: .left) } - .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) + } } } @@ -356,11 +390,10 @@ 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) } @@ -371,21 +404,30 @@ private struct ShowItemButton: View { let item: MenuBarItem let action: () -> Void + private var backgroundShape: some InsettableShape { + if #available(macOS 26.0, *) { + RoundedRectangle(cornerRadius: 5, style: .continuous) + } else { + RoundedRectangle(cornerRadius: 3, style: .circular) + } + } + var body: some View { - BottomBarButton(action: action) { + Button(action: action) { HStack { - Text(item.isOnScreen ? "Click item" : "Show item") - .padding(.horizontal, 5) + Text("\(Bridging.isWindowOnScreen(item.windowID) ? "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) @@ -395,75 +437,153 @@ 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 { + guard let app = NSRunningApplication + .runningApplications(withBundleIdentifier: "com.apple.controlcenter") + .first + else { return nil } return app.icon }() private struct MenuBarSearchItemView: View { + @EnvironmentObject var appState: AppState @EnvironmentObject var imageCache: MenuBarItemImageCache + @EnvironmentObject var model: MenuBarSearchModel let item: MenuBarItem - private var image: NSImage? { + private var itemImage: NSImage { guard - let image = imageCache.images[item.info]?.trimmingTransparentPixels(around: [.minXEdge, .maxXEdge]), - let screen = imageCache.screen + let cached = imageCache.images[item.tag], + let trimmed = cached.cgImage.trimmingTransparency(around: [.minXEdge, .maxXEdge]) else { - return nil + return NSImage() } let size = CGSize( - width: CGFloat(image.width) / screen.backingScaleFactor, - height: CGFloat(image.height) / screen.backingScaleFactor + width: CGFloat(trimmed.width) / cached.scale, + height: CGFloat(trimmed.height) / cached.scale ) - return NSImage(cgImage: image, size: size) + return NSImage(cgImage: trimmed, size: size) } private var appIcon: NSImage? { - if item.info.namespace == .systemUIServer { - controlCenterIcon + guard let app = item.sourceApplication else { + return nil + } + switch item.tag.namespace { + case .controlCenter, .systemUIServer, .textInputMenuAgent: + return controlCenterIcon + default: + return app.icon + } + } + + private var backgroundShape: some InsettableShape { + if #available(macOS 26.0, *) { + RoundedRectangle(cornerRadius: 7, style: .continuous) } else { - item.owningApplication?.icon + RoundedRectangle(cornerRadius: 5, style: .circular) } } + private var dimension: CGFloat { + if #available(macOS 26.0, *) { 26 } else { 24 } + } + + private var padding: CGFloat { + if #available(macOS 26.0, *) { 6 } else { 8 } + } + var body: some View { HStack { - if let appIcon { - Image(nsImage: appIcon) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 24, height: 24) + Label { + labelText + } icon: { + labelIcon } - Text(item.displayName) Spacer() - imageViewWithBackground + itemView } - .padding(8) + .padding(padding) } @ViewBuilder - private var imageViewWithBackground: some View { - if let image { - ZStack { - RoundedRectangle(cornerRadius: 5, style: .circular) - .fill(.regularMaterial) - .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) - } + private var labelText: some View { + Text(item.displayName) + } - Image(nsImage: image) - .frame(height: 24) - } + @ViewBuilder + 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) + .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) + .frame(width: dimension, height: dimension) } } + + @ViewBuilder + 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) + } + } } diff --git a/Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift b/Ice/MenuBar/Spacing/MenuBarItemSpacingManager.swift index 4eb0fae39..ad1502af6 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,8 +158,8 @@ final class MenuBarItemSpacingManager { try? await Task.sleep(for: .milliseconds(100)) - let items = MenuBarItem.getMenuBarItems(onScreenOnly: false, activeSpaceOnly: true) - let pids = Set(items.map { $0.ownerPID }) + let items = await MenuBarItem.getMenuBarItems(option: .activeSpace) + let pids = Set(items.map { $0.sourcePID ?? $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/Permissions/AppPermissions.swift b/Ice/Permissions/AppPermissions.swift new file mode 100644 index 000000000..1faf04d77 --- /dev/null +++ b/Ice/Permissions/AppPermissions.swift @@ -0,0 +1,79 @@ +// +// AppPermissions.swift +// Ice +// + +import Combine +import Foundation +import OSLog + +/// A type that manages the permissions of the app. +@MainActor +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 + case hasAll + case hasRequired + } + + /// The manager's logger. + let logger = Logger(category: "Permissions") + + /// The permission for Accessibility features. + let accessibility = AccessibilityPermission() + + /// The permission for Screen Recording features. + let screenRecording = ScreenRecordingPermission() + + /// The state of the app's granted permissions. + @Published private(set) var permissionsState: PermissionsState = .missing + + /// Storage for internal observers. + private var cancellable: AnyCancellable? + + /// The permissions required for full app functionality. + var allPermissions: [Permission] { + [accessibility, screenRecording] + } + + /// The permissions required for basic app functionality. + var requiredPermissions: [Permission] { + allPermissions.filter { $0.isRequired } + } + + /// Creates a new permissions manager. + init() { + self.updatePermissionsState() + self.cancellable = Publishers.MergeMany(allPermissions.map { $0.$hasPermission }) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updatePermissionsState() + } + } + + /// 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 + } + } + + /// Stops running all permissions checks. + func stopAllChecks() { + logger.info("Stopping all permissions checks") + for permission in allPermissions { + permission.stopCheck() + } + } +} diff --git a/Ice/Permissions/Permission.swift b/Ice/Permissions/Permission.swift index dd82abd18..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 @@ -19,20 +17,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? @@ -126,10 +129,10 @@ final class AccessibilityPermission: Permission { isRequired: true, settingsURL: nil, check: { - checkIsProcessTrusted() + AXHelpers.isProcessTrusted() }, request: { - checkIsProcessTrusted(prompt: true) + AXHelpers.isProcessTrusted(prompt: true) } ) } @@ -142,7 +145,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/PermissionsManager.swift b/Ice/Permissions/PermissionsManager.swift deleted file mode 100644 index c991d0474..000000000 --- a/Ice/Permissions/PermissionsManager.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// PermissionsManager.swift -// Ice -// - -import Combine -import Foundation - -/// A type that manages the permissions of the app. -@MainActor -final class PermissionsManager: ObservableObject { - /// The state of the granted permissions for the app. - enum PermissionsState { - case missingPermissions - case hasAllPermissions - case hasRequiredPermissions - } - - /// The state of the granted permissions for the app. - @Published var permissionsState = PermissionsState.missingPermissions - - let accessibilityPermission: AccessibilityPermission - - let screenRecordingPermission: ScreenRecordingPermission - - let allPermissions: [Permission] - - private(set) weak var appState: AppState? - - private var cancellables = Set() - - var requiredPermissions: [Permission] { - allPermissions.filter { $0.isRequired } - } - - init(appState: AppState) { - self.appState = appState - self.accessibilityPermission = AccessibilityPermission() - self.screenRecordingPermission = ScreenRecordingPermission() - self.allPermissions = [ - accessibilityPermission, - screenRecordingPermission, - ] - configureCancellables() - } - - 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 - } - } - .store(in: &c) - - cancellables = c - } - - /// Stops running all permissions checks. - func stopAllChecks() { - for permission in allPermissions { - permission.stopCheck() - } - } -} diff --git a/Ice/Permissions/PermissionsView.swift b/Ice/Permissions/PermissionsView.swift index 8b7430095..669a7ce25 100644 --- a/Ice/Permissions/PermissionsView.swift +++ b/Ice/Permissions/PermissionsView.swift @@ -6,11 +6,11 @@ import SwiftUI struct PermissionsView: View { - @EnvironmentObject var permissionsManager: PermissionsManager - @Environment(\.openWindow) private var openWindow + @EnvironmentObject var appState: AppState + @EnvironmentObject var manager: AppPermissions private var continueButtonText: LocalizedStringKey { - if case .hasRequiredPermissions = permissionsManager.permissionsState { + if case .hasRequired = manager.permissionsState { "Continue in Limited Mode" } else { "Continue" @@ -18,10 +18,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) } } @@ -30,65 +33,51 @@ struct PermissionsView: View { headerView .padding(.vertical) - explanationView - permissionsGroupStack + permissionsStack footerView .padding(.vertical) } .padding(.horizontal) + .frame(width: 550) .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 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) { - ForEach(permissionsManager.allPermissions) { permission in + private var permissionsStack: some View { + VStack { + explanationBox + ForEach(manager.allPermissions) { permission in permissionBox(permission) } } @@ -116,29 +105,36 @@ struct PermissionsView: View { @ViewBuilder private var continueButton: some View { Button { - guard let appState = permissionsManager.appState else { + 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 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() @@ -147,21 +143,18 @@ struct PermissionsView: View { ForEach(permission.details, id: \.self) { detail in HStack { Text("•").bold() - Text(detail) + Text(detail).fontWeight(.medium) } } } } Button { - guard let appState = permissionsManager.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 +167,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..aed8029f1 100644 --- a/Ice/Permissions/PermissionsWindow.swift +++ b/Ice/Permissions/PermissionsWindow.swift @@ -9,17 +9,29 @@ 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.standardWindowButton(.closeButton)?.isHidden = true + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true + if let contentView = window.contentView { + withMutableCopy(of: 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) .windowStyle(.hiddenTitleBar) - .environmentObject(appState.permissionsManager) + .environmentObject(appState) + .environmentObject(appState.permissions) } } diff --git a/Ice/Resources/Acknowledgements.pdf b/Ice/Resources/Acknowledgements.pdf index 8c8d58211..60e181bdf 100644 Binary files a/Ice/Resources/Acknowledgements.pdf and b/Ice/Resources/Acknowledgements.pdf differ diff --git a/Ice/Resources/Acknowledgements.rtf b/Ice/Resources/Acknowledgements.rtf index 69f7bc359..fe435f1b5 100644 --- a/Ice/Resources/Acknowledgements.rtf +++ b/Ice/Resources/Acknowledgements.rtf @@ -1,103 +1,59 @@ -{\rtf1\ansi\ansicpg1252\cocoartf2761 -\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 HelveticaNeue-Bold;\f1\fnil\fcharset0 HelveticaNeue;} +{\rtf1\ansi\ansicpg1252\cocoartf2865 +\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Arial-BoldMT;\f1\fswiss\fcharset0 ArialMT;} {\colortbl;\red255\green255\blue255;\red0\green0\blue0;} {\*\expandedcolortbl;;\cssrgb\c0\c1\c1;} -\margl1440\margr1440\vieww34360\viewh20460\viewkind0 +\margl1440\margr1440\vieww34360\viewh20220\viewkind0 \deftab720 \pard\pardeftab720\partightenfactor0 -\f0\b\fs36 \cf2 \expnd0\expndtw0\kerning0 +\f0\b\fs24 \cf2 \expnd0\expndtw0\kerning0 Acknowledgements -\f1\b0\fs24 \page \ -\pard\pardeftab720\partightenfactor0 - -\f0\b\fs28 \cf2 AXSwift \f1\b0 \ -\pard\pardeftab720\partightenfactor0 -{\field{\*\fldinst{HYPERLINK "https://github.com/tmandry/AXSwift"}}{\fldrslt -\fs24 \cf2 \ul \ulc2 https://github.com/tmandry/AXSwift}} -\fs22 \ -\pard\pardeftab720\partightenfactor0 - -\fs24 \cf2 \ -MIT License\ +Ice uses a number of excellent open source libraries. Their licenses and copyright notices are included below.\ \ + +\f0\b AXSwift - https://github.com/tmandry/AXSwift +\f1\b0 \ Copyright (c) 2017 Tyler Mandry\ -\ 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. -\fs22 \page -\fs24 \ -\pard\pardeftab720\partightenfactor0 -\f0\b\fs28 \cf2 CompactSlider +\f0\b CompactSlider - https://github.com/buh/CompactSlider \f1\b0 \ -\pard\pardeftab720\partightenfactor0 -{\field{\*\fldinst{HYPERLINK "https://github.com/buh/CompactSlider"}}{\fldrslt -\fs24 \cf2 \ul \ulc2 https://github.com/buh/CompactSlider}} -\fs22 \ -\pard\pardeftab720\partightenfactor0 - -\fs24 \cf2 \ -MIT License\ -\ Copyright (c) 2022 Alexey Bukhtin\ -\ 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 Ifrit +\f0\b Ifrit - https://github.com/ukushu/Ifrit \f1\b0 \ -{\field{\*\fldinst{HYPERLINK "https://github.com/ukushu/Ifrit"}}{\fldrslt -\fs24 \ul \ulc2 https://github.com/ukushu/Ifrit}} -\fs22 \ - -\fs24 \ -MIT License\ -\ Copyright (c) 2024 Andrii Vynnychenko, Kirollos Risk(original "fuse-swift" repository code)\ -\ 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 -\cf2 \ -\pard\pardeftab720\partightenfactor0 +\ -\f0\b\fs28 \cf2 LaunchAtLogin +\f0\b LaunchAtLogin - https://github.com/sindresorhus/LaunchAtLogin \f1\b0 \ -\pard\pardeftab720\partightenfactor0 -{\field{\*\fldinst{HYPERLINK "https://github.com/sindresorhus/LaunchAtLogin"}}{\fldrslt -\fs24 \cf2 \ul \ulc2 https://github.com/sindresorhus/LaunchAtLogin}} -\fs24 \ -\ -MIT License\ -\ Copyright (c) Sindre Sorhus (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 Sparkle +\f0\b Semaphore - https://github.com/groue/Semaphore \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) 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.\ \ + +\f0\b Sparkle - https://github.com/sparkle-project/Sparkle +\f1\b0 \ Copyright (c) 2006-2013 Andy Matuschak.\ Copyright (c) 2009-2013 Elgato Systems GmbH.\ Copyright (c) 2011-2014 Kornel Lesi\uc0\u324 ski.\ @@ -106,68 +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 +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 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 54% rename from Ice/Info.plist rename to Ice/Resources/Info.plist index b1b2d9fa2..49ccecf2f 100644 --- a/Ice/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/Ice/Settings/SettingsManagers/AdvancedSettingsManager.swift b/Ice/Settings/Models/AdvancedSettings.swift similarity index 64% rename from Ice/Settings/SettingsManagers/AdvancedSettingsManager.swift rename to Ice/Settings/Models/AdvancedSettings.swift index 9585b31ab..2a84b55e3 100644 --- a/Ice/Settings/SettingsManagers/AdvancedSettingsManager.swift +++ b/Ice/Settings/Models/AdvancedSettings.swift @@ -1,28 +1,34 @@ // -// AdvancedSettingsManager.swift +// AdvancedSettings.swift // Ice // import Combine -import Foundation +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 section divider control - /// items should be shown. - @Published var showSectionDividers = false +// MARK: - AdvancedSettings +/// Model for the app's Advanced settings. +@MainActor +final class AdvancedSettings: ObservableObject { /// 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 + + /// 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 @@ -30,66 +36,71 @@ 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) { + /// Performs the initial setup of the model. + func performSetup(with appState: AppState) { self.appState = appState - } - - func performSetup() { loadInitialState() configureCancellables() } + /// Loads the model's initial state. private func loadInitialState() { - Defaults.ifPresent(key: .hideApplicationMenus, assign: &hideApplicationMenus) - Defaults.ifPresent(key: .showSectionDividers, assign: &showSectionDividers) Defaults.ifPresent(key: .enableAlwaysHiddenSection, assign: &enableAlwaysHiddenSection) - Defaults.ifPresent(key: .canToggleAlwaysHiddenSection, assign: &canToggleAlwaysHiddenSection) + 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) - 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 + } + } } + /// Configures the internal observers for the model. private func configureCancellables() { var c = Set() - $hideApplicationMenus + $enableAlwaysHiddenSection .receive(on: DispatchQueue.main) - .sink { shouldHide in - Defaults.set(shouldHide, forKey: .hideApplicationMenus) + .sink { enable in + Defaults.set(enable, forKey: .enableAlwaysHiddenSection) } .store(in: &c) - $showSectionDividers + $showAllSectionsOnUserDrag .receive(on: DispatchQueue.main) - .sink { shouldShow in - Defaults.set(shouldShow, forKey: .showSectionDividers) + .sink { showAll in + Defaults.set(showAll, forKey: .showAllSectionsOnUserDrag) } .store(in: &c) - $enableAlwaysHiddenSection + $sectionDividerStyle .receive(on: DispatchQueue.main) - .sink { enable in - Defaults.set(enable, forKey: .enableAlwaysHiddenSection) + .sink { style in + Defaults.set(style.rawValue, forKey: .sectionDividerStyle) } .store(in: &c) - $canToggleAlwaysHiddenSection + $hideApplicationMenus .receive(on: DispatchQueue.main) - .sink { canToggle in - Defaults.set(canToggle, forKey: .canToggleAlwaysHiddenSection) + .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) @@ -107,23 +118,23 @@ 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/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 85% rename from Ice/Settings/SettingsManagers/GeneralSettingsManager.swift rename to Ice/Settings/Models/GeneralSettings.swift index d4d0cd583..31c61b1d3 100644 --- a/Ice/Settings/SettingsManagers/GeneralSettingsManager.swift +++ b/Ice/Settings/Models/GeneralSettings.swift @@ -1,13 +1,17 @@ // -// GeneralSettingsManager.swift +// GeneralSettings.swift // Ice // import Combine -import Foundation +import OSLog +import SwiftUI +// 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 @@ -71,15 +75,14 @@ final class GeneralSettingsManager: ObservableObject { /// The shared app state. private(set) weak var appState: AppState? - init(appState: AppState) { + /// Performs the initial setup of the model. + func performSetup(with appState: AppState) { self.appState = appState - } - - func performSetup() { loadInitialState() configureCancellables() } + /// Loads the model's initial state. private func loadInitialState() { Defaults.ifPresent(key: .showIceIcon, assign: &showIceIcon) Defaults.ifPresent(key: .customIceIconIsTemplate, assign: &customIceIconIsTemplate) @@ -106,7 +109,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 @@ -114,6 +117,7 @@ final class GeneralSettingsManager: ObservableObject { } } + /// Configures the internal observers for the model. private func configureCancellables() { var c = Set() @@ -137,7 +141,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) @@ -217,10 +221,25 @@ final class GeneralSettingsManager: ObservableObject { } } -// MARK: GeneralSettingsManager: BindingExposable -extension GeneralSettingsManager: BindingExposable { } - -// MARK: - Logger -private extension Logger { - static let generalSettingsManager = Logger(category: "GeneralSettingsManager") +// 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/Models/HotkeysSettings.swift b/Ice/Settings/Models/HotkeysSettings.swift new file mode 100644 index 000000000..c590af5a3 --- /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 + withMutableCopy(of: 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 ea8c2cbeb..000000000 --- a/Ice/Settings/SettingsManagers/HotkeySettingsManager.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// HotkeySettingsManager.swift -// Ice -// - -import Combine -import Foundation - -@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? - - init(appState: AppState) { - self.appState = appState - } - - func performSetup() { - 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.hotkeySettingsManager.error("Error decoding hotkey: \(error)") - } - } - } - } - } - - 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.assignAppState(appState) - do { - dict[hotkey.action.rawValue] = try self.encoder.encode(hotkey.keyCombination) - } catch { - Logger.hotkeySettingsManager.error("Error encoding hotkey: \(error)") - } - } - Defaults.set(dict, forKey: .hotkeys) - } - .store(in: &c) - - cancellables = c - } - - func hotkey(withAction action: HotkeyAction) -> Hotkey? { - hotkeys.first { $0.action == action } - } -} - -// MARK: - Logger -private extension Logger { - static let hotkeySettingsManager = Logger(category: "HotkeySettingsManager") -} diff --git a/Ice/Settings/SettingsManagers/SettingsManager.swift b/Ice/Settings/SettingsManagers/SettingsManager.swift deleted file mode 100644 index fca81bd95..000000000 --- a/Ice/Settings/SettingsManagers/SettingsManager.swift +++ /dev/null @@ -1,63 +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() - - /// 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() { - configureCancellables() - generalSettingsManager.performSetup() - advancedSettingsManager.performSetup() - hotkeySettingsManager.performSetup() - } - - 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/AboutSettingsPane.swift b/Ice/Settings/SettingsPanes/AboutSettingsPane.swift index 2e1cb0912..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 - Spacer(minLength: 20) - bottomBar + 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(containerShape: Capsule(style: cornerStyle)) } - .padding(30) } @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 @@ -74,22 +81,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) } } } @@ -110,7 +118,7 @@ struct AboutSettingsPane: View { private var automaticallyCheckForUpdates: some View { Toggle( "Automatically check for updates", - isOn: updatesManager.bindings.automaticallyChecksForUpdates + isOn: $updatesManager.automaticallyChecksForUpdates ) } @@ -118,7 +126,7 @@ struct AboutSettingsPane: View { private var automaticallyDownloadUpdates: some View { Toggle( "Automatically download updates", - isOn: updatesManager.bindings.automaticallyDownloadsUpdates + isOn: $updatesManager.automaticallyDownloadsUpdates ) } @@ -135,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) @@ -156,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) } } @@ -165,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 47d19e5a3..8aef63fba 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 { @@ -28,19 +25,16 @@ struct AdvancedSettingsPane: View { var body: some View { IceForm { - IceSection { - hideApplicationMenus - showSectionDividers - showAllSectionsOnUserDrag - showContextMenuOnRightClick - } - IceSection { + IceSection("Menu Bar Sections") { enableAlwaysHiddenSection - canToggleAlwaysHiddenSection + showAllSectionsOnUserDrag + sectionDividerStyle } - IceSection { - showOnHoverDelaySlider - tempShowIntervalSlider + IceSection("Other") { + hideApplicationMenus + enableSecondaryContextMenu + showOnHoverDelay + tempShowInterval } IceSection("Permissions") { allPermissions @@ -49,105 +43,108 @@ 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") + private var enableAlwaysHiddenSection: some View { + Toggle( + "Enable the always-hidden section", + isOn: $settings.enableAlwaysHiddenSection + ) } @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 showAllSectionsOnUserDrag: some View { + Toggle( + "Show all sections when ⌘ Command + dragging menu bar items", + isOn: $settings.showAllSectionsOnUserDrag + ) + } + + @ViewBuilder + private var sectionDividerStyle: some View { + IcePicker("Section divider style", selection: $settings.sectionDividerStyle) { + ForEach(SectionDividerStyle.allCases) { style in + Text(style.localized).tag(style) } + } } @ViewBuilder - private var enableAlwaysHiddenSection: some View { - Toggle("Enable always-hidden section", isOn: manager.bindings.enableAlwaysHiddenSection) + private var hideApplicationMenus: some View { + Toggle( + "Hide app menus when showing menu bar items", + isOn: $settings.hideApplicationMenus + ) + .annotation { + Text( + """ + Make more room in the menu bar by hiding the current app menus if \ + needed. macOS requires Ice to make itself visible in the Dock while \ + this setting is in effect. + """ + ) + .padding(.trailing, 75) + } } @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 enableSecondaryContextMenu: some View { + Toggle( + "Enable secondary context menu", + isOn: $settings.enableSecondaryContextMenu + ) + .annotation { + Text( + """ + Right-click in an empty area of the menu bar to display a minimal \ + version of Ice's menu. Disable this setting if you encounter conflicts \ + with other apps. + """ + ) + .padding(.trailing, 75) } } @ViewBuilder - private var showOnHoverDelaySlider: some View { - IceLabeledContent { + private var showOnHoverDelay: some View { + LabeledContent { IceSlider( - formattedToSeconds(manager.showOnHoverDelay), - value: manager.bindings.showOnHoverDelay, + formattedToSeconds(settings.showOnHoverDelay), + value: $settings.showOnHoverDelay, in: 0...1, step: 0.1 ) } 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 - private var tempShowIntervalSlider: some View { - IceLabeledContent { + private var tempShowInterval: some View { + LabeledContent { IceSlider( - formattedToSeconds(manager.tempShowInterval), - value: manager.bindings.tempShowInterval, - in: 0...30, + formattedToSeconds(settings.tempShowInterval), + value: $settings.tempShowInterval, + in: 0...60, step: 1 ) } 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 private var allPermissions: some View { - ForEach(appState.permissionsManager.allPermissions) { permission in - IceLabeledContent { + ForEach(appState.permissions.allPermissions) { permission in + LabeledContent { if permission.hasPermission { Label { Text("Permission Granted") @@ -167,9 +164,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..10189e055 100644 --- a/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/GeneralSettingsPane.swift @@ -8,54 +8,35 @@ 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 // Temporary state for the slider + @State private var isApplyingItemSpacingOffset = false + @State private var tempItemSpacingOffset: CGFloat = 0 - private var manager: GeneralSettingsManager { - appState.settingsManager.generalSettingsManager - } - - private var itemSpacingOffset: LocalizedStringKey { - localizedOffsetString(for: manager.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()) } } 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") } } - private var hasSpacingSliderValueChanged: Bool { - tempItemSpacingOffset != manager.itemSpacingOffset - } - - private var isActualOffsetDifferentFromDefault: Bool { - manager.itemSpacingOffset != 0 - } - var body: some View { IceForm { IceSection { - launchAtLogin + appOptions } IceSection { iceIconOptions @@ -64,32 +45,115 @@ struct GeneralSettingsPane: View { iceBarOptions } IceSection { - showOnClick - showOnHover - showOnScroll + showOptions } IceSection { - autoRehideOptions + rehideOptions } IceSection { spacingOptions } } + } + + // MARK: App Options + + @ViewBuilder + private var appOptions: some View { + LaunchAtLogin.Toggle() + } + + // MARK: Ice Icon Options + + @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.") + } + + @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: { + iceIconMenuItem(for: imageSet) + } + .tag(imageSet) + } + if let lastCustomIceIcon = settings.lastCustomIceIcon { + Button { + settings.iceIcon = lastCustomIceIcon + } label: { + iceIconMenuItem(for: lastCustomIceIcon) + } + .tag(lastCustomIceIcon) + } + } + .pickerStyle(.inline) + .labelsHidden() + + Divider() + + Button("Choose image…") { + isImportingCustomIceIcon = true + } + } title: { + iceIconMenuItem(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 + } + } .alert(isPresented: $isPresentingError, error: presentedError) { Button("OK") { presentedError = nil isPresentingError = false } } - } - @ViewBuilder - private var launchAtLogin: some View { - LaunchAtLogin.Toggle() + if case .custom = settings.iceIcon.name { + 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 with both light and dark backgrounds. + """ + ) + .padding(.trailing, 50) + } + } } @ViewBuilder - private func menuItem(for imageSet: ControlItemImageSet) -> some View { + private func iceIconMenuItem(for imageSet: ControlItemImageSet) -> some View { Label { Text(imageSet.name.rawValue) } icon: { @@ -97,10 +161,7 @@ struct GeneralSettingsPane: View { switch imageSet.name { case .custom: Image(size: CGSize(width: 18, height: 18)) { context in - context.draw( - Image(nsImage: nsImage), - in: context.clipBoundingRect - ) + context.draw(Image(nsImage: nsImage), in: context.clipBoundingRect) } default: Image(nsImage: nsImage) @@ -109,153 +170,133 @@ struct GeneralSettingsPane: View { } } - @ViewBuilder - 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 { - IceMenu("Ice icon") { - Picker("Ice icon", selection: manager.bindings.iceIcon) { - ForEach(ControlItemImageSet.userSelectableIceIcons) { imageSet in - Button { - manager.iceIcon = imageSet - } label: { - menuItem(for: imageSet) - } - .tag(imageSet) - } - if let lastCustomIceIcon = manager.lastCustomIceIcon { - Button { - manager.iceIcon = lastCustomIceIcon - } label: { - menuItem(for: lastCustomIceIcon) - } - .tag(lastCustomIceIcon) - } - } - .pickerStyle(.inline) - .labelsHidden() - - Divider() - - Button("Choose image…") { - isImportingCustomIceIcon = true - } - } title: { - menuItem(for: manager.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) - manager.iceIcon = ControlItemImageSet(name: .custom, image: .data(data)) - } - } catch { - presentedError = LocalizedErrorWrapper(error) - isPresentingError = true - } - } - - 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") - } - } - } + // MARK: Ice Bar Options @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) - .annotation("Show hidden menu bar items in a separate bar below the menu bar") + 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") + 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.") } } } + // MARK: Show Options + @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") + 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: manager.bindings.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: manager.bindings.showOnScroll) - .annotation("Scroll or swipe in the menu bar to toggle hidden menu bar items") + private var autoRehide: some View { + Toggle("Automatically rehide", isOn: $settings.autoRehide) } + @ViewBuilder + private var rehideStrategyPicker: some View { + 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.") + } + } + + if case .timed = settings.rehideStrategy { + IceSlider( + rehideIntervalKey, + value: $settings.rehideInterval, + in: 0...30, + step: 1 + ) + } + } + } + + // MARK: Spacing Options + @ViewBuilder private var spacingOptions: some View { - IceLabeledContent { + LabeledContent { IceSlider( - localizedOffsetString(for: tempItemSpacingOffset), + itemSpacingOffsetKey, value: $tempItemSpacingOffset, in: -16...16, step: 2 ) - .disabled(isApplyingOffset) + .disabled(isApplyingItemSpacingOffset) } label: { - IceLabeledContent { + 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 { @@ -268,64 +309,20 @@ 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 - } - } - - @ViewBuilder - private var rehideStrategyPicker: some View { - IcePicker("Strategy", selection: manager.bindings.rehideStrategy) { - ForEach(RehideStrategy.allCases) { strategy in - Text(strategy.localized).tag(strategy) - } - } - .annotation { - switch manager.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") - } + tempItemSpacingOffset = settings.itemSpacingOffset } } - @ViewBuilder - private var autoRehideOptions: some View { - Toggle("Automatically rehide", isOn: manager.bindings.autoRehide) - if manager.autoRehide { - if case .timed = manager.rehideStrategy { - VStack { - rehideStrategyPicker - IceSlider( - rehideIntervalKey, - value: manager.bindings.rehideInterval, - in: 0...30, - step: 1 - ) - } - } else { - rehideStrategyPicker - } - } - } - - /// Apply menu bar spacing offset. - private func applyOffset() { - isApplyingOffset = true - manager.itemSpacingOffset = tempItemSpacingOffset + private func applyTempItemSpacingOffset() { + isApplyingItemSpacingOffset = true + settings.itemSpacingOffset = tempItemSpacingOffset Task { do { try await appState.spacingManager.applyOffset() @@ -333,14 +330,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 - manager.itemSpacingOffset = tempItemSpacingOffset - applyOffset() - } } diff --git a/Ice/Settings/SettingsPanes/HotkeysSettingsPane.swift b/Ice/Settings/SettingsPanes/HotkeysSettingsPane.swift index b05cd89e9..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 hotkeySettingsManager: HotkeySettingsManager { - appState.settingsManager.hotkeySettingsManager - } + @ObservedObject var settings: HotkeysSettings var body: some View { IceForm { @@ -23,7 +20,6 @@ struct HotkeysSettingsPane: View { } IceSection("Other") { hotkeyRecorder(forAction: .enableIceBar) - hotkeyRecorder(forAction: .showSectionDividers) hotkeyRecorder(forAction: .toggleApplicationMenus) } } @@ -31,7 +27,7 @@ struct HotkeysSettingsPane: View { @ViewBuilder private func hotkeyRecorder(forAction action: HotkeyAction) -> some View { - if let hotkey = hotkeySettingsManager.hotkey(withAction: action) { + if let hotkey = settings.hotkey(withAction: action) { HotkeyRecorder(hotkey: hotkey) { switch action { case .toggleHiddenSection: @@ -42,8 +38,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..8bce92686 100644 --- a/Ice/Settings/SettingsPanes/MenuBarAppearanceSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/MenuBarAppearanceSettingsPane.swift @@ -6,15 +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) } } - -#Preview { - MenuBarAppearanceSettingsPane() - .environmentObject(AppState()) -} diff --git a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift index b10382488..a759c79b3 100644 --- a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift @@ -7,14 +7,19 @@ import SwiftUI struct MenuBarLayoutSettingsPane: View { @EnvironmentObject var appState: AppState + @ObservedObject var itemManager: MenuBarItemManager + + private var hasItems: Bool { + !itemManager.itemCache.managedItems.isEmpty + } 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 } @@ -23,43 +28,46 @@ struct MenuBarLayoutSettingsPane: View { @ViewBuilder private var header: some 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") - } + IceSection { + VStack(spacing: 3) { + Text("Drag to arrange your menu bar items into different sections.") + .font(.title3.bold()) + Text("Items can also be arranged by ⌘ Command + dragging them in the menu bar.") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) } + .padding(15) } } @ViewBuilder private var layoutBars: some View { - VStack(spacing: 25) { + VStack(spacing: 20) { ForEach(MenuBarSection.Name.allCases, id: \.self) { section in layoutBar(for: section) } } + .opacity(hasItems ? 1 : 0.75) + .blur(radius: hasItems ? 0 : 5) + .allowsHitTesting(hasItems) + .overlay { + if !hasItems { + 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 { @@ -72,18 +80,26 @@ struct MenuBarLayoutSettingsPane: View { } @ViewBuilder - private func layoutBar(for section: MenuBarSection.Name) -> some View { + 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 - 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") - .font(.system(size: 14)) - .padding(.leading, 2) + VStack(alignment: .leading) { + Text(name.localized) + .font(.headline) + .padding(.leading, 8) - LayoutBar(section: section) - .environmentObject(appState.imageCache) + LayoutBar(imageCache: appState.imageCache, section: name) } } } diff --git a/Ice/Settings/SettingsView.swift b/Ice/Settings/SettingsView.swift index 82bf84cab..82c201528 100644 --- a/Ice/Settings/SettingsView.swift +++ b/Ice/Settings/SettingsView.swift @@ -6,15 +6,28 @@ import SwiftUI struct SettingsView: View { - @EnvironmentObject var navigationState: AppNavigationState - @Environment(\.sidebarRowSize) var sidebarRowSize + @EnvironmentObject var appState: AppState + @ObservedObject var navigationState: AppNavigationState + @Environment(\.appearsActive) private var appearsActive + @Environment(\.sidebarRowSize) private var sidebarRowSize + + private let sidebarPadding: CGFloat = 3 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: 215 + case .large: 230 + @unknown default: 215 + } } } @@ -27,7 +40,7 @@ struct SettingsView: View { } } - private var sidebarItemFontSize: CGFloat { + private var sidebarFontSize: CGFloat { switch sidebarRowSize { case .small: 13 case .medium: 15 @@ -36,73 +49,98 @@ struct SettingsView: View { } } + private var sidebarTextStyle: some ShapeStyle { + appearsActive ? .primary : .secondary + } + + private var navigationTitle: LocalizedStringKey { + navigationState.settingsNavigationIdentifier.localized + } + var body: some View { NavigationSplitView { sidebar } detail: { detailView } - .navigationTitle(navigationState.settingsNavigationIdentifier.localized) + .navigationTitle(navigationTitle) } @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: { Text("Ice") - .font(.system(size: 36, weight: .medium)) - .foregroundStyle(.primary) - .padding(.vertical, 5) + .font(.system(size: sidebarFontSize * 2.67, weight: .medium)) + .foregroundStyle(sidebarTextStyle) + .padding(.leading, sidebarPadding) + .padding(.bottom, sidebarFontSize) } .collapsible(false) } .scrollDisabled(true) - .removeSidebarToggle() - .navigationSplitViewColumnWidth(sidebarWidth) - } - - @ViewBuilder - private var detailView: some View { - switch navigationState.settingsNavigationIdentifier { - case .general: - GeneralSettingsPane() - case .menuBarLayout: - MenuBarLayoutSettingsPane() - case .menuBarAppearance: - MenuBarAppearanceSettingsPane() - case .hotkeys: - HotkeysSettingsPane() - case .advanced: - AdvancedSettingsPane() - case .about: - AboutSettingsPane() + .toolbar(removing: .sidebarToggle) + .toolbar { + sidebarToolbarSpacer } + .navigationSplitViewColumnWidth(sidebarWidth) } @ViewBuilder 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).view + 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) + } + } } - 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 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(settings: appState.settings.general) + case .menuBarLayout: + MenuBarLayoutSettingsPane(itemManager: appState.itemManager) + case .menuBarAppearance: + MenuBarAppearanceSettingsPane(appearanceManager: appState.appearanceManager) + case .hotkeys: + HotkeysSettingsPane(settings: appState.settings.hotkeys) + case .advanced: + AdvancedSettingsPane(settings: appState.settings.advanced) + case .about: + AboutSettingsPane(updatesManager: appState.updatesManager) } } } diff --git a/Ice/Settings/SettingsWindow.swift b/Ice/Settings/SettingsWindow.swift index b8bc94b08..e9e94948e 100644 --- a/Ice/Settings/SettingsWindow.swift +++ b/Ice/Settings/SettingsWindow.swift @@ -3,26 +3,69 @@ // Ice // +import Combine import SwiftUI +// MARK: - SettingsWindow + struct SettingsWindow: Scene { @ObservedObject var appState: AppState + @StateObject private var model = SettingsWindowModel() var body: some Scene { - Window(Constants.settingsWindowTitle, id: Constants.settingsWindowID) { - SettingsView() - .readWindow { window in - guard let window else { - return - } - appState.assignSettingsWindow(window) + IceWindow(id: .settings) { + 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) - .environmentObject(appState.navigationState) + } +} + +// 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/IceForm.swift b/Ice/UI/IceUI/IceForm.swift index 6e62b4496..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 @@ -16,8 +15,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 +27,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() @@ -42,26 +41,27 @@ struct IceForm: View { } var body: some View { - if isScrollEnabled { - GeometryReader { geometry in - if contentFrame.height > geometry.size.height { - ScrollView { - contentStack - } - .scrollContentBackground(.hidden) - } else { - contentStack - } + GeometryReader { geometry in + ScrollView { + contentLayout.frame( + maxWidth: geometry.size.width, + minHeight: geometry.size.height, + alignment: .top + ) } - } else { - contentStack + .scrollContentBackground(.hidden) + .scrollIndicatorsFlash(onAppear: true) + .scrollDisabled(contentFrame.height > 0 && contentFrame.height <= geometry.size.height) } + .focusSection() + .accessibilityElement(children: .contain) } @ViewBuilder - private var contentStack: some View { + private var contentLayout: some View { VStack(alignment: alignment, spacing: spacing) { content + .labeledContentStyle(IceFormLabeledContentStyle()) .toggleStyle(IceFormToggleStyle()) } .padding(padding) @@ -69,17 +69,39 @@ struct IceForm: View { } } -private struct IceFormToggleStyle: ToggleStyle { +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) } } } + +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 = { + 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/IceGradientPicker.swift b/Ice/UI/IceUI/IceGradientPicker.swift new file mode 100644 index 000000000..87daf2e52 --- /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 { + LabeledContent { + 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/IceUI/IceGroupBox.swift b/Ice/UI/IceUI/IceGroupBox.swift index 09b23685c..e9cd5c137 100644 --- a/Ice/UI/IceUI/IceGroupBox.swift +++ b/Ice/UI/IceUI/IceGroupBox.swift @@ -9,14 +9,26 @@ 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: 11, style: .continuous) + } else { + RoundedRectangle(cornerRadius: 7, style: .circular) + } + } + + private var borderStyle: some ShapeStyle { + if #available(macOS 26.0, *) { + AnyShapeStyle(Color.clear) + } else { + AnyShapeStyle(Color.primary.quaternary) + } } init( - padding: CGFloat = 10, + padding: EdgeInsets = .iceGroupBoxDefaultPadding, @ViewBuilder header: () -> Header, @ViewBuilder content: () -> Content, @ViewBuilder footer: () -> Footer @@ -28,7 +40,36 @@ 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 { + self.init(padding: padding) { + EmptyView() + } content: { + content() + } footer: { + footer() + } + } + + init( + padding: CGFloat, @ViewBuilder content: () -> Content, @ViewBuilder footer: () -> Footer ) where Header == EmptyView { @@ -42,7 +83,7 @@ struct IceGroupBox: View { } init( - padding: CGFloat = 10, + padding: EdgeInsets = .iceGroupBoxDefaultPadding, @ViewBuilder header: () -> Header, @ViewBuilder content: () -> Content ) where Footer == EmptyView { @@ -56,7 +97,34 @@ struct IceGroupBox: View { } init( - padding: CGFloat = 10, + padding: CGFloat, + @ViewBuilder header: () -> Header, + @ViewBuilder content: () -> Content + ) where Footer == EmptyView { + self.init(padding: padding) { + header() + } content: { + content() + } footer: { + EmptyView() + } + } + + init( + padding: EdgeInsets = .iceGroupBoxDefaultPadding, + @ViewBuilder content: () -> Content + ) where Header == EmptyView, Footer == EmptyView { + self.init(padding: padding) { + EmptyView() + } content: { + content() + } footer: { + EmptyView() + } + } + + init( + padding: CGFloat, @ViewBuilder content: () -> Content ) where Header == EmptyView, Footer == EmptyView { self.init(padding: padding) { @@ -70,12 +138,23 @@ struct IceGroupBox: View { init( _ title: LocalizedStringKey, - padding: CGFloat = 10, + padding: EdgeInsets = .iceGroupBoxDefaultPadding, @ViewBuilder content: () -> Content ) where Header == Text, Footer == EmptyView { self.init(padding: padding) { - Text(title) - .font(.headline) + Text(title).font(.headline) + } content: { + content() + } + } + + init( + _ title: LocalizedStringKey, + padding: CGFloat, + @ViewBuilder content: () -> Content + ) where Header == Text, Footer == EmptyView { + self.init(padding: padding) { + Text(title).font(.headline) } content: { content() } @@ -84,19 +163,34 @@ struct IceGroupBox: View { var body: some View { VStack(alignment: .leading) { header - VStack { - content - } - .padding(padding) - .background { - backgroundShape - .fill(.quinary) - .overlay { - backgroundShape - .strokeBorder(.quaternary) - } - } + .accessibilityAddTraits(.isHeader) + .padding([.top, .leading], 8) + .padding(.bottom, 2) + + contentStack + .padding(padding) + .background { + backgroundShape + .fill(Color.primary.quinary) + .strokeBorder(borderStyle) + } + .containerShape(backgroundShape) + footer + .padding([.bottom, .leading], 8) + .padding(.top, 2) } + .focusSection() + .accessibilityElement(children: .contain) } + + @ViewBuilder + private var contentStack: some View { + VStack { content } + } +} + +extension EdgeInsets { + /// The default padding for an ``IceGroupBox``. + static let iceGroupBoxDefaultPadding = EdgeInsets(all: 12) } 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 40f2ec70e..a0092dda3 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,50 +85,62 @@ struct IceSection: View { init( _ title: LocalizedStringKey, - spacing: CGFloat = 10, + spacing: CGFloat = .iceSectionDefaultSpacing, options: IceSectionOptions = .default, @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) + .padding([.top, .leading], 8) + .padding(.bottom, 2) + + contentLayout + + footer + .padding([.bottom, .leading], 8) + .padding(.top, 2) + } + .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,9 +151,28 @@ 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/IceUI/IceSlider.swift b/Ice/UI/IceUI/IceSlider.swift index 4d2f911ea..c956ebae2 100644 --- a/Ice/UI/IceUI/IceSlider.swift +++ b/Ice/UI/IceUI/IceSlider.swift @@ -6,54 +6,67 @@ 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 { + if #available(macOS 26.0, *) { + RoundedRectangle(cornerRadius: 6, style: .continuous) + } else { + RoundedRectangle(cornerRadius: 5, style: .circular) } } + private var height: CGFloat { + if #available(macOS 26.0, *) { 24 } else { 22 } + } + var body: some View { CompactSlider( - value: value, + value: $value, in: bounds, - step: step, - handleVisibility: .hovering(width: 1) + step: step ?? 0, + handleVisibility: .hovering(width: 0), + minHeight: 0, + gestureOptions: .default.subtracting([.scrollWheel]) ) { valueLabel - .textSelection(valueLabelSelectability) + .frame(height: height) } .compactSliderDisabledHapticFeedback(true) + .compactSliderSecondaryColor( + progressColor: .accentColor.opacity(0.5), + focusedProgressColor: .accentColor.opacity(0.75) + ) + .clipShape(borderShape) + .contentShape([.interaction, .focusEffect], borderShape) } } diff --git a/Ice/UI/IceUI/IceWindow.swift b/Ice/UI/IceUI/IceWindow.swift new file mode 100644 index 000000000..b19c56a3c --- /dev/null +++ b/Ice/UI/IceUI/IceWindow.swift @@ -0,0 +1,132 @@ +// +// 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 { + 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) { + windowContentView + } + .defaultLaunchBehavior(.suppressed) + } + + 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) + } + } + } +} + +// 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/LayoutBar/LayoutBar.swift b/Ice/UI/LayoutBar/LayoutBar.swift deleted file mode 100644 index 01ac6219f..000000000 --- a/Ice/UI/LayoutBar/LayoutBar.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// LayoutBar.swift -// Ice -// - -import SwiftUI - -struct LayoutBar: View { - private struct Representable: NSViewRepresentable { - let appState: AppState - let section: MenuBarSection - let spacing: CGFloat - - func makeNSView(context: Context) -> LayoutBarScrollView { - LayoutBarScrollView(appState: appState, section: section, spacing: spacing) - } - - func updateNSView(_ nsView: LayoutBarScrollView, context: Context) { - nsView.spacing = spacing - } - } - - @EnvironmentObject var appState: AppState - @EnvironmentObject var imageCache: MenuBarItemImageCache - - let section: MenuBarSection - let spacing: CGFloat - - private var menuBarManager: MenuBarManager { - appState.menuBarManager - } - - private var backgroundShape: some InsettableShape { - RoundedRectangle(cornerRadius: 9, style: .circular) - } - - init(section: MenuBarSection, spacing: CGFloat = 0) { - self.section = section - self.spacing = spacing - } - - var body: some View { - conditionalBody - .frame(height: 50) - .frame(maxWidth: .infinity) - .layoutBarStyle(appState: appState, averageColorInfo: menuBarManager.averageColorInfo) - .clipShape(backgroundShape) - .overlay { - backgroundShape - .stroke(.quaternary) - } - } - - @ViewBuilder - private var conditionalBody: some View { - if imageCache.cacheFailed(for: section.name) { - 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) - } - } -} 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/Modifiers/LocalEventMonitorModifier.swift b/Ice/UI/Modifiers/LocalEventMonitorModifier.swift new file mode 100644 index 000000000..75acc4ffe --- /dev/null +++ b/Ice/UI/Modifiers/LocalEventMonitorModifier.swift @@ -0,0 +1,64 @@ +// +// LocalEventMonitorModifier.swift +// Ice +// + +import Combine +import SwiftUI + +private struct LocalEventMonitorModifier: ViewModifier { + @MainActor + private final class Model: ObservableObject { + @Published var isEnabled = false + + 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() + } + } + + @StateObject private var model: Model + @Binding var isEnabled: Bool + + 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.onChange(of: isEnabled, initial: true) { _, newValue in + model.isEnabled = newValue + } + } +} + +extension View { + /// 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. + /// - 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/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/Modifiers/OnKeyDown.swift b/Ice/UI/Modifiers/OnKeyDown.swift new file mode 100644 index 000000000..e11f01d93 --- /dev/null +++ b/Ice/UI/Modifiers/OnKeyDown.swift @@ -0,0 +1,40 @@ +// +// OnKeyDown.swift +// Ice +// + +import SwiftUI + +extension View { + /// Returns a view that performs the given action when + /// the specified key is pressed. + 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 { + 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/Modifiers/OnWindowChange.swift b/Ice/UI/Modifiers/OnWindowChange.swift new file mode 100644 index 000000000..3ee3336cd --- /dev/null +++ b/Ice/UI/Modifiers/OnWindowChange.swift @@ -0,0 +1,55 @@ +// +// OnWindowChange.swift +// Ice +// + +import SwiftUI + +private struct WindowReaderView: NSViewRepresentable { + private final class Represented: NSView { + var action: ((NSWindow?) -> Void)? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if let action { + // Wrap the action in a Task to prevent SwiftUI update conflicts. + Task { + action(window) + } + } + } + } + + var action: (NSWindow?) -> Void + + func makeNSView(context: Context) -> NSView { + let view = Represented() + view.action = action + return view + } + + func updateNSView(_: NSView, 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`. + 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`. + func onWindowChange(update binding: Binding) -> some View { + onWindowChange { window in + binding.wrappedValue = window + } + } +} diff --git a/Ice/UI/Modifiers/Once.swift b/Ice/UI/Modifiers/Once.swift new file mode 100644 index 000000000..183d14f75 --- /dev/null +++ b/Ice/UI/Modifiers/Once.swift @@ -0,0 +1,70 @@ +// +// Once.swift +// Ice +// + +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 action: OnceAction + + init(action: @escaping () -> Void) { + self.action = OnceAction(action: action) + } + + func body(content: Content) -> some View { + content.onAppear { + action() + } + } +} + +extension View { + /// Adds an action to perform exactly once, before the first + /// time the view appears. + /// + /// - Parameter action: The action to perform. + func once(perform action: @escaping () -> Void) -> some View { + 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/Pickers/CustomColorPicker/CustomColorPicker.swift b/Ice/UI/Pickers/CustomColorPicker/CustomColorPicker.swift deleted file mode 100644 index a0a82451b..000000000 --- a/Ice/UI/Pickers/CustomColorPicker/CustomColorPicker.swift +++ /dev/null @@ -1,128 +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 .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 ec23585f1..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) - } - .readWindow(window: $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/UI/Shapes/AnyInsettableShape.swift b/Ice/UI/Shapes/AnyInsettableShape.swift index 970fe3e16..2d8ab6509 100644 --- a/Ice/UI/Shapes/AnyInsettableShape.swift +++ b/Ice/UI/Shapes/AnyInsettableShape.swift @@ -7,12 +7,10 @@ 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) { + init(_ shape: S) { self.base = shape } 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..2fdc4d042 --- /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.ColorAveragingOption = []) -> 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/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/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 deleted file mode 100644 index a6ca6ce61..000000000 --- a/Ice/UI/ViewModifiers/LocalEventMonitorModifier.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// LocalEventMonitorModifier.swift -// Ice -// - -import SwiftUI - -private final class LocalEventMonitorModifierState: ObservableObject { - let monitor: LocalEventMonitor - - init(mask: NSEvent.EventTypeMask, action: @escaping (NSEvent) -> NSEvent?) { - self.monitor = LocalEventMonitor(mask: mask, handler: action) - self.monitor.start() - } - - deinit { - monitor.stop() - } -} - -private struct LocalEventMonitorModifier: ViewModifier { - @StateObject private var state: LocalEventMonitorModifierState - - init(mask: NSEvent.EventTypeMask, action: @escaping (NSEvent) -> NSEvent?) { - let state = LocalEventMonitorModifierState(mask: mask, action: action) - self._state = StateObject(wrappedValue: state) - } - - func body(content: Content) -> some View { - content - } -} - -extension View { - /// Returns a view that performs the given action when events - /// specified by the given 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)) - } -} diff --git a/Ice/UI/ViewModifiers/OnKeyDown.swift b/Ice/UI/ViewModifiers/OnKeyDown.swift deleted file mode 100644 index a28759180..000000000 --- a/Ice/UI/ViewModifiers/OnKeyDown.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// OnKeyDown.swift -// Ice -// - -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 - if event.keyCode == key.rawValue { - action() - return nil - } - return event - } - } -} diff --git a/Ice/UI/ViewModifiers/Once.swift b/Ice/UI/ViewModifiers/Once.swift deleted file mode 100644 index d7d116e9f..000000000 --- a/Ice/UI/ViewModifiers/Once.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// Once.swift -// Ice -// - -import SwiftUI - -private struct OnceModifier: ViewModifier { - @State private var hasAppeared = false - - let onAppear: () -> Void - - func body(content: Content) -> some View { - content.onAppear { - if !hasAppeared { - onAppear() - hasAppeared = true - } - } - } -} - -extension View { - /// Adds an action to perform exactly once, before the first - /// time the view appears. - /// - /// - Parameter action: The action to perform. - func once(perform action: @escaping () -> Void) -> some View { - modifier(OnceModifier(onAppear: 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/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/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/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/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/UI/Views/HotkeyRecorder.swift b/Ice/UI/Views/HotkeyRecorder.swift new file mode 100644 index 000000000..77455826e --- /dev/null +++ b/Ice/UI/Views/HotkeyRecorder.swift @@ -0,0 +1,238 @@ +// +// 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 { + LabeledContent { + segmentStack + } label: { + label + } + .alert( + "Hotkey is reserved by macOS", + isPresented: $model.isPresentingSystemReservedError + ) { + Button("OK") { + model.isPresentingSystemReservedError = false + } + } + } + + @ViewBuilder + private var segmentStack: some View { + HStack(spacing: 1) { + leadingSegment + trailingSegment + } + .frame(width: 132, height: 24) + } + + @ViewBuilder + private var leadingSegment: some View { + Button { + if model.isRecording { + model.stopRecording() + } else { + 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) = if model.isRecording { + ("escape", "Cancel", 6.0) + } else if model.hotkey.isEnabled { + ("xmark", "Clear", 7.5) + } else { + ("record.circle", "Record", 5.5) + } + Image(systemName: name) + .resizable() + .aspectRatio(1, contentMode: .fit) + .padding(padding) + .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 = EventMonitor.local(for: .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 { + let isProminent = configuration.isPressed != isHighlighted + borderShape + .fill(isProminent ? .tertiary : .quaternary) + .opacity(isProminent ? 0.5 : 0.75) + .overlay { + configuration.label + .lineLimit(1) + .foregroundStyle(.primary) + } + .contentShape([.interaction, .focusEffect], borderShape) + } +} 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 ba7dfae98..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 @@ -74,24 +71,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 +144,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 } } @@ -180,25 +180,56 @@ 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: withMutableCopy(of: environment) { $0.colorScheme = .dark }) + } else { + Color.primary.resolve(in: environment) + } + } + + 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 } @@ -214,10 +245,4 @@ private struct SectionedListItemView: View { itemFrames[item.id] = frame } } - - @ViewBuilder - private var itemBackground: some View { - VisualEffectView(material: .selection, blendingMode: .withinWindow) - .clipShape(RoundedRectangle(cornerRadius: 5, style: .circular)) - } } diff --git a/Ice/UserNotifications/UserNotificationManager.swift b/Ice/UserNotifications/UserNotificationManager.swift index 8aa9bbab6..948d74ab2 100644 --- a/Ice/UserNotifications/UserNotificationManager.swift +++ b/Ice/UserNotifications/UserNotificationManager.swift @@ -3,6 +3,7 @@ // Ice // +import OSLog import UserNotifications /// Manager for user notifications. @@ -14,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 } @@ -31,7 +27,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 +79,3 @@ extension UserNotificationManager: @preconcurrency UNUserNotificationCenterDeleg } } } - -// MARK: - Logger -private extension Logger { - static let userNotifications = Logger(category: "UserNotifications") -} diff --git a/Ice/Utilities/BindingExposable.swift b/Ice/Utilities/BindingExposable.swift deleted file mode 100644 index bb3712bb6..000000000 --- a/Ice/Utilities/BindingExposable.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// BindingExposable.swift -// Ice -// - -import SwiftUI - -/// A type that exposes its writable properties as bindings. -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. -@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]) - } -} diff --git a/Ice/Utilities/ConcurrencyHelpers.swift b/Ice/Utilities/ConcurrencyHelpers.swift new file mode 100644 index 000000000..9d93fb6e7 --- /dev/null +++ b/Ice/Utilities/ConcurrencyHelpers.swift @@ -0,0 +1,112 @@ +// +// 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) + } + } +} 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 8349b99e6..e0d7f562f 100644 --- a/Ice/Utilities/Defaults.swift +++ b/Ice/Utilities/Defaults.swift @@ -137,55 +137,44 @@ 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 hideApplicationMenus = "HideApplicationMenus" - case showSectionDividers = "ShowSectionDividers" case enableAlwaysHiddenSection = "EnableAlwaysHiddenSection" - case canToggleAlwaysHiddenSection = "CanToggleAlwaysHiddenSection" + case showAllSectionsOnUserDrag = "ShowAllSectionsOnUserDrag" + case sectionDividerStyle = "SectionDividerStyle" + case hideApplicationMenus = "HideApplicationMenus" + case enableSecondaryContextMenu = "EnableSecondaryContextMenu" case showOnHoverDelay = "ShowOnHoverDelay" case tempShowInterval = "TempShowInterval" - case showAllSectionsOnUserDrag = "ShowAllSectionsOnUserDrag" - case showContextMenuOnRightClick = "ShowContextMenuOnRightClick" - - // 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" 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 - - case sections = "Sections" + // MARK: Deprecated (Appearance Settings) case menuBarHasBorder = "MenuBarHasBorder" case menuBarBorderColor = "MenuBarBorderColor" case menuBarBorderWidth = "MenuBarBorderWidth" @@ -197,5 +186,12 @@ extension Defaults { case menuBarFullShapeInfo = "MenuBarFullShapeInfo" case menuBarSplitShapeInfo = "MenuBarSplitShapeInfo" case menuBarAppearanceConfiguration = "MenuBarAppearanceConfiguration" + + // 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 fb824db2b..31db49b43 100644 --- a/Ice/Utilities/Extensions.swift +++ b/Ice/Utilities/Extensions.swift @@ -11,27 +11,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 } @@ -53,43 +64,36 @@ 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 { - // MARK: Average Color + // MARK: Color Averaging + + /// Options that effect how colors are processed when computing + /// an average color. + struct ColorAveragingOption: OptionSet { + let rawValue: Int + + /// Includes the alpha component in the resulting average. + static let ignoreAlpha = ColorAveragingOption(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: ColorAveragingOption = []) -> 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, @@ -97,8 +101,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 } @@ -106,74 +110,94 @@ 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 + // 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, @@ -181,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) @@ -285,29 +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 + /// - 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() } } @@ -315,28 +356,69 @@ extension CGImage { 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 } + /// tag appears in the collection. + func firstIndex(matching tag: MenuBarItemTag) -> Index? { + firstIndex { $0.tag == tag } } } // MARK: - Comparable extension Comparable { - /// Returns a copy of this value that has been clamped within the bounds - /// of the given limiting range. + /// Returns a copy of this value, clamped to the given minimum + /// and maximum limiting values. + /// + /// - Parameters: + /// - min: The minimum limiting value. + /// - max: The maximum limiting value. + /// + /// - Precondition: `min <= max` /// - /// - Parameter limits: A closed range within which to clamp this value. - func clamped(to limits: ClosedRange) -> Self { - min(max(self, limits.lowerBound), limits.upperBound) + /// - 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) + } +} + +// MARK: - DistributedNotificationCenter + +extension DistributedNotificationCenter { + /// A notification posted whenever the system-wide interface theme changes. + static let interfaceThemeChangedNotification = Notification.Name("AppleInterfaceThemeChangedNotification") } // 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) } @@ -425,6 +507,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. @@ -455,8 +545,48 @@ 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 + 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 = AXHelpers.element(at: displayBounds.origin), + AXHelpers.role(for: menuBar) == .menuBar + else { + return nil + } + + 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) + } + } + + if applicationMenuFrame.width <= 0 || applicationMenuFrame.isNull { + return nil + } + + // 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, + let notchedScreen = NSScreen.screens.first(where: { $0.hasNotch }), + let leftArea = notchedScreen.auxiliaryTopLeftArea, + applicationMenuFrame.width >= leftArea.maxX + { + return nil + } + + return applicationMenuFrame } } @@ -477,19 +607,91 @@ extension NSStatusItem { // MARK: - Publisher extension Publisher { - /// Transforms all elements from the upstream publisher into `Void` values. - func mapToVoid() -> some Publisher { - map { _ in () } + /// Replaces each upstream element with an element returned from + /// the given closure. + /// + /// - Parameter output: A closure that returns a new element to + /// publish in place of the upstream element. + func replace(_ output: @escaping () -> T) -> Publishers.Map { + map { _ in output() } + } + + /// Replaces each upstream element with the given 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 } + } + + /// 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. + /// + /// - 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 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. + /// + /// - Returns: A publisher that emits an event when any upstream publisher + /// emits an event. + func mergeMap( + _ transform: @escaping (Output.Element) -> P + ) -> some Publisher where Output: Sequence, Failure == Never { + flatMap { sequence in + Publishers.MergeMany(sequence.map(transform)) + } + } +} + +// 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 } + } +} + +// 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 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 tag. + func first(matching tag: MenuBarItemTag) -> MenuBarItem? { + first { $0.tag == tag } } } diff --git a/Ice/Utilities/Helpers.swift b/Ice/Utilities/Helpers.swift new file mode 100644 index 000000000..43de07693 --- /dev/null +++ b/Ice/Utilities/Helpers.swift @@ -0,0 +1,17 @@ +// +// Helpers.swift +// Ice +// + +// 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/Logging.swift b/Ice/Utilities/Logging.swift deleted file mode 100644 index e0dfcf303..000000000 --- a/Ice/Utilities/Logging.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// Logging.swift -// Ice -// - -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. - init(category: String) { - self.base = os.Logger(subsystem: Constants.bundleIdentifier, category: category) - } - - /// Logs the given informative message to the logger. - func info(_ message: String) { - base.info("\(message, privacy: .public)") - } - - /// Logs the given debug message to the logger. - func debug(_ message: String) { - base.debug("\(message, privacy: .public)") - } - - /// 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)") - } -} diff --git a/Ice/Utilities/MigrationManager.swift b/Ice/Utilities/Migration.swift similarity index 65% rename from Ice/Utilities/MigrationManager.swift rename to Ice/Utilities/Migration.swift index 0141e0bf2..cd6b51b31 100644 --- a/Ice/Utilities/MigrationManager.swift +++ b/Ice/Utilities/Migration.swift @@ -1,12 +1,17 @@ // -// MigrationManager.swift +// Migration.swift // Ice // 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,38 @@ 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(), + migrate0_11_13(), + migrate0_11_13_1(), ] 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 +64,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 @@ -89,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"] @@ -100,13 +105,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 } } @@ -132,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 @@ -142,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 @@ -180,25 +185,24 @@ 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 MigrationManager.performAll(blocks: [ - migrateControlItems0_10_0, - ]) + + 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 { + 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 ) } } @@ -216,7 +220,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 } @@ -228,22 +232,24 @@ 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() - 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 +261,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 +270,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,11 +279,16 @@ 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) - let newConfiguration = with(MenuBarAppearanceConfigurationV2.defaultConfiguration) { configuration in + let newConfiguration = withMutableCopy(of: MenuBarAppearanceConfigurationV2.defaultConfiguration) { configuration in let partialConfiguration = MenuBarAppearancePartialConfiguration( hasShadow: oldConfiguration.hasShadow, hasBorder: oldConfiguration.hasBorder, @@ -297,10 +309,81 @@ 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 + } +} + +// 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: - 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 @@ -308,7 +391,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 +430,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,28 +455,22 @@ 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 private extension ControlItem.Identifier { - var deprecatedRawValue: String { + var rawValue0_8_0: String { switch self { - case .iceIcon: "IceIcon" + case .visible: "IceIcon" + case .hidden: "HItem" + case .alwaysHidden: "AHItem" + } + } + + var rawValue0_10_0: String { + switch self { + case .visible: "SItem" case .hidden: "HItem" case .alwaysHidden: "AHItem" } @@ -403,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" @@ -411,8 +488,3 @@ private extension MenuBarSection.Name { } } } - -// MARK: - Logger -private extension Logger { - static let migration = Logger(category: "Migration") -} diff --git a/Ice/Utilities/MouseCursor.swift b/Ice/Utilities/MouseCursor.swift deleted file mode 100644 index e10886fbb..000000000 --- a/Ice/Utilities/MouseCursor.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// MouseCursor.swift -// Ice -// - -import CoreGraphics - -/// 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. - 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. - static var locationCoreGraphics: CGPoint? { - CGEvent(source: nil)?.location - } - - /// Hides the mouse cursor and increments the hide cursor count. - static func hide() { - let result = CGDisplayHideCursor(CGMainDisplayID()) - if result != .success { - Logger.mouseCursor.error("CGDisplayHideCursor failed with error \(result.logString)") - } - } - - /// Decrements the hide cursor count and shows the mouse cursor if the count is `0`. - static func show() { - let result = CGDisplayShowCursor(CGMainDisplayID()) - if result != .success { - Logger.mouseCursor.error("CGDisplayShowCursor failed with error \(result.logString)") - } - } - - /// 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) { - let result = CGWarpMouseCursorPosition(point) - if result != .success { - Logger.mouseCursor.error("CGWarpMouseCursorPosition failed with error \(result.logString)") - } - } -} - -// MARK: - Logger -private extension Logger { - static let mouseCursor = Logger(category: "MouseCursor") -} diff --git a/Ice/Utilities/MouseHelpers.swift b/Ice/Utilities/MouseHelpers.swift new file mode 100644 index 000000000..357fcd3c3 --- /dev/null +++ b/Ice/Utilities/MouseHelpers.swift @@ -0,0 +1,108 @@ +// +// MouseHelpers.swift +// Ice +// + +import CoreGraphics +import OSLog + +/// 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 `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 hideCursor() { + let result = CGDisplayHideCursor(CGMainDisplayID()) + if result != .success { + Logger.default.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 showCursor() { + let result = CGDisplayShowCursor(CGMainDisplayID()) + if result != .success { + Logger.default.error("CGDisplayShowCursor failed with error \(result.logString, privacy: .public)") + } + } + + /// 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 warpCursor(to point: CGPoint) { + let result = CGWarpMouseCursorPosition(point) + if result != .success { + Logger.default.error("CGWarpMouseCursorPosition failed with error \(result.logString, privacy: .public)") + } + } + + /// 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.default.error("CGAssociateMouseAndMouseCursorPosition failed with error \(result.logString, privacy: .public)") + } + } + + /// 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 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 + } +} 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 64c4c9f9a..7d464975d 100644 --- a/Ice/Utilities/Predicates.swift +++ b/Ice/Utilities/Predicates.swift @@ -34,89 +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.frame) - } - } - - /// 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.frame) - } - } -} - -// 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 - ) - - /// 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 - } - } - - /// 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 - item.frame.maxX <= hiddenControlItem.frame.minX && - item.frame.minX >= alwaysHiddenControlItem.frame.maxX - } - } else { - predicate { item in - item.frame.maxX <= hiddenControlItem.frame.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 - item.frame.maxX <= alwaysHiddenControlItem.frame.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/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/Utilities/ScreenCapture.swift b/Ice/Utilities/ScreenCapture.swift index efa69aab4..64597b682 100644 --- a/Ice/Utilities/ScreenCapture.swift +++ b/Ice/Utilities/ScreenCapture.swift @@ -8,92 +8,87 @@ 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.getMenuBarWindowList(option: [.itemsOnly, .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, *) { - // 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() } } + // 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. - 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)) - } - guard let windowArray = CFArrayCreate(kCFAllocatorDefault, pointer, windowIDs.count, nil) else { + /// - 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 } - return .windowListImage(from: screenBounds ?? .null, windowArray: windowArray, imageOption: option) + let bounds = screenBounds ?? .null + // 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. Pass `nil` to capture the minimum rectangle that encloses the window. - /// - option: Options that specify the image to be captured. - static func captureWindow(_ windowID: CGWindowID, screenBounds: CGRect? = nil, option: CGWindowImageOption = []) -> CGImage? { - captureWindows([windowID], screenBounds: screenBounds, option: option) + /// - 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) } } - -/// A protocol used to suppress deprecation warnings for the `CGWindowList` screen capture APIs. -/// -/// ScreenCaptureKit doesn't support capturing composite images of offscreen menu bar items, but -/// this should be replaced once it does. -private protocol WindowListImage { - init?(windowListFromArrayScreenBounds: CGRect, windowArray: CFArray, imageOption: CGWindowImageOption) -} - -private extension WindowListImage { - static func windowListImage(from screenBounds: CGRect, windowArray: CFArray, imageOption: CGWindowImageOption) -> Self? { - Self(windowListFromArrayScreenBounds: screenBounds, windowArray: windowArray, imageOption: imageOption) - } -} - -extension CGImage: WindowListImage { } 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/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/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 } diff --git a/Ice/Utilities/TaskTimeout.swift b/Ice/Utilities/TaskTimeout.swift deleted file mode 100644 index 93d0b77a1..000000000 --- a/Ice/Utilities/TaskTimeout.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// TaskTimeout.swift -// Ice -// - -import Foundation - -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: - /// - priority: The priority of the task. - /// - timeout: The amount of time to wait before throwing a ``TaskTimeoutError``. - /// - tolerance: The tolerance of the clock. - /// - clock: The clock to use in the timeout operation. - /// - 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 - ) { - 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: - /// - priority: The priority of the task. - /// - timeout: The amount of time to wait before throwing a ``TaskTimeoutError``. - /// - tolerance: The tolerance of the clock. - /// - clock: The clock to use in the timeout operation. - /// - 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 { - detached(priority: priority) { - try await run(operation: operation, withTimeout: timeout, tolerance: tolerance, clock: clock) - } - } - - private static func run( - operation: @escaping @Sendable () 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: Error, 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 deleted file mode 100644 index 224bcfbbc..000000000 --- a/Ice/Utilities/WindowInfo.swift +++ /dev/null @@ -1,319 +0,0 @@ -// -// WindowInfo.swift -// Ice -// - -import Cocoa - -/// Information for a window. -struct WindowInfo { - /// The window identifier associated with the window. - 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 title of the window. - let title: String? - - /// The layer number of the window. - 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 name of the application that owns the window. - /// - /// 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) - } - - /// 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 - let info = dictionary as? [CFString: CFTypeRef], - 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 - 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.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 list = CGWindowListCreateDescriptionFromArray(array) as? [CFDictionary], - let dictionary = list.first - else { - return nil - } - self.init(dictionary: dictionary) - } -} - -// 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. - /// - /// - 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) - } -} - -// 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)) - } - - /// Returns the wallpaper window for the given display. - static func getWallpaperWindow(for display: CGDirectDisplayID) -> WindowInfo? { - getWallpaperWindow(from: getOnScreenWindows(), 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)) - } - - /// Returns the menu bar window for the given display. - static func getMenuBarWindow(for display: CGDirectDisplayID) -> WindowInfo? { - getMenuBarWindow(from: getOnScreenWindows(excludeDesktopWindows: true), for: display) - } -} - -// MARK: WindowInfo: Equatable -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 && - lhs.ownerName == rhs.ownerName && - lhs.sharingState == rhs.sharingState && - lhs.backingStoreType == rhs.backingStoreType && - lhs.memoryUsage == rhs.memoryUsage && - lhs.isOnScreen == rhs.isOnScreen && - lhs.isBackedByVideoMemory == rhs.isBackedByVideoMemory - } -} - -// MARK: WindowInfo: Hashable -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(ownerName) - hasher.combine(sharingState) - hasher.combine(backingStoreType) - hasher.combine(memoryUsage) - hasher.combine(isOnScreen) - hasher.combine(isBackedByVideoMemory) - } -} diff --git a/MenuBarItemService/Listener.swift b/MenuBarItemService/Listener.swift new file mode 100644 index 000000000..064da0416 --- /dev/null +++ b/MenuBarItemService/Listener.swift @@ -0,0 +1,100 @@ +// +// Listener.swift +// MenuBarItemService +// + +import OSLog +import XPC + +/// A wrapper around an XPC listener object. +final class Listener { + /// 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() { } + + 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: + 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.default.error("Listener failed to handle message 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. + func activate() { + guard listener == nil else { + Logger.default.notice("Listener is already active") + return + } + + Logger.default.debug("Activating listener") + + if #available(macOS 26.0, *) { + do { + try uncheckedActivateWithSameTeamRequirement() + } 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)") + } + } + } + + /// Cancels the listener. + func cancel() { + Logger.default.debug("Canceling listener") + listener.take()?.cancel() + } +} diff --git a/MenuBarItemService/Resources/Info.plist b/MenuBarItemService/Resources/Info.plist new file mode 100644 index 000000000..b5c5e1140 --- /dev/null +++ b/MenuBarItemService/Resources/Info.plist @@ -0,0 +1,15 @@ + + + + + XPCService + + JoinExistingSession + + RunLoopType + NSRunLoop + ServiceType + Application + + + diff --git a/MenuBarItemService/SourcePIDCache.swift b/MenuBarItemService/SourcePIDCache.swift new file mode 100644 index 000000000..6203181f6 --- /dev/null +++ b/MenuBarItemService/SourcePIDCache.swift @@ -0,0 +1,230 @@ +// +// SourcePIDCache.swift +// MenuBarItemService +// + +import AXSwift +import Cocoa +import Combine +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. 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 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 + /// 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 currentBounds = window.currentBounds() else { + // Failure here means the window probably doesn't + // exist anymore. + return nil + } + if currentBounds == cachedBounds { + return currentBounds + } + cachedBounds = currentBounds + // Compute the sleep interval from the current 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()) + + /// Observer for running applications. + private lazy var cancellable = NSWorkspace.shared.publisher(for: \.runningApplications).sink { [weak self] runningApps in + guard let self else { + return + } + + Logger.default.debug("Received new running applications") + + 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 } + } + } + } + } + + /// Creates the shared cache. + private init() { + Bridging.setProcessUnresponsiveTimeout(3) + } + + /// Starts the observers for the cache. + func start() { + Logger.default.debug("Starting observers for source PID cache") + _ = cancellable + } + + /// 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] + } + } +} 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/Bridging/Bridging.swift b/Shared/Bridging/Bridging.swift new file mode 100644 index 000000000..d1aab8bae --- /dev/null +++ b/Shared/Bridging/Bridging.swift @@ -0,0 +1,471 @@ +// +// Bridging.swift +// Shared +// + +import Cocoa +import OSLog + +// MARK: - Bridging + +/// A namespace for bridged or wrapped APIs. +enum Bridging { + private static let logger = Logger(category: "Bridging") +} + +// MARK: - CGSConnection + +extension Bridging { + + // 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 associated with a value in the main + /// window server connection. + static func getConnectionProperty(forKey key: String) -> Any? { + var value: Unmanaged? + let result = CGSCopyConnectionProperty( + getMainConnection(), + getMainConnection(), + key as CFString, + &value + ) + if result != .success { + logger.error("CGSCopyConnectionProperty failed with error \(result.logString, privacy: .public)") + } + return value?.takeRetainedValue() + } + + /// Sets a value in the main window server connection. + /// + /// - Parameters: + /// - 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( + getMainConnection(), + getMainConnection(), + key as CFString, + value as CFTypeRef + ) + if result != .success { + logger.error("CGSSetConnectionProperty failed with error \(result.logString, privacy: .public)") + } + } +} + +// MARK: - CGDisplay / CGSDisplay + +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(getMainConnection()) else { + logger.warning("CGSCopyActiveMenuBarDisplayIdentifier returned nil, falling back to CGMainDisplayID") + return CGMainDisplayID() + } + guard let uuid = CFUUIDCreateFromString(nil, string.takeRetainedValue()) else { + logger.warning("CFUUIDCreateFromString returned nil, falling back to CGMainDisplayID") + return CGMainDisplayID() + } + 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 + } +} + +// 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(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(getMainConnection(), timeout) + if result != .success { + logger.error("CGSEventSetAppIsUnresponsiveNotificationTimeout failed with error \(result.logString, privacy: .public)") + } + } +} + +// MARK: - CGSSpace + +extension Bridging { + /// Returns the identifier for the active space. + static func getActiveSpaceID() -> CGSSpaceID { + CGSGetActiveSpace(getMainConnection()) + } + + /// 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 = 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(getMainConnection(), 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. + static func getSpaceList(for windowID: CGWindowID, visibleSpacesOnly: Bool = false) -> [CGSSpaceID] { + let mask: CGSSpaceMask = visibleSpacesOnly ? .allVisibleSpacesMask : .allSpacesMask + guard let spaces = CGSCopySpacesForWindows(getMainConnection(), 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(getMainConnection(), spaceID) + return type == .fullscreen + } +} + +// MARK: - CGSWindow + +extension Bridging { + /// 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 + let result = CGSGetScreenRectForWindow(getConnectionForThread(), windowID, &bounds) + guard result == .success else { + logger.error("CGSGetScreenRectForWindow failed with error \(result.logString, privacy: .public)") + return nil + } + return bounds + } + + /// Returns the level for the given window. + /// + /// - Parameter windowID: An identifier for a window. + static func getWindowLevel(for windowID: CGWindowID) -> CGWindowLevel? { + var level: CGWindowLevel = 0 + let result = CGSGetWindowLevel(getMainConnection(), windowID, &level) + guard result == .success else { + logger.error("CGSGetWindowLevel failed with error \(result.logString, privacy: .public)") + return nil + } + return level + } + + /// 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, visibleSpacesOnly: false) + return list.contains(spaceID) + } + + /// Returns a Boolean value that indicates whether the given window + /// intersects the given display bounds. + /// + /// - Parameters: + /// - windowID: An identifier for a window. + /// - displayBounds: The bounds of a display. + static func windowIntersectsDisplayBounds(_ windowID: CGWindowID, _ displayBounds: CGRect) -> Bool { + if let windowBounds = getWindowBounds(for: windowID) { + return displayBounds.intersects(windowBounds) + } + return false + } + + /// 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) + } + + /// 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? { + var count: Int32 = 0 + let result = CGSGetWindowCount(getMainConnection(), nullConnection, &count) + guard result == .success else { + logger.error("CGSGetWindowCount failed with error \(result.logString, privacy: .public)") + return nil + } + return count + } + + private static func getOnScreenWindowCount() -> Int32? { + var count: Int32 = 0 + let result = CGSGetOnScreenWindowCount(getMainConnection(), nullConnection, &count) + guard result == .success else { + logger.error("CGSGetOnScreenWindowCount failed with error \(result.logString, privacy: .public)") + return nil + } + return count + } + + private static func getWindowList() -> [CGWindowID] { + guard var count = getWindowCount() else { + return [] + } + var list = [CGWindowID](repeating: 0, count: Int(count)) + let result = CGSGetWindowList(getMainConnection(), nullConnection, count, &list, &count) + guard result == .success else { + logger.error("CGSGetWindowList failed with error \(result.logString, privacy: .public)") + return [] + } + return [CGWindowID](list[.. [CGWindowID] { + guard var count = getOnScreenWindowCount() else { + return [] + } + var list = [CGWindowID](repeating: 0, count: Int(count)) + let result = CGSGetOnScreenWindowList(getMainConnection(), nullConnection, count, &list, &count) + guard result == .success else { + logger.error("CGSGetOnScreenWindowList failed with error \(result.logString, privacy: .public)") + return [] + } + return [CGWindowID](list[.. [CGWindowID] { + guard var count = getWindowCount() else { + return [] + } + var list = [CGWindowID](repeating: 0, count: Int(count)) + let result = CGSGetProcessMenuBarWindowList(getMainConnection(), nullConnection, count, &list, &count) + guard result == .success else { + logger.error("CGSGetProcessMenuBarWindowList failed with error \(result.logString, privacy: .public)") + return [] + } + return [CGWindowID](list[.. [CGWindowID] { + let list = if option.contains(.onScreen) { + getOnScreenWindowList() + } else { + getWindowList() + } + if option.contains(.activeSpace) { + let activeSpaceID = getActiveSpaceID() + return list.filter { windowID in + isWindowOnSpace(windowID, activeSpaceID) + } + } + return list + } + + /// 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. + static func getMenuBarWindowList(option: MenuBarWindowListOption = []) -> [CGWindowID] { + var predicates = [(CGWindowID) -> Bool]() + + if option.contains(.onScreen) { + let onScreenList = Set(getOnScreenWindowList()) + predicates.append { windowID in + onScreenList.contains(windowID) + } + } + + if option.contains(.activeSpace) { + let activeSpaceID = getActiveSpaceID() + predicates.append { windowID in + isWindowOnSpace(windowID, activeSpaceID) + } + } + + if option.contains(.itemsOnly) { + predicates.append { windowID in + getWindowLevel(for: windowID) != kCGMainMenuWindowLevel + } + } + + return getProcessMenuBarWindowList().filter { windowID in + predicates.allSatisfy { predicate in + predicate(windowID) + } + } + } + + // MARK: - CGWindowList Helpers + + /// Creates a `CFArray` containing the bit patterns of the given + /// window list. + /// + /// 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)) + } + guard + !pointers.isEmpty, + let array = CFArrayCreate(nil, &pointers, pointers.count, nil) + else { + return nil + } + return array + } +} diff --git a/Ice/Bridging/Shims/Private.swift b/Shared/Bridging/Shims.swift similarity index 64% rename from Ice/Bridging/Shims/Private.swift rename to Shared/Bridging/Shims.swift index e03528adb..3cdf6e03f 100644 --- a/Ice/Bridging/Shims/Private.swift +++ b/Shared/Bridging/Shims.swift @@ -1,14 +1,15 @@ // -// Private.swift -// Ice +// Shims.swift +// Shared // +import ApplicationServices import CoreGraphics // MARK: - Bridged Types typealias CGSConnectionID = Int32 -typealias CGSSpaceID = size_t +typealias CGSSpaceID = Int enum CGSSpaceType: UInt32 { case user = 0 @@ -23,19 +24,22 @@ 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 +// MARK: - CGSConnection @_silgen_name("CGSMainConnectionID") func CGSMainConnectionID() -> CGSConnectionID +@_silgen_name("CGSDefaultConnectionForThread") +func CGSDefaultConnectionForThread() -> CGSConnectionID + @_silgen_name("CGSCopyConnectionProperty") func CGSCopyConnectionProperty( _ cid: CGSConnectionID, @@ -52,7 +56,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( @@ -60,7 +69,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 @@ -72,34 +87,36 @@ func CGSCopySpacesForWindows( _ windowIDs: CFArray ) -> Unmanaged? +@_silgen_name("CGSManagedDisplayGetCurrentSpace") +func CGSManagedDisplayGetCurrentSpace( + _ cid: CGSConnectionID, + _ displayUUID: CFString +) -> CGSSpaceID + @_silgen_name("CGSSpaceGetType") func CGSSpaceGetType( _ cid: CGSConnectionID, _ 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, @@ -107,17 +124,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 @@ -127,3 +148,18 @@ func CGSGetScreenRectForWindow( _ wid: CGWindowID, _ outRect: inout CGRect ) -> CGError + +@_silgen_name("CGSGetWindowLevel") +func CGSGetWindowLevel( + _ cid: CGSConnectionID, + _ wid: CGWindowID, + _ outLevel: inout CGWindowLevel +) -> CGError + +// MARK: - ProcessSerialNumber + +@_silgen_name("GetProcessForPID") +func GetProcessForPID( + _ pid: pid_t, + _ psn: inout ProcessSerialNumber +) -> OSStatus 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/Shared/Utilities/AXHelpers.swift b/Shared/Utilities/AXHelpers.swift new file mode 100644 index 000000000..d97c26a13 --- /dev/null +++ b/Shared/Utilities/AXHelpers.swift @@ -0,0 +1,48 @@ +// +// AXHelpers.swift +// Shared +// + +import AXSwift +import Cocoa + +enum AXHelpers { + private static let queue = DispatchQueue.targetingGlobal( + label: "AXHelpers.queue", + qos: .userInteractive, + attributes: .concurrent + ) + + @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? { + 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) } + } + + static func role(for element: UIElement) -> Role? { + queue.sync { try? element.role() } + } +} diff --git a/Shared/Utilities/Logging.swift b/Shared/Utilities/Logging.swift new file mode 100644 index 000000000..8a33b8c6a --- /dev/null +++ b/Shared/Utilities/Logging.swift @@ -0,0 +1,28 @@ +// +// Logging.swift +// 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: Self.subsystem, category: category) + } +} + +// MARK: - Shared Loggers + +extension Logger { + /// The default logger. + static let `default` = Logger(.default) + + /// 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/Shared/Utilities/SharedExtensions.swift b/Shared/Utilities/SharedExtensions.swift new file mode 100644 index 000000000..f52bd021a --- /dev/null +++ b/Shared/Utilities/SharedExtensions.swift @@ -0,0 +1,62 @@ +// +// 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: - 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 { + /// 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 = .default, + attributes: Attributes = [] + ) -> DispatchQueue { + let target = DispatchQueue.global(qos: qos) + return DispatchQueue(label: label, attributes: attributes, target: target) + } +} diff --git a/Shared/Utilities/WindowInfo.swift b/Shared/Utilities/WindowInfo.swift new file mode 100644 index 000000000..9b68fa333 --- /dev/null +++ b/Shared/Utilities/WindowInfo.swift @@ -0,0 +1,185 @@ +// +// WindowInfo.swift +// Shared +// + +import Cocoa + +/// Information for a window. +struct WindowInfo { + /// The window's identifier. + let windowID: CGWindowID + + /// The identifier of the process that owns the window. + let ownerPID: pid_t + + /// The window's bounds, specified in screen coordinates. + let bounds: CGRect + + /// The window's layer number. + let layer: Int + + /// The window's title. + let title: String? + + /// The name of the process that owns the window. + /// + /// This may have a value when ``owningApplication`` does not have + /// a localized name. + let ownerName: String? + + /// A Boolean value that indicates whether the window is on screen. + let isOnScreen: Bool + + /// The application that owns the window. + var owningApplication: NSRunningApplication? { + NSRunningApplication(processIdentifier: ownerPID) + } + + /// A Boolean value that indicates whether the window belongs to the + /// window server. + var isWindowServerWindow: Bool { + ownerName == "Window Server" + } + + /// Creates a window with the given dictionary. + private init?(dictionary: CFDictionary) { + guard + let info = dictionary as? [CFString: Any], + let windowID = info[kCGWindowNumber] as? CGWindowID, + let ownerPID = info[kCGWindowOwnerPID] as? pid_t, + let boundsDict = info[kCGWindowBounds] as? NSDictionary, + let bounds = CGRect(dictionaryRepresentation: boundsDict), + let layer = info[kCGWindowLayer] as? Int + else { + return nil + } + self.windowID = windowID + self.ownerPID = ownerPID + self.bounds = bounds + self.layer = layer + self.title = info[kCGWindowName] as? String + self.ownerName = info[kCGWindowOwnerName] as? String + self.isOnScreen = info[kCGWindowIsOnscreen] as? Bool ?? false + } + + /// 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 + } + + /// 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. + static func createWindows(from windowIDs: [CGWindowID]) -> [WindowInfo] { + guard + let array = Bridging.createCGWindowArray(with: windowIDs), + let list = CGWindowListCreateDescriptionFromArray(array) as? [CFDictionary] + else { + return [] + } + return list.compactMap { WindowInfo(dictionary: $0) } + } + + /// 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)) + } + + /// 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 createMenuBarWindows(option: Bridging.MenuBarWindowListOption = []) -> [WindowInfo] { + createWindows(from: Bridging.getMenuBarWindowList(option: option)) + } +} + +// 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? { + 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 && + displayBounds.contains(window.bounds) + } + } + + /// 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 + + /// 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? { + 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" && + displayBounds.contains(window.bounds) + } + } + + /// 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 { + lhs.windowID == rhs.windowID && + lhs.ownerPID == rhs.ownerPID && + NSStringFromRect(lhs.bounds) == NSStringFromRect(rhs.bounds) && + lhs.layer == rhs.layer && + lhs.title == rhs.title && + lhs.ownerName == rhs.ownerName && + lhs.isOnScreen == rhs.isOnScreen + } +} + +// MARK: WindowInfo: Hashable +extension WindowInfo: Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(windowID) + hasher.combine(ownerPID) + hasher.combine(NSStringFromRect(bounds)) + hasher.combine(layer) + hasher.combine(title) + hasher.combine(ownerName) + hasher.combine(isOnScreen) + } +}