├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml └── workflows │ └── lint.yml ├── .gitignore ├── .swiftlint.yml ├── CODE_OF_CONDUCT.md ├── FREQUENT_ISSUES.md ├── Ice.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ └── Ice.xcscheme ├── Ice ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── icon_128x128.png │ │ ├── icon_128x128@2x.png │ │ ├── icon_16x16.png │ │ ├── icon_16x16@2x.png │ │ ├── icon_256x256.png │ │ ├── icon_256x256@2x.png │ │ ├── icon_32x32.png │ │ ├── icon_32x32@2x.png │ │ ├── icon_512x512.png │ │ └── icon_512x512@2x.png │ ├── Contents.json │ ├── ControlItemImages │ │ ├── Contents.json │ │ ├── Dot │ │ │ ├── Contents.json │ │ │ ├── DotFill.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── DotFill.png │ │ │ └── DotStroke.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── DotStroke.png │ │ ├── Ellipsis │ │ │ ├── Contents.json │ │ │ ├── EllipsisFill.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── EllipsisFill.png │ │ │ └── EllipsisStroke.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── EllipsisStroke.png │ │ └── IceCube │ │ │ ├── Contents.json │ │ │ ├── IceCubeFill.imageset │ │ │ ├── Contents.json │ │ │ └── IceCubeFill.png │ │ │ └── IceCubeStroke.imageset │ │ │ ├── Contents.json │ │ │ └── IceCubeStroke.png │ ├── DefaultLayoutBarColor.colorset │ │ └── Contents.json │ └── Warning.imageset │ │ ├── Contents.json │ │ └── Warning.png ├── Bridging │ ├── Bridging.swift │ └── Shims │ │ ├── Deprecated.swift │ │ └── Private.swift ├── Events │ ├── EventManager.swift │ ├── EventMonitors │ │ ├── GlobalEventMonitor.swift │ │ ├── LocalEventMonitor.swift │ │ ├── RunLoopLocalEventMonitor.swift │ │ └── UniversalEventMonitor.swift │ └── EventTap.swift ├── Hotkeys │ ├── Hotkey.swift │ ├── HotkeyAction.swift │ ├── HotkeyRegistry.swift │ ├── KeyCode.swift │ ├── KeyCombination.swift │ └── Modifiers.swift ├── Ice.entitlements ├── Info.plist ├── Main │ ├── AppDelegate.swift │ ├── AppState.swift │ ├── IceApp.swift │ └── Navigation │ │ ├── AppNavigationState.swift │ │ └── NavigationIdentifiers │ │ ├── NavigationIdentifier.swift │ │ └── SettingsNavigationIdentifier.swift ├── MenuBar │ ├── Appearance │ │ ├── Configurations │ │ │ ├── MenuBarAppearanceConfigurationV1.swift │ │ │ └── MenuBarAppearanceConfigurationV2.swift │ │ ├── MenuBarAppearanceEditor │ │ │ ├── MenuBarAppearanceEditor.swift │ │ │ ├── MenuBarAppearanceEditorPanel.swift │ │ │ └── MenuBarShapePicker.swift │ │ ├── MenuBarAppearanceManager.swift │ │ ├── MenuBarOverlayPanel.swift │ │ ├── MenuBarShape.swift │ │ └── MenuBarTintKind.swift │ ├── ControlItem │ │ ├── ControlItem.swift │ │ ├── ControlItemImage.swift │ │ └── ControlItemImageSet.swift │ ├── MenuBarItems │ │ ├── MenuBarItem.swift │ │ ├── MenuBarItemImageCache.swift │ │ ├── MenuBarItemInfo.swift │ │ └── MenuBarItemManager.swift │ ├── MenuBarManager.swift │ ├── MenuBarSection.swift │ ├── Search │ │ └── MenuBarSearchPanel.swift │ └── Spacing │ │ └── MenuBarItemSpacingManager.swift ├── Permissions │ ├── Permission.swift │ ├── PermissionsManager.swift │ ├── PermissionsView.swift │ └── PermissionsWindow.swift ├── Resources │ ├── Acknowledgements.pdf │ └── Acknowledgements.rtf ├── Settings │ ├── SettingsManagers │ │ ├── AdvancedSettingsManager.swift │ │ ├── GeneralSettingsManager.swift │ │ ├── HotkeySettingsManager.swift │ │ └── SettingsManager.swift │ ├── SettingsPanes │ │ ├── AboutSettingsPane.swift │ │ ├── AdvancedSettingsPane.swift │ │ ├── GeneralSettingsPane.swift │ │ ├── HotkeysSettingsPane.swift │ │ ├── MenuBarAppearanceSettingsPane.swift │ │ ├── MenuBarLayoutSettingsPane.swift │ │ └── UpdatesSettingsPane.swift │ ├── SettingsView.swift │ └── SettingsWindow.swift ├── Swizzling │ └── NSSplitViewItem+swizzledCanCollapse.swift ├── UI │ ├── HotkeyRecorder │ │ ├── HotkeyRecorder.swift │ │ └── HotkeyRecorderModel.swift │ ├── IceBar │ │ ├── IceBar.swift │ │ ├── IceBarColorManager.swift │ │ └── IceBarLocation.swift │ ├── IceUI │ │ ├── IceForm.swift │ │ ├── IceGroupBox.swift │ │ ├── IceLabeledContent.swift │ │ ├── IceMenu.swift │ │ ├── IcePicker.swift │ │ ├── IcePopUpIndicator.swift │ │ ├── IceSection.swift │ │ └── IceSlider.swift │ ├── LayoutBar │ │ ├── LayoutBar.swift │ │ ├── LayoutBarContainer.swift │ │ ├── LayoutBarItemView.swift │ │ ├── LayoutBarPaddingView.swift │ │ └── LayoutBarScrollView.swift │ ├── Pickers │ │ ├── CustomColorPicker │ │ │ └── CustomColorPicker.swift │ │ └── CustomGradientPicker │ │ │ ├── ColorStop.swift │ │ │ ├── CustomGradient.swift │ │ │ └── CustomGradientPicker.swift │ ├── Shapes │ │ └── AnyInsettableShape.swift │ ├── ViewModifiers │ │ ├── BottomBar.swift │ │ ├── ErasedToAnyView.swift │ │ ├── LayoutBarStyle.swift │ │ ├── LocalEventMonitorModifier.swift │ │ ├── OnFrameChange.swift │ │ ├── OnKeyDown.swift │ │ ├── Once.swift │ │ ├── ReadWindow.swift │ │ └── RemoveSidebarToggle.swift │ └── Views │ │ ├── AnnotationView.swift │ │ ├── BetaBadge.swift │ │ ├── SectionedList.swift │ │ └── VisualEffectView.swift ├── Updates │ └── UpdatesManager.swift ├── UserNotifications │ ├── UserNotificationIdentifier.swift │ └── UserNotificationManager.swift └── Utilities │ ├── BindingExposable.swift │ ├── CodableColor.swift │ ├── Constants.swift │ ├── Defaults.swift │ ├── Extensions.swift │ ├── IconResource.swift │ ├── Injection.swift │ ├── LocalizedErrorWrapper.swift │ ├── Logging.swift │ ├── MigrationManager.swift │ ├── MouseCursor.swift │ ├── Notifications.swift │ ├── ObjectStorage.swift │ ├── Predicates.swift │ ├── RehideStrategy.swift │ ├── ScreenCapture.swift │ ├── StatusItemDefaults.swift │ ├── SystemAppearance.swift │ ├── TaskTimeout.swift │ └── WindowInfo.swift ├── LICENSE ├── README.md └── Resources ├── Icon.fig ├── Icon.png ├── rearranging.gif └── rearranging.mov /.gitattributes: -------------------------------------------------------------------------------- 1 | *.rtf linguist-vendored 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: jordanbaird 2 | buy_me_a_coffee: jordanbaird 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: Bug 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Check Existing Issues 9 | description: Check existing open and closed issues to prevent duplicates. Use the search field at the top of the issue tracker. Please only submit a new issue if it has not been covered by an existing issue, or if a regression has occurred. 10 | options: 11 | - label: I have checked existing issues, and this issue is not a duplicate 12 | required: true 13 | - type: textarea 14 | attributes: 15 | label: Description 16 | placeholder: A clear and concise description of the bug... 17 | validations: 18 | required: true 19 | - type: textarea 20 | attributes: 21 | label: Steps to Reproduce 22 | description: Steps to reliably reproduce the behavior. 23 | placeholder: | 24 | 1. Go to '...' 25 | 2. Click on '....' 26 | 3. Scroll down to '....' 27 | 4. See error 28 | validations: 29 | required: true 30 | - type: input 31 | attributes: 32 | label: Ice Version 33 | placeholder: e.g. 1.2.3 34 | validations: 35 | required: true 36 | - type: input 37 | attributes: 38 | label: macOS Version 39 | placeholder: e.g. 14.4.1 40 | validations: 41 | required: true 42 | - type: textarea 43 | attributes: 44 | label: Screenshots 45 | description: Relevant screenshots or screen recordings. 46 | placeholder: (optional) 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Request a feature or suggest an idea 3 | title: "[Feature Request]: " 4 | labels: Feature 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Check Existing Issues 9 | description: Check existing open and closed issues to prevent duplicates. Use the search field at the top of the issue tracker. Please only submit a new issue if it has not been covered by an existing issue. 10 | options: 11 | - label: I have checked existing issues, and this issue is not a duplicate 12 | required: true 13 | - type: textarea 14 | attributes: 15 | label: Description 16 | placeholder: A clear and concise description of the feature... 17 | validations: 18 | required: true 19 | - type: textarea 20 | attributes: 21 | label: Screenshots 22 | description: Relevant screenshots or screen recordings. 23 | placeholder: (optional) 24 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint Swift Files 2 | on: 3 | push: 4 | branches: [ "main" ] 5 | paths: 6 | - ".github/workflows/lint.yml" 7 | - ".swiftlint.yml" 8 | - "**/*.swift" 9 | pull_request: 10 | paths: 11 | - ".github/workflows/lint.yml" 12 | - ".swiftlint.yml" 13 | - "**/*.swift" 14 | jobs: 15 | swiftlint: 16 | if: '!github.event.pull_request.merged' 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Run SwiftLint 21 | uses: norio-nomura/action-swiftlint@3.2.1 22 | with: 23 | args: --strict 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | build/ 3 | xcuserdata/ 4 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: 2 | - Ice 3 | 4 | disabled_rules: 5 | - cyclomatic_complexity 6 | - file_length 7 | - function_body_length 8 | - function_parameter_count 9 | - generic_type_name 10 | - identifier_name 11 | - large_tuple 12 | - line_length 13 | - nesting 14 | - opening_brace 15 | - todo 16 | - type_body_length 17 | 18 | opt_in_rules: 19 | - closure_end_indentation 20 | - closure_spacing 21 | - collection_alignment 22 | - convenience_type 23 | - discouraged_object_literal 24 | - empty_count 25 | - fatal_error_message 26 | - file_header 27 | - force_unwrapping 28 | - implicitly_unwrapped_optional 29 | - indentation_width 30 | - literal_expression_end_indentation 31 | - lower_acl_than_parent 32 | - modifier_order 33 | - multiline_arguments 34 | - multiline_arguments_brackets 35 | - multiline_literal_brackets 36 | - multiline_parameters 37 | - multiline_parameters_brackets 38 | - period_spacing 39 | - unavailable_function 40 | - vertical_parameter_alignment_on_call 41 | - vertical_whitespace_closing_braces 42 | - yoda_condition 43 | 44 | custom_rules: 45 | objc_dynamic: 46 | name: "@objc dynamic" 47 | message: "`dynamic` modifier should immediately follow `@objc` attribute" 48 | regex: '@objc\b(\(\w*\))?+\s*(\S+|\v+\S*)\s*\bdynamic' 49 | match_kinds: attribute.builtin 50 | prefer_spaces_over_tabs: 51 | name: Prefer Spaces Over Tabs 52 | message: "Indentation should use 4 spaces per indentation level instead of tabs" 53 | regex: ^\t 54 | 55 | file_header: 56 | required_pattern: | 57 | // 58 | // SWIFTLINT_CURRENT_FILENAME 59 | // Ice 60 | // 61 | 62 | modifier_order: 63 | preferred_modifier_order: 64 | - acl 65 | - setterACL 66 | - override 67 | - mutators 68 | - lazy 69 | - final 70 | - required 71 | - convenience 72 | - typeMethods 73 | - owned 74 | 75 | trailing_comma: 76 | mandatory_comma: true 77 | -------------------------------------------------------------------------------- /FREQUENT_ISSUES.md: -------------------------------------------------------------------------------- 1 | # Frequent Issues 2 | 3 | - [Items are moved to the always-hidden section](#items-are-moved-to-the-always-hidden-section) 4 | - [Ice removed an item](#ice-removed-an-item) 5 | - [Ice does not remember the order of items](#ice-does-not-remember-the-order-of-items) 6 | - [How do I solve the `Ice cannot arrange menu bar items in automatically hidden menu bars` error?](#how-do-i-solve-the-ice-cannot-arrange-menu-bar-items-in-automatically-hidden-menu-bars-error) 7 | 8 | ## Items are moved to the always-hidden section 9 | 10 | By default, macOS adds new items to the far left of the menu bar, which is also the location of Ice's always-hidden section. Most apps are configured 11 | to remember the positions of their items, but some are not. macOS treats the items of these apps as new items each time they appear. This results in 12 | these items appearing in the always-hidden section, even if they have been previously been moved. 13 | 14 | Ice does not currently manage individual items, and in fact cannot, as of the current release. Once issues 15 | [#6](https://github.com/jordanbaird/Ice/issues/6) and [#26](https://github.com/jordanbaird/Ice/issues/26) are implemented, Ice will be able to 16 | monitor the items in the menu bar, and move the ones it recognizes to their previous locations, even if macOS rearranges them. 17 | 18 | ## Ice removed an item 19 | 20 | Ice does not have the ability to move or remove items. It likely got placed in the always-hidden section by macOS. Option + click the Ice icon to show 21 | the always-hidden section, then Command + drag the item into a different section. 22 | 23 | ## Ice does not remember the order of items 24 | 25 | This is not a bug, but a missing feature. It is being tracked in [#26](https://github.com/jordanbaird/Ice/issues/26). 26 | 27 | ## How do I solve the `Ice cannot arrange menu bar items in automatically hidden menu bars` error? 28 | 29 | 1. Open `System Settings` on your Mac 30 | 2. Go to `Control Center` 31 | 3. Select `Never` as shown in the image below 32 | 4. Update your `Menu Bar Items` in `Ice` 33 | 5. Return `Automatically hide and show the menu bar` to your preferred settings 34 | 35 | ![Disable Menu Bar Hiding](https://github.com/user-attachments/assets/74c1fde6-d310-4fe3-9f2b-703d8ccb636a) 36 | -------------------------------------------------------------------------------- /Ice.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Ice.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Ice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "a7567d11f06745371832127a8ce2132148ef6a89fb55ecc72d6c313b688387fa", 3 | "pins" : [ 4 | { 5 | "identity" : "axswift", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/tmandry/AXSwift", 8 | "state" : { 9 | "revision" : "81dcc36aced905d6464cc25e35f8d13184bbf21c", 10 | "version" : "0.3.2" 11 | } 12 | }, 13 | { 14 | "identity" : "compactslider", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/buh/CompactSlider", 17 | "state" : { 18 | "revision" : "abe4d1df6f0c85dcb133266cc07c2a5d08295726", 19 | "version" : "1.1.6" 20 | } 21 | }, 22 | { 23 | "identity" : "ifrit", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/ukushu/Ifrit", 26 | "state" : { 27 | "revision" : "e610cdf4eddec1e76a9c7ae5db37738c7f73150b", 28 | "version" : "2.0.3" 29 | } 30 | }, 31 | { 32 | "identity" : "launchatlogin-modern", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/sindresorhus/LaunchAtLogin-Modern", 35 | "state" : { 36 | "revision" : "a04ec1c363be3627734f6dad757d82f5d4fa8fcc", 37 | "version" : "1.1.0" 38 | } 39 | }, 40 | { 41 | "identity" : "sparkle", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/sparkle-project/Sparkle", 44 | "state" : { 45 | "revision" : "0ef1ee0220239b3776f433314515fd849025673f", 46 | "version" : "2.6.4" 47 | } 48 | } 49 | ], 50 | "version" : 3 51 | } 52 | -------------------------------------------------------------------------------- /Ice.xcodeproj/xcshareddata/xcschemes/Ice.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 44 | 50 | 51 | 52 | 53 | 59 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /Ice/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Ice/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16x16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon_16x16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32x32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32x32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_128x128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon_128x128@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_256x256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon_256x256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512x512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_512x512@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Ice/Assets.xcassets/AppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Ice/Assets.xcassets/AppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /Ice/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Ice/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /Ice/Assets.xcassets/AppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Ice/Assets.xcassets/AppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /Ice/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Ice/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /Ice/Assets.xcassets/AppIcon.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Ice/Assets.xcassets/AppIcon.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /Ice/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Ice/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /Ice/Assets.xcassets/AppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Ice/Assets.xcassets/AppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /Ice/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Ice/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /Ice/Assets.xcassets/AppIcon.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Ice/Assets.xcassets/AppIcon.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /Ice/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Ice/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /Ice/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Ice/Assets.xcassets/ControlItemImages/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Ice/Assets.xcassets/ControlItemImages/Dot/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Ice/Assets.xcassets/ControlItemImages/Dot/DotFill.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "DotFill.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Ice/Assets.xcassets/ControlItemImages/Dot/DotFill.imageset/DotFill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Ice/Assets.xcassets/ControlItemImages/Dot/DotFill.imageset/DotFill.png -------------------------------------------------------------------------------- /Ice/Assets.xcassets/ControlItemImages/Dot/DotStroke.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "DotStroke.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Ice/Assets.xcassets/ControlItemImages/Dot/DotStroke.imageset/DotStroke.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Ice/Assets.xcassets/ControlItemImages/Dot/DotStroke.imageset/DotStroke.png -------------------------------------------------------------------------------- /Ice/Assets.xcassets/ControlItemImages/Ellipsis/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Ice/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisFill.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "EllipsisFill.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Ice/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisFill.imageset/EllipsisFill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Ice/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisFill.imageset/EllipsisFill.png -------------------------------------------------------------------------------- /Ice/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisStroke.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "EllipsisStroke.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Ice/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisStroke.imageset/EllipsisStroke.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Ice/Assets.xcassets/ControlItemImages/Ellipsis/EllipsisStroke.imageset/EllipsisStroke.png -------------------------------------------------------------------------------- /Ice/Assets.xcassets/ControlItemImages/IceCube/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Ice/Assets.xcassets/ControlItemImages/IceCube/IceCubeFill.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "IceCubeFill.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Ice/Assets.xcassets/ControlItemImages/IceCube/IceCubeFill.imageset/IceCubeFill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Ice/Assets.xcassets/ControlItemImages/IceCube/IceCubeFill.imageset/IceCubeFill.png -------------------------------------------------------------------------------- /Ice/Assets.xcassets/ControlItemImages/IceCube/IceCubeStroke.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "IceCubeStroke.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Ice/Assets.xcassets/ControlItemImages/IceCube/IceCubeStroke.imageset/IceCubeStroke.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Ice/Assets.xcassets/ControlItemImages/IceCube/IceCubeStroke.imageset/IceCubeStroke.png -------------------------------------------------------------------------------- /Ice/Assets.xcassets/DefaultLayoutBarColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "0.170", 8 | "blue" : "0.000", 9 | "green" : "0.000", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "0.070", 26 | "blue" : "1.000", 27 | "green" : "1.000", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Ice/Assets.xcassets/Warning.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "Warning.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Ice/Assets.xcassets/Warning.imageset/Warning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Ice/Assets.xcassets/Warning.imageset/Warning.png -------------------------------------------------------------------------------- /Ice/Bridging/Shims/Deprecated.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Deprecated.swift 3 | // Ice 4 | // 5 | 6 | import ApplicationServices 7 | 8 | /// Returns a PSN for a given PID. 9 | @_silgen_name("GetProcessForPID") 10 | func GetProcessForPID( 11 | _ pid: pid_t, 12 | _ psn: inout ProcessSerialNumber 13 | ) -> OSStatus 14 | -------------------------------------------------------------------------------- /Ice/Bridging/Shims/Private.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Private.swift 3 | // Ice 4 | // 5 | 6 | import CoreGraphics 7 | 8 | // MARK: - Bridged Types 9 | 10 | typealias CGSConnectionID = Int32 11 | typealias CGSSpaceID = size_t 12 | 13 | enum CGSSpaceType: UInt32 { 14 | case user = 0 15 | case system = 2 16 | case fullscreen = 4 17 | } 18 | 19 | struct CGSSpaceMask: OptionSet { 20 | let rawValue: UInt32 21 | 22 | static let includesCurrent = CGSSpaceMask(rawValue: 1 << 0) 23 | static let includesOthers = CGSSpaceMask(rawValue: 1 << 1) 24 | static let includesUser = CGSSpaceMask(rawValue: 1 << 2) 25 | 26 | static let includesVisible = CGSSpaceMask(rawValue: 1 << 16) 27 | 28 | static let currentSpace: CGSSpaceMask = [.includesUser, .includesCurrent] 29 | static let otherSpaces: CGSSpaceMask = [.includesOthers, .includesCurrent] 30 | static let allSpaces: CGSSpaceMask = [.includesUser, .includesOthers, .includesCurrent] 31 | static let allVisibleSpaces: CGSSpaceMask = [.includesVisible, .allSpaces] 32 | } 33 | 34 | // MARK: - CGSConnection Functions 35 | 36 | @_silgen_name("CGSMainConnectionID") 37 | func CGSMainConnectionID() -> CGSConnectionID 38 | 39 | @_silgen_name("CGSCopyConnectionProperty") 40 | func CGSCopyConnectionProperty( 41 | _ cid: CGSConnectionID, 42 | _ targetCID: CGSConnectionID, 43 | _ key: CFString, 44 | _ outValue: inout Unmanaged? 45 | ) -> CGError 46 | 47 | @_silgen_name("CGSSetConnectionProperty") 48 | func CGSSetConnectionProperty( 49 | _ cid: CGSConnectionID, 50 | _ targetCID: CGSConnectionID, 51 | _ key: CFString, 52 | _ value: CFTypeRef 53 | ) -> CGError 54 | 55 | // MARK: - CGSEvent Functions 56 | 57 | @_silgen_name("CGSEventIsAppUnresponsive") 58 | func CGSEventIsAppUnresponsive( 59 | _ cid: CGSConnectionID, 60 | _ psn: inout ProcessSerialNumber 61 | ) -> Bool 62 | 63 | // MARK: - CGSSpace Functions 64 | 65 | @_silgen_name("CGSGetActiveSpace") 66 | func CGSGetActiveSpace(_ cid: CGSConnectionID) -> CGSSpaceID 67 | 68 | @_silgen_name("CGSCopySpacesForWindows") 69 | func CGSCopySpacesForWindows( 70 | _ cid: CGSConnectionID, 71 | _ mask: CGSSpaceMask, 72 | _ windowIDs: CFArray 73 | ) -> Unmanaged? 74 | 75 | @_silgen_name("CGSSpaceGetType") 76 | func CGSSpaceGetType( 77 | _ cid: CGSConnectionID, 78 | _ sid: CGSSpaceID 79 | ) -> CGSSpaceType 80 | 81 | // MARK: - CGSWindow Functions 82 | 83 | @_silgen_name("CGSGetWindowList") 84 | func CGSGetWindowList( 85 | _ cid: CGSConnectionID, 86 | _ targetCID: CGSConnectionID, 87 | _ count: Int32, 88 | _ list: UnsafeMutablePointer, 89 | _ outCount: inout Int32 90 | ) -> CGError 91 | 92 | @_silgen_name("CGSGetOnScreenWindowList") 93 | func CGSGetOnScreenWindowList( 94 | _ cid: CGSConnectionID, 95 | _ targetCID: CGSConnectionID, 96 | _ count: Int32, 97 | _ list: UnsafeMutablePointer, 98 | _ outCount: inout Int32 99 | ) -> CGError 100 | 101 | @_silgen_name("CGSGetProcessMenuBarWindowList") 102 | func CGSGetProcessMenuBarWindowList( 103 | _ cid: CGSConnectionID, 104 | _ targetCID: CGSConnectionID, 105 | _ count: Int32, 106 | _ list: UnsafeMutablePointer, 107 | _ outCount: inout Int32 108 | ) -> CGError 109 | 110 | @_silgen_name("CGSGetWindowCount") 111 | func CGSGetWindowCount( 112 | _ cid: CGSConnectionID, 113 | _ targetCID: CGSConnectionID, 114 | _ outCount: inout Int32 115 | ) -> CGError 116 | 117 | @_silgen_name("CGSGetOnScreenWindowCount") 118 | func CGSGetOnScreenWindowCount( 119 | _ cid: CGSConnectionID, 120 | _ targetCID: CGSConnectionID, 121 | _ outCount: inout Int32 122 | ) -> CGError 123 | 124 | @_silgen_name("CGSGetScreenRectForWindow") 125 | func CGSGetScreenRectForWindow( 126 | _ cid: CGSConnectionID, 127 | _ wid: CGWindowID, 128 | _ outRect: inout CGRect 129 | ) -> CGError 130 | -------------------------------------------------------------------------------- /Ice/Events/EventMonitors/GlobalEventMonitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GlobalEventMonitor.swift 3 | // Ice 4 | // 5 | 6 | import Cocoa 7 | import Combine 8 | 9 | /// A type that monitors for events outside the scope of the current process. 10 | final class GlobalEventMonitor { 11 | private let mask: NSEvent.EventTypeMask 12 | private let handler: (NSEvent) -> Void 13 | private var monitor: Any? 14 | 15 | /// Creates an event monitor with the given event type mask and handler. 16 | /// 17 | /// - Parameters: 18 | /// - mask: An event type mask specifying which events to monitor. 19 | /// - handler: A handler to execute when the event monitor receives 20 | /// an event corresponding to the event types in `mask`. 21 | init(mask: NSEvent.EventTypeMask, handler: @escaping (_ event: NSEvent) -> Void) { 22 | self.mask = mask 23 | self.handler = handler 24 | } 25 | 26 | deinit { 27 | stop() 28 | } 29 | 30 | /// Starts monitoring for events. 31 | func start() { 32 | guard monitor == nil else { 33 | return 34 | } 35 | monitor = NSEvent.addGlobalMonitorForEvents( 36 | matching: mask, 37 | handler: handler 38 | ) 39 | } 40 | 41 | /// Stops monitoring for events. 42 | func stop() { 43 | guard let monitor else { 44 | return 45 | } 46 | NSEvent.removeMonitor(monitor) 47 | self.monitor = nil 48 | } 49 | } 50 | 51 | extension GlobalEventMonitor { 52 | /// A publisher that emits global events for an event type mask. 53 | struct GlobalEventPublisher: Publisher { 54 | typealias Output = NSEvent 55 | typealias Failure = Never 56 | 57 | let mask: NSEvent.EventTypeMask 58 | 59 | func receive>(subscriber: S) { 60 | let subscription = GlobalEventSubscription(mask: mask, subscriber: subscriber) 61 | subscriber.receive(subscription: subscription) 62 | } 63 | } 64 | 65 | /// Returns a publisher that emits global events for the given event type mask. 66 | /// 67 | /// - Parameter mask: An event type mask specifying which events to publish. 68 | static func publisher(for mask: NSEvent.EventTypeMask) -> GlobalEventPublisher { 69 | GlobalEventPublisher(mask: mask) 70 | } 71 | } 72 | 73 | extension GlobalEventMonitor.GlobalEventPublisher { 74 | private final class GlobalEventSubscription>: Subscription { 75 | var subscriber: S? 76 | let monitor: GlobalEventMonitor 77 | 78 | init(mask: NSEvent.EventTypeMask, subscriber: S) { 79 | self.subscriber = subscriber 80 | self.monitor = GlobalEventMonitor(mask: mask) { event in 81 | _ = subscriber.receive(event) 82 | } 83 | monitor.start() 84 | } 85 | 86 | func request(_ demand: Subscribers.Demand) { } 87 | 88 | func cancel() { 89 | monitor.stop() 90 | subscriber = nil 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Ice/Events/EventMonitors/LocalEventMonitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalEventMonitor.swift 3 | // Ice 4 | // 5 | 6 | import Cocoa 7 | import Combine 8 | 9 | /// A type that monitors for events within the scope of the current process. 10 | final class LocalEventMonitor { 11 | private let mask: NSEvent.EventTypeMask 12 | private let handler: (NSEvent) -> NSEvent? 13 | private var monitor: Any? 14 | 15 | /// Creates an event monitor with the given event type mask and handler. 16 | /// 17 | /// - Parameters: 18 | /// - mask: An event type mask specifying which events to monitor. 19 | /// - handler: A handler to execute when the event monitor receives 20 | /// an event corresponding to the event types in `mask`. 21 | init(mask: NSEvent.EventTypeMask, handler: @escaping (_ event: NSEvent) -> NSEvent?) { 22 | self.mask = mask 23 | self.handler = handler 24 | } 25 | 26 | deinit { 27 | stop() 28 | } 29 | 30 | /// Starts monitoring for events. 31 | func start() { 32 | guard monitor == nil else { 33 | return 34 | } 35 | monitor = NSEvent.addLocalMonitorForEvents( 36 | matching: mask, 37 | handler: handler 38 | ) 39 | } 40 | 41 | /// Stops monitoring for events. 42 | func stop() { 43 | guard let monitor else { 44 | return 45 | } 46 | NSEvent.removeMonitor(monitor) 47 | self.monitor = nil 48 | } 49 | } 50 | 51 | extension LocalEventMonitor { 52 | /// A publisher that emits local events for an event type mask. 53 | struct LocalEventPublisher: Publisher { 54 | typealias Output = NSEvent 55 | typealias Failure = Never 56 | 57 | let mask: NSEvent.EventTypeMask 58 | 59 | func receive>(subscriber: S) { 60 | let subscription = LocalEventSubscription(mask: mask, subscriber: subscriber) 61 | subscriber.receive(subscription: subscription) 62 | } 63 | } 64 | 65 | /// Returns a publisher that emits local events for the given event type mask. 66 | /// 67 | /// - Parameter mask: An event type mask specifying which events to publish. 68 | static func publisher(for mask: NSEvent.EventTypeMask) -> LocalEventPublisher { 69 | LocalEventPublisher(mask: mask) 70 | } 71 | } 72 | 73 | extension LocalEventMonitor.LocalEventPublisher { 74 | private final class LocalEventSubscription>: Subscription { 75 | var subscriber: S? 76 | let monitor: LocalEventMonitor 77 | 78 | init(mask: NSEvent.EventTypeMask, subscriber: S) { 79 | self.subscriber = subscriber 80 | self.monitor = LocalEventMonitor(mask: mask) { event in 81 | _ = subscriber.receive(event) 82 | return event 83 | } 84 | monitor.start() 85 | } 86 | 87 | func request(_ demand: Subscribers.Demand) { } 88 | 89 | func cancel() { 90 | monitor.stop() 91 | subscriber = nil 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Ice/Events/EventMonitors/RunLoopLocalEventMonitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RunLoopLocalEventMonitor.swift 3 | // Ice 4 | // 5 | 6 | import Cocoa 7 | import Combine 8 | 9 | final class RunLoopLocalEventMonitor { 10 | private let runLoop = CFRunLoopGetCurrent() 11 | private let mode: RunLoop.Mode 12 | private let handler: (NSEvent) -> NSEvent? 13 | private let observer: CFRunLoopObserver 14 | 15 | /// Creates an event monitor with the given event type mask and handler. 16 | /// 17 | /// - Parameters: 18 | /// - mask: An event type mask specifying which events to monitor. 19 | /// - handler: A handler to execute when the event monitor receives 20 | /// an event corresponding to the event types in `mask`. 21 | init( 22 | mask: NSEvent.EventTypeMask, 23 | mode: RunLoop.Mode, 24 | handler: @escaping (_ event: NSEvent) -> NSEvent? 25 | ) { 26 | self.mode = mode 27 | self.handler = handler 28 | self.observer = CFRunLoopObserverCreateWithHandler( 29 | kCFAllocatorDefault, 30 | CFRunLoopActivity.beforeSources.rawValue, 31 | true, 32 | 0 33 | ) { _, _ in 34 | var events = [NSEvent]() 35 | 36 | while let event = NSApp.nextEvent(matching: .any, until: nil, inMode: .default, dequeue: true) { 37 | events.append(event) 38 | } 39 | 40 | for event in events { 41 | var handledEvent: NSEvent? 42 | 43 | if !mask.contains(NSEvent.EventTypeMask(rawValue: 1 << event.type.rawValue)) { 44 | handledEvent = event 45 | } else if let eventFromHandler = handler(event) { 46 | handledEvent = eventFromHandler 47 | } 48 | 49 | guard let handledEvent else { 50 | continue 51 | } 52 | 53 | NSApp.postEvent(handledEvent, atStart: false) 54 | } 55 | } 56 | } 57 | 58 | deinit { 59 | stop() 60 | } 61 | 62 | func start() { 63 | CFRunLoopAddObserver( 64 | runLoop, 65 | observer, 66 | CFRunLoopMode(mode.rawValue as CFString) 67 | ) 68 | } 69 | 70 | func stop() { 71 | CFRunLoopRemoveObserver( 72 | runLoop, 73 | observer, 74 | CFRunLoopMode(mode.rawValue as CFString) 75 | ) 76 | } 77 | } 78 | 79 | extension RunLoopLocalEventMonitor { 80 | /// A publisher that emits local events for an event type mask. 81 | struct RunLoopLocalEventPublisher: Publisher { 82 | typealias Output = NSEvent 83 | typealias Failure = Never 84 | 85 | let mask: NSEvent.EventTypeMask 86 | let mode: RunLoop.Mode 87 | 88 | func receive>(subscriber: S) { 89 | let subscription = RunLoopLocalEventSubscription(mask: mask, mode: mode, subscriber: subscriber) 90 | subscriber.receive(subscription: subscription) 91 | } 92 | } 93 | 94 | /// Returns a publisher that emits local events for the given event type mask. 95 | /// 96 | /// - Parameter mask: An event type mask specifying which events to publish. 97 | static func publisher(for mask: NSEvent.EventTypeMask, mode: RunLoop.Mode) -> RunLoopLocalEventPublisher { 98 | RunLoopLocalEventPublisher(mask: mask, mode: mode) 99 | } 100 | } 101 | 102 | extension RunLoopLocalEventMonitor.RunLoopLocalEventPublisher { 103 | private final class RunLoopLocalEventSubscription>: Subscription { 104 | var subscriber: S? 105 | let monitor: RunLoopLocalEventMonitor 106 | 107 | init(mask: NSEvent.EventTypeMask, mode: RunLoop.Mode, subscriber: S) { 108 | self.subscriber = subscriber 109 | self.monitor = RunLoopLocalEventMonitor(mask: mask, mode: mode) { event in 110 | _ = subscriber.receive(event) 111 | return event 112 | } 113 | monitor.start() 114 | } 115 | 116 | func request(_ demand: Subscribers.Demand) { } 117 | 118 | func cancel() { 119 | monitor.stop() 120 | subscriber = nil 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Ice/Events/EventMonitors/UniversalEventMonitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UniversalEventMonitor.swift 3 | // Ice 4 | // 5 | 6 | import Cocoa 7 | import Combine 8 | 9 | /// A type that monitors for local and global events. 10 | final class UniversalEventMonitor { 11 | private let local: LocalEventMonitor 12 | private let global: GlobalEventMonitor 13 | 14 | /// Creates an event monitor with the given event type mask and handler. 15 | /// 16 | /// - Parameters: 17 | /// - mask: An event type mask specifying which events to monitor. 18 | /// - handler: A handler to execute when the event monitor receives 19 | /// an event corresponding to the event types in `mask`. 20 | init(mask: NSEvent.EventTypeMask, handler: @escaping (_ event: NSEvent) -> NSEvent?) { 21 | self.local = LocalEventMonitor(mask: mask, handler: handler) 22 | self.global = GlobalEventMonitor(mask: mask, handler: { _ = handler($0) }) 23 | } 24 | 25 | deinit { 26 | stop() 27 | } 28 | 29 | /// Starts monitoring for events. 30 | func start() { 31 | local.start() 32 | global.start() 33 | } 34 | 35 | /// Stops monitoring for events. 36 | func stop() { 37 | local.stop() 38 | global.stop() 39 | } 40 | } 41 | 42 | extension UniversalEventMonitor { 43 | /// A publisher that emits local and global events for an event type mask. 44 | struct UniversalEventPublisher: Publisher { 45 | typealias Output = NSEvent 46 | typealias Failure = Never 47 | 48 | let mask: NSEvent.EventTypeMask 49 | 50 | func receive>(subscriber: S) { 51 | let subscription = UniversalEventSubscription(mask: mask, subscriber: subscriber) 52 | subscriber.receive(subscription: subscription) 53 | } 54 | } 55 | 56 | /// Returns a publisher that emits local and global events for the given 57 | /// event type mask. 58 | /// 59 | /// - Parameter mask: An event type mask specifying which events to publish. 60 | static func publisher(for mask: NSEvent.EventTypeMask) -> UniversalEventPublisher { 61 | UniversalEventPublisher(mask: mask) 62 | } 63 | } 64 | 65 | extension UniversalEventMonitor.UniversalEventPublisher { 66 | private final class UniversalEventSubscription>: Subscription { 67 | var subscriber: S? 68 | let monitor: UniversalEventMonitor 69 | 70 | init(mask: NSEvent.EventTypeMask, subscriber: S) { 71 | self.subscriber = subscriber 72 | self.monitor = UniversalEventMonitor(mask: mask) { event in 73 | _ = subscriber.receive(event) 74 | return event 75 | } 76 | monitor.start() 77 | } 78 | 79 | func request(_ demand: Subscribers.Demand) { } 80 | 81 | func cancel() { 82 | monitor.stop() 83 | subscriber = nil 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Ice/Hotkeys/Hotkey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Hotkey.swift 3 | // Ice 4 | // 5 | 6 | import Combine 7 | 8 | /// A combination of a key and modifiers that can be used to 9 | /// trigger actions on system-wide key-up or key-down events. 10 | final class Hotkey: ObservableObject { 11 | private weak var appState: AppState? 12 | 13 | private var listener: Listener? 14 | 15 | let action: HotkeyAction 16 | 17 | @Published var keyCombination: KeyCombination? { 18 | didSet { 19 | enable() 20 | } 21 | } 22 | 23 | var isEnabled: Bool { 24 | listener != nil 25 | } 26 | 27 | init(keyCombination: KeyCombination?, action: HotkeyAction) { 28 | self.keyCombination = keyCombination 29 | self.action = action 30 | } 31 | 32 | func assignAppState(_ appState: AppState) { 33 | self.appState = appState 34 | enable() 35 | } 36 | 37 | func enable() { 38 | disable() 39 | listener = Listener(hotkey: self, eventKind: .keyDown, appState: appState) 40 | } 41 | 42 | func disable() { 43 | listener?.invalidate() 44 | listener = nil 45 | } 46 | } 47 | 48 | extension Hotkey { 49 | /// An object that manges the lifetime of a hotkey observation. 50 | private final class Listener { 51 | private weak var appState: AppState? 52 | 53 | private var id: UInt32? 54 | 55 | var isValid: Bool { 56 | id != nil 57 | } 58 | 59 | init?(hotkey: Hotkey, eventKind: HotkeyRegistry.EventKind, appState: AppState?) { 60 | guard 61 | let appState, 62 | hotkey.keyCombination != nil 63 | else { 64 | return nil 65 | } 66 | let id = appState.hotkeyRegistry.register( 67 | hotkey: hotkey, 68 | eventKind: eventKind 69 | ) { [weak appState] in 70 | guard let appState else { 71 | return 72 | } 73 | Task { 74 | await hotkey.action.perform(appState: appState) 75 | } 76 | } 77 | guard let id else { 78 | return nil 79 | } 80 | self.appState = appState 81 | self.id = id 82 | } 83 | 84 | deinit { 85 | invalidate() 86 | } 87 | 88 | func invalidate() { 89 | guard isValid else { 90 | return 91 | } 92 | guard let appState else { 93 | Logger.hotkey.error("Error invalidating hotkey: Missing AppState") 94 | return 95 | } 96 | defer { 97 | id = nil 98 | } 99 | if let id { 100 | appState.hotkeyRegistry.unregister(id) 101 | } 102 | } 103 | } 104 | } 105 | 106 | // MARK: Hotkey: Codable 107 | extension Hotkey: Codable { 108 | private enum CodingKeys: CodingKey { 109 | case keyCombination 110 | case action 111 | } 112 | 113 | convenience init(from decoder: any Decoder) throws { 114 | let container = try decoder.container(keyedBy: CodingKeys.self) 115 | try self.init( 116 | keyCombination: container.decode(KeyCombination?.self, forKey: .keyCombination), 117 | action: container.decode(HotkeyAction.self, forKey: .action) 118 | ) 119 | } 120 | 121 | func encode(to encoder: any Encoder) throws { 122 | var container = encoder.container(keyedBy: CodingKeys.self) 123 | try container.encode(keyCombination, forKey: .keyCombination) 124 | try container.encode(action, forKey: .action) 125 | } 126 | } 127 | 128 | // MARK: Hotkey: Equatable 129 | extension Hotkey: Equatable { 130 | static func == (lhs: Hotkey, rhs: Hotkey) -> Bool { 131 | lhs.keyCombination == rhs.keyCombination && 132 | lhs.action == rhs.action 133 | } 134 | } 135 | 136 | // MARK: Hotkey: Hashable 137 | extension Hotkey: Hashable { 138 | func hash(into hasher: inout Hasher) { 139 | hasher.combine(keyCombination) 140 | hasher.combine(action) 141 | } 142 | } 143 | 144 | // MARK: - Logger 145 | private extension Logger { 146 | static let hotkey = Logger(category: "Hotkey") 147 | } 148 | -------------------------------------------------------------------------------- /Ice/Hotkeys/HotkeyAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HotkeyAction.swift 3 | // Ice 4 | // 5 | 6 | enum HotkeyAction: String, Codable, CaseIterable { 7 | // Menu Bar Sections 8 | case toggleHiddenSection = "ToggleHiddenSection" 9 | case toggleAlwaysHiddenSection = "ToggleAlwaysHiddenSection" 10 | 11 | // Menu Bar Items 12 | case searchMenuBarItems = "SearchMenuBarItems" 13 | 14 | // Other 15 | case enableIceBar = "EnableIceBar" 16 | case showSectionDividers = "ShowSectionDividers" 17 | case toggleApplicationMenus = "ToggleApplicationMenus" 18 | 19 | @MainActor 20 | func perform(appState: AppState) async { 21 | switch self { 22 | case .toggleHiddenSection: 23 | guard let section = appState.menuBarManager.section(withName: .hidden) else { 24 | return 25 | } 26 | section.toggle() 27 | // Prevent the section from automatically rehiding after mouse movement. 28 | if !section.isHidden { 29 | appState.preventShowOnHover() 30 | } 31 | case .toggleAlwaysHiddenSection: 32 | guard let section = appState.menuBarManager.section(withName: .alwaysHidden) else { 33 | return 34 | } 35 | section.toggle() 36 | // Prevent the section from automatically rehiding after mouse movement. 37 | if !section.isHidden { 38 | appState.preventShowOnHover() 39 | } 40 | case .searchMenuBarItems: 41 | await appState.menuBarManager.searchPanel.toggle() 42 | case .enableIceBar: 43 | appState.settingsManager.generalSettingsManager.useIceBar.toggle() 44 | case .showSectionDividers: 45 | appState.settingsManager.advancedSettingsManager.showSectionDividers.toggle() 46 | case .toggleApplicationMenus: 47 | appState.menuBarManager.toggleApplicationMenus() 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Ice/Hotkeys/KeyCombination.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyCombination.swift 3 | // Ice 4 | // 5 | 6 | import Carbon.HIToolbox 7 | import Cocoa 8 | 9 | struct KeyCombination: Hashable { 10 | let key: KeyCode 11 | let modifiers: Modifiers 12 | 13 | var stringValue: String { 14 | modifiers.symbolicValue + key.stringValue 15 | } 16 | 17 | init(key: KeyCode, modifiers: Modifiers) { 18 | self.key = key 19 | self.modifiers = modifiers 20 | } 21 | 22 | init(event: NSEvent) { 23 | let key = KeyCode(rawValue: Int(event.keyCode)) 24 | let modifiers = Modifiers(nsEventFlags: event.modifierFlags) 25 | self.init(key: key, modifiers: modifiers) 26 | } 27 | } 28 | 29 | private func getSystemReservedKeyCombinations() -> [KeyCombination] { 30 | var symbolicHotkeys: Unmanaged? 31 | let status = CopySymbolicHotKeys(&symbolicHotkeys) 32 | 33 | guard status == noErr else { 34 | Logger.keyCombination.error("CopySymbolicHotKeys returned invalid status: \(status)") 35 | return [] 36 | } 37 | guard let reservedHotkeys = symbolicHotkeys?.takeRetainedValue() as? [[String: Any]] else { 38 | Logger.keyCombination.error("Failed to serialize symbolic hotkeys") 39 | return [] 40 | } 41 | 42 | return reservedHotkeys.compactMap { hotkey in 43 | guard 44 | hotkey[kHISymbolicHotKeyEnabled] as? Bool == true, 45 | let keyCode = hotkey[kHISymbolicHotKeyCode] as? Int, 46 | let modifiers = hotkey[kHISymbolicHotKeyModifiers] as? Int 47 | else { 48 | return nil 49 | } 50 | return KeyCombination( 51 | key: KeyCode(rawValue: keyCode), 52 | modifiers: Modifiers(carbonFlags: modifiers) 53 | ) 54 | } 55 | } 56 | 57 | extension KeyCombination { 58 | /// Returns a Boolean value that indicates whether this key 59 | /// combination is reserved for system use. 60 | var isReservedBySystem: Bool { 61 | getSystemReservedKeyCombinations().contains(self) 62 | } 63 | } 64 | 65 | extension KeyCombination: Codable { 66 | init(from decoder: any Decoder) throws { 67 | var container = try decoder.unkeyedContainer() 68 | guard container.count == 2 else { 69 | throw DecodingError.dataCorrupted( 70 | DecodingError.Context( 71 | codingPath: decoder.codingPath, 72 | debugDescription: "Expected 2 encoded values, found \(container.count ?? 0)" 73 | ) 74 | ) 75 | } 76 | self.key = try KeyCode(rawValue: container.decode(Int.self)) 77 | self.modifiers = try Modifiers(rawValue: container.decode(Int.self)) 78 | } 79 | 80 | func encode(to encoder: any Encoder) throws { 81 | var container = encoder.unkeyedContainer() 82 | try container.encode(key.rawValue) 83 | try container.encode(modifiers.rawValue) 84 | } 85 | } 86 | 87 | // MARK: - Logger 88 | private extension Logger { 89 | static let keyCombination = Logger(category: "KeyCombination") 90 | } 91 | -------------------------------------------------------------------------------- /Ice/Hotkeys/Modifiers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Modifiers.swift 3 | // Ice 4 | // 5 | 6 | import Carbon.HIToolbox 7 | import Cocoa 8 | 9 | /// A bit mask containing the modifier keys for a hotkey. 10 | struct Modifiers: OptionSet, Codable, Hashable { 11 | let rawValue: Int 12 | 13 | static let control = Modifiers(rawValue: 1 << 0) 14 | static let option = Modifiers(rawValue: 1 << 1) 15 | static let shift = Modifiers(rawValue: 1 << 2) 16 | static let command = Modifiers(rawValue: 1 << 3) 17 | } 18 | 19 | extension Modifiers { 20 | /// All modifiers in the order displayed by the system, 21 | /// according to Apple's style guide. 22 | static let canonicalOrder = [control, option, shift, command] 23 | 24 | /// A symbolic string representation of the modifiers. 25 | var symbolicValue: String { 26 | var result = "" 27 | if contains(.control) { 28 | result.append("⌃") 29 | } 30 | if contains(.option) { 31 | result.append("⌥") 32 | } 33 | if contains(.shift) { 34 | result.append("⇧") 35 | } 36 | if contains(.command) { 37 | result.append("⌘") 38 | } 39 | return result 40 | } 41 | 42 | /// A string representation of the modifiers that is 43 | /// suitable for display in a label. 44 | var labelValue: String { 45 | var result = [String]() 46 | if contains(.control) { 47 | result.append("Control") 48 | } 49 | if contains(.option) { 50 | result.append("Option") 51 | } 52 | if contains(.shift) { 53 | result.append("Shift") 54 | } 55 | if contains(.command) { 56 | result.append("Command") 57 | } 58 | return result.joined(separator: " + ") 59 | } 60 | 61 | /// A combined string representation of the modifiers 62 | /// that is suitable for display. 63 | var combinedValue: String { 64 | "\(labelValue) (\(symbolicValue))" 65 | } 66 | 67 | /// Cocoa flags. 68 | var nsEventFlags: NSEvent.ModifierFlags { 69 | var result: NSEvent.ModifierFlags = [] 70 | if contains(.control) { 71 | result.insert(.control) 72 | } 73 | if contains(.option) { 74 | result.insert(.option) 75 | } 76 | if contains(.shift) { 77 | result.insert(.shift) 78 | } 79 | if contains(.command) { 80 | result.insert(.command) 81 | } 82 | return result 83 | } 84 | 85 | /// CoreGraphics flags. 86 | var cgEventFlags: CGEventFlags { 87 | var result: CGEventFlags = [] 88 | if contains(.control) { 89 | result.insert(.maskControl) 90 | } 91 | if contains(.option) { 92 | result.insert(.maskAlternate) 93 | } 94 | if contains(.shift) { 95 | result.insert(.maskShift) 96 | } 97 | if contains(.command) { 98 | result.insert(.maskCommand) 99 | } 100 | return result 101 | } 102 | 103 | /// Raw Carbon flags. 104 | var carbonFlags: Int { 105 | var result = 0 106 | if contains(.control) { 107 | result |= controlKey 108 | } 109 | if contains(.option) { 110 | result |= optionKey 111 | } 112 | if contains(.shift) { 113 | result |= shiftKey 114 | } 115 | if contains(.command) { 116 | result |= cmdKey 117 | } 118 | return result 119 | } 120 | 121 | init(nsEventFlags: NSEvent.ModifierFlags) { 122 | self.init() 123 | if nsEventFlags.contains(.control) { 124 | insert(.control) 125 | } 126 | if nsEventFlags.contains(.option) { 127 | insert(.option) 128 | } 129 | if nsEventFlags.contains(.shift) { 130 | insert(.shift) 131 | } 132 | if nsEventFlags.contains(.command) { 133 | insert(.command) 134 | } 135 | } 136 | 137 | init(cgEventFlags: CGEventFlags) { 138 | self.init() 139 | if cgEventFlags.contains(.maskControl) { 140 | insert(.control) 141 | } 142 | if cgEventFlags.contains(.maskAlternate) { 143 | insert(.option) 144 | } 145 | if cgEventFlags.contains(.maskShift) { 146 | insert(.shift) 147 | } 148 | if cgEventFlags.contains(.maskCommand) { 149 | insert(.command) 150 | } 151 | } 152 | 153 | init(carbonFlags: Int) { 154 | self.init() 155 | if carbonFlags & controlKey == controlKey { 156 | insert(.control) 157 | } 158 | if carbonFlags & optionKey == optionKey { 159 | insert(.option) 160 | } 161 | if carbonFlags & shiftKey == shiftKey { 162 | insert(.shift) 163 | } 164 | if carbonFlags & cmdKey == cmdKey { 165 | insert(.command) 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /Ice/Ice.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Ice/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SUFeedURL 6 | https://jordanbaird.github.io/ice-releases/appcast.xml 7 | SUPublicEDKey 8 | 3nfIGMOD8DALPE8vIdFo2tUOIVc2MVbzhc+2J9JLn+Q= 9 | 10 | 11 | -------------------------------------------------------------------------------- /Ice/Main/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | @MainActor 9 | final class AppDelegate: NSObject, NSApplicationDelegate { 10 | private weak var appState: AppState? 11 | 12 | // MARK: NSApplicationDelegate Methods 13 | 14 | func applicationWillFinishLaunching(_ notification: Notification) { 15 | guard let appState else { 16 | Logger.appDelegate.warning("Missing app state in applicationWillFinishLaunching") 17 | return 18 | } 19 | 20 | // Assign the delegate to the shared app state. 21 | appState.assignAppDelegate(self) 22 | 23 | // Allow the app to set the cursor in the background. 24 | appState.setsCursorInBackground = true 25 | } 26 | 27 | func applicationDidFinishLaunching(_ notification: Notification) { 28 | guard let appState else { 29 | Logger.appDelegate.warning("Missing app state in applicationDidFinishLaunching") 30 | return 31 | } 32 | 33 | // Dismiss the windows. 34 | appState.dismissSettingsWindow() 35 | appState.dismissPermissionsWindow() 36 | 37 | // Hide the main menu to make more space in the menu bar. 38 | if let mainMenu = NSApp.mainMenu { 39 | for item in mainMenu.items { 40 | item.isHidden = true 41 | } 42 | } 43 | 44 | // Perform setup after a small delay to ensure that the settings window 45 | // has been assigned. 46 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 47 | guard !appState.isPreview else { 48 | return 49 | } 50 | // If we have the required permissions, set up the shared app state. 51 | // Otherwise, open the permissions window. 52 | switch appState.permissionsManager.permissionsState { 53 | case .hasAllPermissions, .hasRequiredPermissions: 54 | appState.performSetup() 55 | case .missingPermissions: 56 | appState.activate(withPolicy: .regular) 57 | appState.openPermissionsWindow() 58 | } 59 | } 60 | } 61 | 62 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 63 | // Deactivate and set the policy to accessory when all windows are closed. 64 | appState?.deactivate(withPolicy: .accessory) 65 | return false 66 | } 67 | 68 | func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { 69 | return true 70 | } 71 | 72 | // MARK: Other Methods 73 | 74 | /// Assigns the app state to the delegate. 75 | func assignAppState(_ appState: AppState) { 76 | guard self.appState == nil else { 77 | Logger.appDelegate.warning("Multiple attempts made to assign app state") 78 | return 79 | } 80 | self.appState = appState 81 | } 82 | 83 | /// Opens the settings window and activates the app. 84 | @objc func openSettingsWindow() { 85 | guard let appState else { 86 | Logger.appDelegate.error("Failed to open settings window") 87 | return 88 | } 89 | // Small delay makes this more reliable. 90 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 91 | appState.activate(withPolicy: .regular) 92 | appState.openSettingsWindow() 93 | } 94 | } 95 | } 96 | 97 | // MARK: - Logger 98 | private extension Logger { 99 | static let appDelegate = Logger(category: "AppDelegate") 100 | } 101 | -------------------------------------------------------------------------------- /Ice/Main/IceApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IceApp.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | @main 9 | struct IceApp: App { 10 | @NSApplicationDelegateAdaptor var appDelegate: AppDelegate 11 | @ObservedObject var appState = AppState() 12 | 13 | init() { 14 | NSSplitViewItem.swizzle() 15 | MigrationManager.migrateAll(appState: appState) 16 | appDelegate.assignAppState(appState) 17 | } 18 | 19 | var body: some Scene { 20 | SettingsWindow(appState: appState) 21 | PermissionsWindow(appState: appState) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Ice/Main/Navigation/AppNavigationState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppNavigationState.swift 3 | // Ice 4 | // 5 | 6 | import Combine 7 | 8 | /// The model for app-wide navigation. 9 | @MainActor 10 | final class AppNavigationState: ObservableObject { 11 | @Published var isAppFrontmost = false 12 | @Published var isSettingsPresented = false 13 | @Published var isIceBarPresented = false 14 | @Published var isSearchPresented = false 15 | @Published var settingsNavigationIdentifier: SettingsNavigationIdentifier = .general 16 | } 17 | -------------------------------------------------------------------------------- /Ice/Main/Navigation/NavigationIdentifiers/NavigationIdentifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationIdentifier.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | /// A type that represents an identifier used for navigation in a user interface. 9 | protocol NavigationIdentifier: CaseIterable, Hashable, Identifiable, RawRepresentable { 10 | /// A localized description of the identifier that can be presented to the user. 11 | var localized: LocalizedStringKey { get } 12 | } 13 | 14 | extension NavigationIdentifier where ID == Int { 15 | var id: Int { hashValue } 16 | } 17 | 18 | extension NavigationIdentifier where RawValue == String { 19 | var localized: LocalizedStringKey { LocalizedStringKey(rawValue) } 20 | } 21 | -------------------------------------------------------------------------------- /Ice/Main/Navigation/NavigationIdentifiers/SettingsNavigationIdentifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsNavigationIdentifier.swift 3 | // Ice 4 | // 5 | 6 | /// An identifier used for navigation in the settings interface. 7 | enum SettingsNavigationIdentifier: String, NavigationIdentifier { 8 | case general = "General" 9 | case menuBarLayout = "Menu Bar Layout" 10 | case menuBarAppearance = "Menu Bar Appearance" 11 | case hotkeys = "Hotkeys" 12 | case advanced = "Advanced" 13 | case updates = "Updates" 14 | case about = "About" 15 | } 16 | -------------------------------------------------------------------------------- /Ice/MenuBar/Appearance/MenuBarAppearanceEditor/MenuBarAppearanceEditorPanel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuBarAppearanceEditorPanel.swift 3 | // Ice 4 | // 5 | 6 | import Combine 7 | import SwiftUI 8 | 9 | // MARK: - MenuBarAppearanceEditorPanel 10 | 11 | /// A panel that manages the appearance editor popover. 12 | final class MenuBarAppearanceEditorPanel: NSPanel { 13 | /// The shared app state. 14 | private weak var appState: AppState? 15 | 16 | /// Storage for internal observers. 17 | private var cancellables = Set() 18 | 19 | init(appState: AppState) { 20 | super.init( 21 | contentRect: CGRect(x: 0, y: 0, width: 1, height: 1), 22 | styleMask: [.borderless, .nonactivatingPanel], 23 | backing: .buffered, 24 | defer: false 25 | ) 26 | self.appState = appState 27 | self.isFloatingPanel = true 28 | self.backgroundColor = .clear 29 | configureCancellables() 30 | } 31 | 32 | private func configureCancellables() { 33 | var c = Set() 34 | 35 | NSWorkspace.shared.notificationCenter 36 | .publisher(for: NSWorkspace.activeSpaceDidChangeNotification) 37 | .sink { [weak self] _ in 38 | self?.orderOut(self) 39 | NSColorPanel.shared.close() 40 | NSColorPanel.shared.hidesOnDeactivate = true 41 | } 42 | .store(in: &c) 43 | 44 | cancellables = c 45 | } 46 | 47 | /// Shows the appearance editor popover. 48 | func showAppearanceEditorPopover() { 49 | guard 50 | let appState, 51 | let contentView, 52 | let screen = NSScreen.screens.first(where: { $0.frame.contains(NSEvent.mouseLocation) }), 53 | let menuBarHeight = NSApp.mainMenu?.menuBarHeight 54 | else { 55 | return 56 | } 57 | setFrameOrigin(CGPoint(x: screen.frame.midX - frame.width / 2, y: screen.frame.maxY - menuBarHeight)) 58 | let popover = MenuBarAppearanceEditorPopover(appState: appState) 59 | popover.delegate = self 60 | popover.show(relativeTo: .zero, of: contentView, preferredEdge: .minY) 61 | popover.contentViewController?.view.window?.makeKey() 62 | NSColorPanel.shared.hidesOnDeactivate = false 63 | } 64 | } 65 | 66 | // MARK: MenuBarAppearanceEditorPanel: NSPopoverDelegate 67 | extension MenuBarAppearanceEditorPanel: NSPopoverDelegate { 68 | func popoverDidClose(_ notification: Notification) { 69 | if let popover = notification.object as? MenuBarAppearanceEditorPopover { 70 | popover.mouseDownMonitor.stop() 71 | orderOut(popover) 72 | NSColorPanel.shared.close() 73 | NSColorPanel.shared.hidesOnDeactivate = true 74 | } 75 | } 76 | } 77 | 78 | // MARK: - MenuBarAppearanceEditorPopover 79 | 80 | /// A popover that displays the menu bar appearance editor 81 | /// at a centered location under the menu bar. 82 | private final class MenuBarAppearanceEditorPopover: NSPopover { 83 | private weak var appState: AppState? 84 | 85 | private(set) lazy var mouseDownMonitor = GlobalEventMonitor(mask: .leftMouseDown) { [weak self] _ in 86 | self?.performClose(self) 87 | } 88 | 89 | @ViewBuilder 90 | private var contentView: some View { 91 | if let appState { 92 | MenuBarAppearanceEditor( 93 | location: .popover(closePopover: { [weak self] in 94 | self?.performClose(self) 95 | }) 96 | ) 97 | .environmentObject(appState) 98 | .environmentObject(appState.appearanceManager) 99 | } 100 | } 101 | 102 | init(appState: AppState) { 103 | super.init() 104 | self.appState = appState 105 | self.contentViewController = NSHostingController(rootView: contentView) 106 | self.contentSize = CGSize(width: 550, height: 600) 107 | self.behavior = .applicationDefined 108 | self.mouseDownMonitor.start() 109 | } 110 | 111 | @available(*, unavailable) 112 | required init?(coder: NSCoder) { 113 | fatalError("init(coder:) has not been implemented") 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Ice/MenuBar/Appearance/MenuBarShape.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuBarShape.swift 3 | // Ice 4 | // 5 | 6 | import CoreGraphics 7 | 8 | /// An end cap in a menu bar shape. 9 | enum MenuBarEndCap: Int, Codable, Hashable, CaseIterable { 10 | /// An end cap with a square shape. 11 | case square = 0 12 | /// An end cap with a rounded shape. 13 | case round = 1 14 | } 15 | 16 | /// A type that specifies a custom shape kind for the menu bar. 17 | enum MenuBarShapeKind: Int, Codable, Hashable, CaseIterable { 18 | /// The menu bar does not use a custom shape. 19 | case none = 0 20 | /// A custom shape that takes up the full menu bar. 21 | case full = 1 22 | /// A custom shape that splits the menu bar between 23 | /// its leading and trailing sides. 24 | case split = 2 25 | } 26 | 27 | /// Information for the ``MenuBarShapeKind/full`` menu bar 28 | /// shape kind. 29 | struct MenuBarFullShapeInfo: Codable, Hashable { 30 | /// The leading end cap of the shape. 31 | var leadingEndCap: MenuBarEndCap 32 | /// The trailing end cap of the shape. 33 | var trailingEndCap: MenuBarEndCap 34 | } 35 | 36 | extension MenuBarFullShapeInfo { 37 | var hasRoundedShape: Bool { 38 | leadingEndCap == .round || trailingEndCap == .round 39 | } 40 | } 41 | 42 | extension MenuBarFullShapeInfo { 43 | static let `default` = MenuBarFullShapeInfo(leadingEndCap: .round, trailingEndCap: .round) 44 | } 45 | 46 | /// Information for the ``MenuBarShapeKind/split`` menu bar 47 | /// shape kind. 48 | struct MenuBarSplitShapeInfo: Codable, Hashable { 49 | /// The leading information of the shape. 50 | var leading: MenuBarFullShapeInfo 51 | /// The trailing information of the shape. 52 | var trailing: MenuBarFullShapeInfo 53 | } 54 | 55 | extension MenuBarSplitShapeInfo { 56 | var hasRoundedShape: Bool { 57 | leading.hasRoundedShape || trailing.hasRoundedShape 58 | } 59 | } 60 | 61 | extension MenuBarSplitShapeInfo { 62 | static let `default` = MenuBarSplitShapeInfo(leading: .default, trailing: .default) 63 | } 64 | -------------------------------------------------------------------------------- /Ice/MenuBar/Appearance/MenuBarTintKind.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuBarTintKind.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | /// A type that specifies how the menu bar is tinted. 9 | enum MenuBarTintKind: Int, CaseIterable, Codable, Identifiable { 10 | /// The menu bar is not tinted. 11 | case none = 0 12 | /// The menu bar is tinted with a solid color. 13 | case solid = 1 14 | /// The menu bar is tinted with a gradient. 15 | case gradient = 2 16 | 17 | var id: Int { rawValue } 18 | 19 | /// Localized string key representation. 20 | var localized: LocalizedStringKey { 21 | switch self { 22 | case .none: "None" 23 | case .solid: "Solid" 24 | case .gradient: "Gradient" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Ice/MenuBar/ControlItem/ControlItemImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ControlItemImage.swift 3 | // Ice 4 | // 5 | 6 | import Cocoa 7 | 8 | /// A Codable image for a control item. 9 | enum ControlItemImage: Codable, Hashable { 10 | /// An image created from drawing code built into the app. 11 | case builtin(_ name: ImageBuiltinName) 12 | /// A system symbol image. 13 | case symbol(_ name: String) 14 | /// An image in an asset catalog. 15 | case catalog(_ name: String) 16 | /// An image stored as data. 17 | case data(_ data: Data) 18 | 19 | /// A Cocoa representation of this image. 20 | @MainActor 21 | func nsImage(for appState: AppState) -> NSImage? { 22 | switch self { 23 | case .builtin(let name): 24 | return switch name { 25 | case .chevronLarge: StaticBuiltins.Chevron.large 26 | case .chevronSmall: StaticBuiltins.Chevron.small 27 | } 28 | case .symbol(let name): 29 | let image = NSImage(systemSymbolName: name, accessibilityDescription: nil) 30 | image?.isTemplate = true 31 | return image 32 | case .catalog(let name): 33 | guard let originalImage = NSImage(named: name) else { 34 | return nil 35 | } 36 | let originalWidth = originalImage.size.width 37 | let originalHeight = originalImage.size.height 38 | let ratio = max(originalWidth / 25, originalHeight / 17) 39 | let newSize = CGSize(width: originalWidth / ratio, height: originalHeight / ratio) 40 | return originalImage.resized(to: newSize) 41 | case .data(let data): 42 | let image = NSImage(data: data) 43 | let generalSettingsManager = appState.settingsManager.generalSettingsManager 44 | image?.isTemplate = generalSettingsManager.customIceIconIsTemplate 45 | return image 46 | } 47 | } 48 | } 49 | 50 | extension ControlItemImage { 51 | /// A name for an image that is created from drawing code in the app. 52 | enum ImageBuiltinName: Codable, Hashable { 53 | /// A large chevron. 54 | case chevronLarge 55 | /// A small chevron. 56 | case chevronSmall 57 | } 58 | } 59 | 60 | extension ControlItemImage { 61 | /// A namespace for static builtin images. 62 | /// 63 | /// - Note: We use the static properties `large` and `small` to avoid repeatedly 64 | /// executing code every time ``nsImage(for:)`` is called. 65 | private enum StaticBuiltins { 66 | /// A namespace for static builtin chevron images. 67 | enum Chevron { 68 | /// Creates a chevron image with the given size and line width. 69 | private static func chevron(size: CGSize, lineWidth: CGFloat) -> NSImage { 70 | let image = NSImage(size: size, flipped: false) { bounds in 71 | let insetBounds = bounds.insetBy(dx: lineWidth / 2, dy: lineWidth / 2) 72 | let path = NSBezierPath() 73 | path.move(to: CGPoint(x: (insetBounds.midX + insetBounds.maxX) / 2, y: insetBounds.maxY)) 74 | path.line(to: CGPoint(x: (insetBounds.minX + insetBounds.midX) / 2, y: insetBounds.midY)) 75 | path.line(to: CGPoint(x: (insetBounds.midX + insetBounds.maxX) / 2, y: insetBounds.minY)) 76 | path.lineWidth = lineWidth 77 | path.lineCapStyle = .butt 78 | NSColor.black.setStroke() 79 | path.stroke() 80 | return true 81 | } 82 | image.isTemplate = true 83 | return image 84 | } 85 | 86 | /// A large chevron. 87 | static let large = chevron(size: CGSize(width: 12, height: 12), lineWidth: 2) 88 | 89 | /// A small chevron. 90 | static let small = chevron(size: CGSize(width: 9, height: 9), lineWidth: 2) 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Ice/MenuBar/ControlItem/ControlItemImageSet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ControlItemImageSet.swift 3 | // Ice 4 | // 5 | 6 | /// A named set of images that are used by control items. 7 | /// 8 | /// An image set contains images for a control item in both the hidden and visible states. 9 | struct ControlItemImageSet: Codable, Hashable, Identifiable { 10 | enum Name: String, Codable, Hashable { 11 | case arrow = "Arrow" 12 | case chevron = "Chevron" 13 | case door = "Door" 14 | case dot = "Dot" 15 | case ellipsis = "Ellipsis" 16 | case iceCube = "Ice Cube" 17 | case sunglasses = "Sunglasses" 18 | case custom = "Custom" 19 | } 20 | 21 | let name: Name 22 | let hidden: ControlItemImage 23 | let visible: ControlItemImage 24 | 25 | var id: Int { hashValue } 26 | 27 | init(name: Name, hidden: ControlItemImage, visible: ControlItemImage) { 28 | self.name = name 29 | self.hidden = hidden 30 | self.visible = visible 31 | } 32 | 33 | init(name: Name, image: ControlItemImage) { 34 | self.init(name: name, hidden: image, visible: image) 35 | } 36 | } 37 | 38 | extension ControlItemImageSet { 39 | /// The default image set for the Ice icon. 40 | static let defaultIceIcon = ControlItemImageSet( 41 | name: .dot, 42 | hidden: .catalog("DotFill"), 43 | visible: .catalog("DotStroke") 44 | ) 45 | 46 | /// The image sets that the user can choose to display in the Ice icon. 47 | static let userSelectableIceIcons = [ 48 | ControlItemImageSet( 49 | name: .arrow, 50 | hidden: .symbol("arrowshape.left.fill"), 51 | visible: .symbol("arrowshape.right.fill") 52 | ), 53 | ControlItemImageSet( 54 | name: .chevron, 55 | hidden: .symbol("chevron.left"), 56 | visible: .symbol("chevron.right") 57 | ), 58 | ControlItemImageSet( 59 | name: .door, 60 | hidden: .symbol("door.left.hand.closed"), 61 | visible: .symbol("door.left.hand.open") 62 | ), 63 | ControlItemImageSet( 64 | name: .dot, 65 | hidden: .catalog("DotFill"), 66 | visible: .catalog("DotStroke") 67 | ), 68 | ControlItemImageSet( 69 | name: .ellipsis, 70 | hidden: .catalog("EllipsisFill"), 71 | visible: .catalog("EllipsisStroke") 72 | ), 73 | ControlItemImageSet( 74 | name: .iceCube, 75 | hidden: .catalog("IceCubeStroke"), 76 | visible: .catalog("IceCubeFill") 77 | ), 78 | ControlItemImageSet( 79 | name: .sunglasses, 80 | hidden: .symbol("sunglasses.fill"), 81 | visible: .symbol("sunglasses") 82 | ), 83 | ] 84 | } 85 | -------------------------------------------------------------------------------- /Ice/Permissions/PermissionsManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PermissionsManager.swift 3 | // Ice 4 | // 5 | 6 | import Combine 7 | import Foundation 8 | 9 | /// A type that manages the permissions of the app. 10 | @MainActor 11 | final class PermissionsManager: ObservableObject { 12 | /// The state of the granted permissions for the app. 13 | enum PermissionsState { 14 | case missingPermissions 15 | case hasAllPermissions 16 | case hasRequiredPermissions 17 | } 18 | 19 | /// The state of the granted permissions for the app. 20 | @Published var permissionsState = PermissionsState.missingPermissions 21 | 22 | let accessibilityPermission: AccessibilityPermission 23 | 24 | let screenRecordingPermission: ScreenRecordingPermission 25 | 26 | let allPermissions: [Permission] 27 | 28 | private(set) weak var appState: AppState? 29 | 30 | private var cancellables = Set() 31 | 32 | var requiredPermissions: [Permission] { 33 | allPermissions.filter { $0.isRequired } 34 | } 35 | 36 | init(appState: AppState) { 37 | self.appState = appState 38 | self.accessibilityPermission = AccessibilityPermission() 39 | self.screenRecordingPermission = ScreenRecordingPermission() 40 | self.allPermissions = [ 41 | accessibilityPermission, 42 | screenRecordingPermission, 43 | ] 44 | configureCancellables() 45 | } 46 | 47 | private func configureCancellables() { 48 | var c = Set() 49 | 50 | Publishers.Merge( 51 | accessibilityPermission.$hasPermission.mapToVoid(), 52 | screenRecordingPermission.$hasPermission.mapToVoid() 53 | ) 54 | .receive(on: DispatchQueue.main) 55 | .sink { [weak self] in 56 | guard let self else { 57 | return 58 | } 59 | if allPermissions.allSatisfy({ $0.hasPermission }) { 60 | permissionsState = .hasAllPermissions 61 | } else if requiredPermissions.allSatisfy({ $0.hasPermission }) { 62 | permissionsState = .hasRequiredPermissions 63 | } else { 64 | permissionsState = .missingPermissions 65 | } 66 | } 67 | .store(in: &c) 68 | 69 | cancellables = c 70 | } 71 | 72 | /// Stops running all permissions checks. 73 | func stopAllChecks() { 74 | for permission in allPermissions { 75 | permission.stopCheck() 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Ice/Permissions/PermissionsWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PermissionsWindow.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct PermissionsWindow: Scene { 9 | @ObservedObject var appState: AppState 10 | 11 | var body: some Scene { 12 | Window(Constants.permissionsWindowTitle, id: Constants.permissionsWindowID) { 13 | PermissionsView() 14 | .readWindow { window in 15 | guard let window else { 16 | return 17 | } 18 | appState.assignPermissionsWindow(window) 19 | } 20 | } 21 | .windowResizability(.contentSize) 22 | .windowStyle(.hiddenTitleBar) 23 | .environmentObject(appState.permissionsManager) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Ice/Resources/Acknowledgements.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Ice/Resources/Acknowledgements.pdf -------------------------------------------------------------------------------- /Ice/Settings/SettingsManagers/AdvancedSettingsManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AdvancedSettingsManager.swift 3 | // Ice 4 | // 5 | 6 | import Combine 7 | import Foundation 8 | 9 | @MainActor 10 | final class AdvancedSettingsManager: ObservableObject { 11 | /// A Boolean value that indicates whether the application menus 12 | /// should be hidden if needed to show all menu bar items. 13 | @Published var hideApplicationMenus = true 14 | 15 | /// A Boolean value that indicates whether section divider control 16 | /// items should be shown. 17 | @Published var showSectionDividers = false 18 | 19 | /// A Boolean value that indicates whether the always-hidden section 20 | /// is enabled. 21 | @Published var enableAlwaysHiddenSection = false 22 | 23 | /// A Boolean value that indicates whether the always-hidden section 24 | /// can be toggled by holding down the Option key. 25 | @Published var canToggleAlwaysHiddenSection = true 26 | 27 | /// The delay before showing on hover. 28 | @Published var showOnHoverDelay: TimeInterval = 0.2 29 | 30 | /// Time interval to temporarily show items for. 31 | @Published var tempShowInterval: TimeInterval = 15 32 | 33 | /// A Boolean value that indicates whether to show all sections when 34 | /// the user is dragging items in the menu bar. 35 | @Published var showAllSectionsOnUserDrag = true 36 | 37 | /// Storage for internal observers. 38 | private var cancellables = Set() 39 | 40 | /// The shared app state. 41 | private(set) weak var appState: AppState? 42 | 43 | init(appState: AppState) { 44 | self.appState = appState 45 | } 46 | 47 | func performSetup() { 48 | loadInitialState() 49 | configureCancellables() 50 | } 51 | 52 | private func loadInitialState() { 53 | Defaults.ifPresent(key: .hideApplicationMenus, assign: &hideApplicationMenus) 54 | Defaults.ifPresent(key: .showSectionDividers, assign: &showSectionDividers) 55 | Defaults.ifPresent(key: .enableAlwaysHiddenSection, assign: &enableAlwaysHiddenSection) 56 | Defaults.ifPresent(key: .canToggleAlwaysHiddenSection, assign: &canToggleAlwaysHiddenSection) 57 | Defaults.ifPresent(key: .showOnHoverDelay, assign: &showOnHoverDelay) 58 | Defaults.ifPresent(key: .tempShowInterval, assign: &tempShowInterval) 59 | Defaults.ifPresent(key: .showAllSectionsOnUserDrag, assign: &showAllSectionsOnUserDrag) 60 | } 61 | 62 | private func configureCancellables() { 63 | var c = Set() 64 | 65 | $hideApplicationMenus 66 | .receive(on: DispatchQueue.main) 67 | .sink { shouldHide in 68 | Defaults.set(shouldHide, forKey: .hideApplicationMenus) 69 | } 70 | .store(in: &c) 71 | 72 | $showSectionDividers 73 | .receive(on: DispatchQueue.main) 74 | .sink { shouldShow in 75 | Defaults.set(shouldShow, forKey: .showSectionDividers) 76 | } 77 | .store(in: &c) 78 | 79 | $enableAlwaysHiddenSection 80 | .receive(on: DispatchQueue.main) 81 | .sink { enable in 82 | Defaults.set(enable, forKey: .enableAlwaysHiddenSection) 83 | } 84 | .store(in: &c) 85 | 86 | $canToggleAlwaysHiddenSection 87 | .receive(on: DispatchQueue.main) 88 | .sink { canToggle in 89 | Defaults.set(canToggle, forKey: .canToggleAlwaysHiddenSection) 90 | } 91 | .store(in: &c) 92 | 93 | $showOnHoverDelay 94 | .receive(on: DispatchQueue.main) 95 | .sink { delay in 96 | Defaults.set(delay, forKey: .showOnHoverDelay) 97 | } 98 | .store(in: &c) 99 | 100 | $tempShowInterval 101 | .receive(on: DispatchQueue.main) 102 | .sink { interval in 103 | Defaults.set(interval, forKey: .tempShowInterval) 104 | } 105 | .store(in: &c) 106 | 107 | $showAllSectionsOnUserDrag 108 | .receive(on: DispatchQueue.main) 109 | .sink { showAll in 110 | Defaults.set(showAll, forKey: .showAllSectionsOnUserDrag) 111 | } 112 | .store(in: &c) 113 | 114 | cancellables = c 115 | } 116 | } 117 | 118 | // MARK: AdvancedSettingsManager: BindingExposable 119 | extension AdvancedSettingsManager: BindingExposable { } 120 | -------------------------------------------------------------------------------- /Ice/Settings/SettingsManagers/HotkeySettingsManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HotkeySettingsManager.swift 3 | // Ice 4 | // 5 | 6 | import Combine 7 | import Foundation 8 | 9 | @MainActor 10 | final class HotkeySettingsManager: ObservableObject { 11 | /// All hotkeys. 12 | @Published private(set) var hotkeys = HotkeyAction.allCases.map { action in 13 | Hotkey(keyCombination: nil, action: action) 14 | } 15 | 16 | /// Encoder for hotkeys. 17 | private let encoder = JSONEncoder() 18 | 19 | /// Decoder for hotkeys. 20 | private let decoder = JSONDecoder() 21 | 22 | /// Storage for internal observers. 23 | private var cancellables = Set() 24 | 25 | /// The shared app state. 26 | private(set) weak var appState: AppState? 27 | 28 | init(appState: AppState) { 29 | self.appState = appState 30 | } 31 | 32 | func performSetup() { 33 | loadInitialState() 34 | configureCancellables() 35 | } 36 | 37 | private func loadInitialState() { 38 | if let dict = Defaults.dictionary(forKey: .hotkeys) as? [String: Data] { 39 | for hotkey in hotkeys { 40 | if let data = dict[hotkey.action.rawValue] { 41 | do { 42 | hotkey.keyCombination = try decoder.decode(KeyCombination?.self, from: data) 43 | } catch { 44 | Logger.hotkeySettingsManager.error("Error decoding hotkey: \(error)") 45 | } 46 | } 47 | } 48 | } 49 | } 50 | 51 | private func configureCancellables() { 52 | var c = Set() 53 | 54 | $hotkeys.combineLatest(Publishers.MergeMany(hotkeys.map { $0.$keyCombination })) 55 | .receive(on: DispatchQueue.main) 56 | .sink { [weak self] hotkeys, _ in 57 | guard 58 | let self, 59 | let appState 60 | else { 61 | return 62 | } 63 | var dict = [String: Data]() 64 | for hotkey in hotkeys { 65 | hotkey.assignAppState(appState) 66 | do { 67 | dict[hotkey.action.rawValue] = try self.encoder.encode(hotkey.keyCombination) 68 | } catch { 69 | Logger.hotkeySettingsManager.error("Error encoding hotkey: \(error)") 70 | } 71 | } 72 | Defaults.set(dict, forKey: .hotkeys) 73 | } 74 | .store(in: &c) 75 | 76 | cancellables = c 77 | } 78 | 79 | func hotkey(withAction action: HotkeyAction) -> Hotkey? { 80 | hotkeys.first { $0.action == action } 81 | } 82 | } 83 | 84 | // MARK: - Logger 85 | private extension Logger { 86 | static let hotkeySettingsManager = Logger(category: "HotkeySettingsManager") 87 | } 88 | -------------------------------------------------------------------------------- /Ice/Settings/SettingsManagers/SettingsManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsManager.swift 3 | // Ice 4 | // 5 | 6 | import Combine 7 | 8 | @MainActor 9 | final class SettingsManager: ObservableObject { 10 | /// The manager for general settings. 11 | let generalSettingsManager: GeneralSettingsManager 12 | 13 | /// The manager for advanced settings. 14 | let advancedSettingsManager: AdvancedSettingsManager 15 | 16 | /// The manager for hotkey settings. 17 | let hotkeySettingsManager: HotkeySettingsManager 18 | 19 | /// Storage for internal observers. 20 | private var cancellables = Set() 21 | 22 | /// The shared app state. 23 | private(set) weak var appState: AppState? 24 | 25 | init(appState: AppState) { 26 | self.generalSettingsManager = GeneralSettingsManager(appState: appState) 27 | self.advancedSettingsManager = AdvancedSettingsManager(appState: appState) 28 | self.hotkeySettingsManager = HotkeySettingsManager(appState: appState) 29 | self.appState = appState 30 | } 31 | 32 | func performSetup() { 33 | configureCancellables() 34 | generalSettingsManager.performSetup() 35 | advancedSettingsManager.performSetup() 36 | hotkeySettingsManager.performSetup() 37 | } 38 | 39 | private func configureCancellables() { 40 | var c = Set() 41 | 42 | generalSettingsManager.objectWillChange 43 | .sink { [weak self] in 44 | self?.objectWillChange.send() 45 | } 46 | .store(in: &c) 47 | advancedSettingsManager.objectWillChange 48 | .sink { [weak self] in 49 | self?.objectWillChange.send() 50 | } 51 | .store(in: &c) 52 | hotkeySettingsManager.objectWillChange 53 | .sink { [weak self] in 54 | self?.objectWillChange.send() 55 | } 56 | .store(in: &c) 57 | 58 | cancellables = c 59 | } 60 | } 61 | 62 | // MARK: SettingsManager: BindingExposable 63 | extension SettingsManager: BindingExposable { } 64 | -------------------------------------------------------------------------------- /Ice/Settings/SettingsPanes/AboutSettingsPane.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutSettingsPane.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct AboutSettingsPane: View { 9 | @Environment(\.openURL) private var openURL 10 | @State private var frame = CGRect.zero 11 | 12 | private var acknowledgementsURL: URL { 13 | // swiftlint:disable:next force_unwrapping 14 | Bundle.main.url(forResource: "Acknowledgements", withExtension: "pdf")! 15 | } 16 | 17 | private var contributeURL: URL { 18 | // swiftlint:disable:next force_unwrapping 19 | URL(string: "https://github.com/jordanbaird/Ice")! 20 | } 21 | 22 | private var issuesURL: URL { 23 | contributeURL.appendingPathComponent("issues") 24 | } 25 | 26 | private var donateURL: URL { 27 | // swiftlint:disable:next force_unwrapping 28 | URL(string: "https://icemenubar.app/Donate")! 29 | } 30 | 31 | private var minFrameDimension: CGFloat { 32 | min(frame.width, frame.height) 33 | } 34 | 35 | var body: some View { 36 | HStack { 37 | if let nsImage = NSImage(named: NSImage.applicationIconName) { 38 | Image(nsImage: nsImage) 39 | .resizable() 40 | .aspectRatio(contentMode: .fit) 41 | .frame(width: minFrameDimension / 1.5) 42 | } 43 | 44 | VStack(alignment: .leading) { 45 | Text("Ice") 46 | .font(.system(size: minFrameDimension / 7)) 47 | .foregroundStyle(.primary) 48 | 49 | HStack(spacing: 4) { 50 | Text("Version") 51 | Text(Constants.appVersion) 52 | } 53 | .font(.system(size: minFrameDimension / 30)) 54 | .foregroundStyle(.secondary) 55 | 56 | Text(Constants.copyright) 57 | .font(.system(size: minFrameDimension / 37)) 58 | .foregroundStyle(.tertiary) 59 | } 60 | .fontWeight(.medium) 61 | .padding([.vertical, .trailing]) 62 | } 63 | .frame(maxWidth: .infinity, maxHeight: .infinity) 64 | .onFrameChange(update: $frame) 65 | .bottomBar { 66 | HStack { 67 | Button("Quit Ice") { 68 | NSApp.terminate(nil) 69 | } 70 | Spacer() 71 | Button("Acknowledgements") { 72 | NSWorkspace.shared.open(acknowledgementsURL) 73 | } 74 | Button("Contribute") { 75 | openURL(contributeURL) 76 | } 77 | Button("Report a Bug") { 78 | openURL(issuesURL) 79 | } 80 | Button { 81 | openURL(donateURL) 82 | } label: { 83 | Label( 84 | "Support Ice", 85 | systemImage: "heart.circle.fill" 86 | ) 87 | } 88 | } 89 | .padding() 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Ice/Settings/SettingsPanes/HotkeysSettingsPane.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HotkeysSettingsPane.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct HotkeysSettingsPane: View { 9 | @EnvironmentObject var appState: AppState 10 | 11 | private var hotkeySettingsManager: HotkeySettingsManager { 12 | appState.settingsManager.hotkeySettingsManager 13 | } 14 | 15 | var body: some View { 16 | IceForm { 17 | IceSection("Menu Bar Sections") { 18 | hotkeyRecorder(forSection: .hidden) 19 | hotkeyRecorder(forSection: .alwaysHidden) 20 | } 21 | IceSection("Menu Bar Items") { 22 | hotkeyRecorder(forAction: .searchMenuBarItems) 23 | } 24 | IceSection("Other") { 25 | hotkeyRecorder(forAction: .enableIceBar) 26 | hotkeyRecorder(forAction: .showSectionDividers) 27 | hotkeyRecorder(forAction: .toggleApplicationMenus) 28 | } 29 | } 30 | } 31 | 32 | @ViewBuilder 33 | private func hotkeyRecorder(forAction action: HotkeyAction) -> some View { 34 | if let hotkey = hotkeySettingsManager.hotkey(withAction: action) { 35 | HotkeyRecorder(hotkey: hotkey) { 36 | switch action { 37 | case .toggleHiddenSection: 38 | Text("Toggle the hidden section") 39 | case .toggleAlwaysHiddenSection: 40 | Text("Toggle the always-hidden section") 41 | case .searchMenuBarItems: 42 | Text("Search menu bar items") 43 | case .enableIceBar: 44 | Text("Enable the Ice Bar") 45 | case .showSectionDividers: 46 | Text("Show section dividers") 47 | case .toggleApplicationMenus: 48 | Text("Toggle application menus") 49 | } 50 | } 51 | } 52 | } 53 | 54 | @ViewBuilder 55 | private func hotkeyRecorder(forSection name: MenuBarSection.Name) -> some View { 56 | if appState.menuBarManager.section(withName: name)?.isEnabled == true { 57 | if case .hidden = name { 58 | hotkeyRecorder(forAction: .toggleHiddenSection) 59 | } else if case .alwaysHidden = name { 60 | hotkeyRecorder(forAction: .toggleAlwaysHiddenSection) 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Ice/Settings/SettingsPanes/MenuBarAppearanceSettingsPane.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuBarAppearanceSettingsPane.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct MenuBarAppearanceSettingsPane: View { 9 | @EnvironmentObject var appState: AppState 10 | 11 | var body: some View { 12 | MenuBarAppearanceEditor(location: .settings) 13 | .environmentObject(appState.appearanceManager) 14 | } 15 | } 16 | 17 | #Preview { 18 | MenuBarAppearanceSettingsPane() 19 | .environmentObject(AppState()) 20 | } 21 | -------------------------------------------------------------------------------- /Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuBarLayoutSettingsPane.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct MenuBarLayoutSettingsPane: View { 9 | @EnvironmentObject var appState: AppState 10 | 11 | var body: some View { 12 | if !ScreenCapture.cachedCheckPermissions() { 13 | missingScreenRecordingPermission 14 | } else if appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults { 15 | cannotArrange 16 | } else { 17 | IceForm(alignment: .leading, spacing: 20) { 18 | header 19 | layoutBars 20 | } 21 | } 22 | } 23 | 24 | @ViewBuilder 25 | private var header: some View { 26 | Text("Drag to arrange your menu bar items") 27 | .font(.title2) 28 | 29 | IceGroupBox { 30 | AnnotationView( 31 | alignment: .center, 32 | font: .callout.bold() 33 | ) { 34 | Label { 35 | Text("Tip: you can also arrange menu bar items by Command + dragging them in the menu bar") 36 | } icon: { 37 | Image(systemName: "lightbulb") 38 | } 39 | } 40 | } 41 | } 42 | 43 | @ViewBuilder 44 | private var layoutBars: some View { 45 | VStack(spacing: 25) { 46 | ForEach(MenuBarSection.Name.allCases, id: \.self) { section in 47 | layoutBar(for: section) 48 | } 49 | } 50 | } 51 | 52 | @ViewBuilder 53 | private var cannotArrange: some View { 54 | Text("Ice cannot arrange menu bar items in automatically hidden menu bars") 55 | .font(.title3) 56 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) 57 | } 58 | 59 | @ViewBuilder 60 | private var missingScreenRecordingPermission: some View { 61 | VStack { 62 | Text("Menu bar layout requires screen recording permissions") 63 | .font(.title2) 64 | 65 | Button { 66 | appState.navigationState.settingsNavigationIdentifier = .advanced 67 | } label: { 68 | Text("Go to Advanced Settings") 69 | } 70 | .buttonStyle(.link) 71 | } 72 | } 73 | 74 | @ViewBuilder 75 | private func layoutBar(for section: MenuBarSection.Name) -> some View { 76 | if 77 | let section = appState.menuBarManager.section(withName: section), 78 | section.isEnabled 79 | { 80 | VStack(alignment: .leading, spacing: 4) { 81 | Text("\(section.name.displayString) Section") 82 | .font(.system(size: 14)) 83 | .padding(.leading, 2) 84 | 85 | LayoutBar(section: section) 86 | .environmentObject(appState.imageCache) 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Ice/Settings/SettingsPanes/UpdatesSettingsPane.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdatesSettingsPane.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct UpdatesSettingsPane: View { 9 | @EnvironmentObject var appState: AppState 10 | 11 | private var updatesManager: UpdatesManager { 12 | appState.updatesManager 13 | } 14 | 15 | private var lastUpdateCheckString: String { 16 | if let date = updatesManager.lastUpdateCheckDate { 17 | date.formatted(date: .abbreviated, time: .standard) 18 | } else { 19 | "Never" 20 | } 21 | } 22 | 23 | var body: some View { 24 | IceForm { 25 | IceSection { 26 | automaticallyCheckForUpdates 27 | automaticallyDownloadUpdates 28 | } 29 | if updatesManager.canCheckForUpdates { 30 | IceSection { 31 | checkForUpdates 32 | } 33 | } 34 | } 35 | } 36 | 37 | @ViewBuilder 38 | private var automaticallyCheckForUpdates: some View { 39 | Toggle( 40 | "Automatically check for updates", 41 | isOn: updatesManager.bindings.automaticallyChecksForUpdates 42 | ) 43 | } 44 | 45 | @ViewBuilder 46 | private var automaticallyDownloadUpdates: some View { 47 | Toggle( 48 | "Automatically download updates", 49 | isOn: updatesManager.bindings.automaticallyDownloadsUpdates 50 | ) 51 | } 52 | 53 | @ViewBuilder 54 | private var checkForUpdates: some View { 55 | HStack { 56 | Button("Check for Updates…") { 57 | updatesManager.checkForUpdates() 58 | } 59 | .controlSize(.large) 60 | 61 | Spacer() 62 | 63 | HStack(spacing: 2) { 64 | Text("Last checked:") 65 | Text(lastUpdateCheckString) 66 | } 67 | .lineLimit(1) 68 | .font(.caption) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Ice/Settings/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct SettingsView: View { 9 | @EnvironmentObject var navigationState: AppNavigationState 10 | @Environment(\.sidebarRowSize) var sidebarRowSize 11 | 12 | private var sidebarWidth: CGFloat { 13 | switch sidebarRowSize { 14 | case .small: 190 15 | case .medium: 210 16 | case .large: 230 17 | @unknown default: 210 18 | } 19 | } 20 | 21 | private var sidebarItemHeight: CGFloat { 22 | switch sidebarRowSize { 23 | case .small: 26 24 | case .medium: 32 25 | case .large: 34 26 | @unknown default: 32 27 | } 28 | } 29 | 30 | private var sidebarItemFontSize: CGFloat { 31 | switch sidebarRowSize { 32 | case .small: 13 33 | case .medium: 15 34 | case .large: 16 35 | @unknown default: 15 36 | } 37 | } 38 | 39 | var body: some View { 40 | NavigationSplitView { 41 | sidebar 42 | } detail: { 43 | detailView 44 | } 45 | .navigationTitle(navigationState.settingsNavigationIdentifier.localized) 46 | } 47 | 48 | @ViewBuilder 49 | private var sidebar: some View { 50 | List(selection: $navigationState.settingsNavigationIdentifier) { 51 | Section { 52 | ForEach(SettingsNavigationIdentifier.allCases, id: \.self) { identifier in 53 | sidebarItem(for: identifier) 54 | } 55 | } header: { 56 | Text("Ice") 57 | .font(.system(size: 36, weight: .medium)) 58 | .foregroundStyle(.primary) 59 | .padding(.vertical, 5) 60 | } 61 | .collapsible(false) 62 | } 63 | .scrollDisabled(true) 64 | .removeSidebarToggle() 65 | .navigationSplitViewColumnWidth(sidebarWidth) 66 | } 67 | 68 | @ViewBuilder 69 | private var detailView: some View { 70 | switch navigationState.settingsNavigationIdentifier { 71 | case .general: 72 | GeneralSettingsPane() 73 | case .menuBarLayout: 74 | MenuBarLayoutSettingsPane() 75 | case .menuBarAppearance: 76 | MenuBarAppearanceSettingsPane() 77 | case .hotkeys: 78 | HotkeysSettingsPane() 79 | case .advanced: 80 | AdvancedSettingsPane() 81 | case .updates: 82 | UpdatesSettingsPane() 83 | case .about: 84 | AboutSettingsPane() 85 | } 86 | } 87 | 88 | @ViewBuilder 89 | private func sidebarItem(for identifier: SettingsNavigationIdentifier) -> some View { 90 | Label { 91 | Text(identifier.localized) 92 | .font(.system(size: sidebarItemFontSize)) 93 | .padding(.leading, 2) 94 | } icon: { 95 | icon(for: identifier).view 96 | } 97 | .frame(height: sidebarItemHeight) 98 | } 99 | 100 | private func icon(for identifier: SettingsNavigationIdentifier) -> IconResource { 101 | switch identifier { 102 | case .general: .systemSymbol("gearshape") 103 | case .menuBarLayout: .systemSymbol("rectangle.topthird.inset.filled") 104 | case .menuBarAppearance: .systemSymbol("swatchpalette") 105 | case .hotkeys: .systemSymbol("keyboard") 106 | case .advanced: .systemSymbol("gearshape.2") 107 | case .updates: .systemSymbol("arrow.triangle.2.circlepath.circle") 108 | case .about: .assetCatalog(.iceCubeStroke) 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Ice/Settings/SettingsWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsWindow.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct SettingsWindow: Scene { 9 | @ObservedObject var appState: AppState 10 | 11 | var body: some Scene { 12 | Window(Constants.settingsWindowTitle, id: Constants.settingsWindowID) { 13 | SettingsView() 14 | .readWindow { window in 15 | guard let window else { 16 | return 17 | } 18 | appState.assignSettingsWindow(window) 19 | } 20 | .frame(minWidth: 825, minHeight: 500) 21 | } 22 | .commandsRemoved() 23 | .windowResizability(.contentSize) 24 | .defaultSize(width: 900, height: 625) 25 | .environmentObject(appState) 26 | .environmentObject(appState.navigationState) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Ice/Swizzling/NSSplitViewItem+swizzledCanCollapse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSSplitViewItem+swizzledCanCollapse.swift 3 | // Ice 4 | // 5 | 6 | import Cocoa 7 | 8 | extension NSSplitViewItem { 9 | @nonobjc private static let swizzler: () = { 10 | let originalCanCollapseSel = #selector(getter: canCollapse) 11 | let swizzledCanCollapseSel = #selector(getter: swizzledCanCollapse) 12 | 13 | guard 14 | let originalCanCollapseMethod = class_getInstanceMethod(NSSplitViewItem.self, originalCanCollapseSel), 15 | let swizzledCanCollapseMethod = class_getInstanceMethod(NSSplitViewItem.self, swizzledCanCollapseSel) 16 | else { 17 | return 18 | } 19 | 20 | method_exchangeImplementations(originalCanCollapseMethod, swizzledCanCollapseMethod) 21 | }() 22 | 23 | @objc private var swizzledCanCollapse: Bool { 24 | if 25 | let window = viewController.view.window, 26 | window.identifier?.rawValue == Constants.settingsWindowID 27 | { 28 | return false 29 | } 30 | return self.swizzledCanCollapse 31 | } 32 | 33 | static func swizzle() { 34 | _ = swizzler 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Ice/UI/HotkeyRecorder/HotkeyRecorder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HotkeyRecorder.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct HotkeyRecorder: View { 9 | @StateObject private var model: HotkeyRecorderModel 10 | 11 | private let label: Label 12 | 13 | init(hotkey: Hotkey, @ViewBuilder label: () -> Label) { 14 | self._model = StateObject(wrappedValue: HotkeyRecorderModel(hotkey: hotkey)) 15 | self.label = label() 16 | } 17 | 18 | var body: some View { 19 | IceLabeledContent { 20 | HStack(spacing: 1) { 21 | leadingSegment 22 | trailingSegment 23 | } 24 | .frame(width: 130, height: 22) 25 | .alignmentGuide(.firstTextBaseline) { dimension in 26 | dimension[VerticalAlignment.center] 27 | } 28 | } label: { 29 | label 30 | .alignmentGuide(.firstTextBaseline) { dimension in 31 | dimension[VerticalAlignment.center] 32 | } 33 | } 34 | .alert( 35 | "Hotkey is reserved by macOS", 36 | isPresented: $model.isPresentingReservedByMacOSError 37 | ) { 38 | Button("OK") { 39 | model.isPresentingReservedByMacOSError = false 40 | } 41 | } 42 | } 43 | 44 | @ViewBuilder 45 | private var leadingSegment: some View { 46 | Button { 47 | model.startRecording() 48 | } label: { 49 | leadingSegmentLabel 50 | } 51 | .buttonStyle( 52 | HotkeyRecorderSegmentButtonStyle( 53 | segment: .leading, 54 | isHighlighted: model.isRecording 55 | ) 56 | ) 57 | } 58 | 59 | @ViewBuilder 60 | private var trailingSegment: some View { 61 | Button { 62 | if model.isRecording { 63 | model.stopRecording() 64 | } else if model.hotkey.isEnabled { 65 | model.hotkey.keyCombination = nil 66 | } else { 67 | model.startRecording() 68 | } 69 | } label: { 70 | trailingSegmentLabel 71 | } 72 | .buttonStyle( 73 | HotkeyRecorderSegmentButtonStyle( 74 | segment: .trailing, 75 | isHighlighted: false 76 | ) 77 | ) 78 | .aspectRatio(1, contentMode: .fit) 79 | } 80 | 81 | @ViewBuilder 82 | private var leadingSegmentLabel: some View { 83 | if model.isRecording { 84 | Text("Type Hotkey") 85 | } else if model.hotkey.isEnabled { 86 | if let keyCombination = model.hotkey.keyCombination { 87 | HStack(spacing: 0) { 88 | Text(keyCombination.modifiers.symbolicValue) 89 | Text(keyCombination.key.stringValue.capitalized) 90 | } 91 | } else { 92 | Text("ERROR") 93 | } 94 | } else { 95 | Text("Record Hotkey") 96 | } 97 | } 98 | 99 | @ViewBuilder 100 | private var trailingSegmentLabel: some View { 101 | let symbolString = if model.isRecording { 102 | "escape" 103 | } else if model.hotkey.isEnabled { 104 | "xmark.circle.fill" 105 | } else { 106 | "record.circle" 107 | } 108 | Image(systemName: symbolString) 109 | .resizable() 110 | .aspectRatio(contentMode: .fill) 111 | .padding(1) 112 | } 113 | } 114 | 115 | private struct HotkeyRecorderSegmentButtonStyle: PrimitiveButtonStyle { 116 | enum Segment { 117 | case leading 118 | case trailing 119 | } 120 | 121 | @State private var frame = CGRect.zero 122 | @State private var isPressed = false 123 | 124 | var segment: Segment 125 | var isHighlighted: Bool 126 | 127 | private var radii: RectangleCornerRadii { 128 | switch segment { 129 | case .leading: 130 | RectangleCornerRadii(topLeading: 5, bottomLeading: 5) 131 | case .trailing: 132 | RectangleCornerRadii(bottomTrailing: 5, topTrailing: 5) 133 | } 134 | } 135 | 136 | func makeBody(configuration: Configuration) -> some View { 137 | UnevenRoundedRectangle(cornerRadii: radii, style: .circular) 138 | .fill(isHighlighted || isPressed ? .tertiary : .quaternary) 139 | .overlay { 140 | configuration.label 141 | .lineLimit(1) 142 | .foregroundStyle(.primary) 143 | .padding(EdgeInsets(top: 3, leading: 8, bottom: 3, trailing: 8)) 144 | } 145 | .simultaneousGesture( 146 | DragGesture(minimumDistance: 0) 147 | .onChanged { value in 148 | isPressed = frame.contains(value.location) 149 | } 150 | .onEnded { value in 151 | isPressed = false 152 | if frame.contains(value.location) { 153 | configuration.trigger() 154 | } 155 | } 156 | ) 157 | .onFrameChange(update: $frame) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /Ice/UI/HotkeyRecorder/HotkeyRecorderModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HotkeyRecorderModel.swift 3 | // Ice 4 | // 5 | 6 | import Combine 7 | import SwiftUI 8 | 9 | @MainActor 10 | final class HotkeyRecorderModel: ObservableObject { 11 | @EnvironmentObject private var appState: AppState 12 | 13 | @Published private(set) var isRecording = false 14 | 15 | @Published var isPresentingReservedByMacOSError = false 16 | 17 | let hotkey: Hotkey 18 | 19 | private lazy var monitor = LocalEventMonitor(mask: .keyDown) { [weak self] event in 20 | guard let self else { 21 | return event 22 | } 23 | handleKeyDown(event: event) 24 | return nil 25 | } 26 | 27 | private var cancellables = Set() 28 | 29 | init(hotkey: Hotkey) { 30 | self.hotkey = hotkey 31 | configureCancellables() 32 | } 33 | 34 | private func configureCancellables() { 35 | var c = Set() 36 | 37 | hotkey.objectWillChange 38 | .sink { [weak self] in 39 | self?.objectWillChange.send() 40 | } 41 | .store(in: &c) 42 | 43 | cancellables = c 44 | } 45 | 46 | func startRecording() { 47 | guard !isRecording else { 48 | return 49 | } 50 | hotkey.disable() 51 | monitor.start() 52 | isRecording = true 53 | } 54 | 55 | func stopRecording() { 56 | guard isRecording else { 57 | return 58 | } 59 | monitor.stop() 60 | hotkey.enable() 61 | isRecording = false 62 | } 63 | 64 | private func handleKeyDown(event: NSEvent) { 65 | let keyCombination = KeyCombination(event: event) 66 | guard !keyCombination.modifiers.isEmpty else { 67 | if keyCombination.key == .escape { 68 | stopRecording() 69 | } else { 70 | NSSound.beep() 71 | } 72 | return 73 | } 74 | guard keyCombination.modifiers != .shift else { 75 | NSSound.beep() 76 | return 77 | } 78 | guard !keyCombination.isReservedBySystem else { 79 | isPresentingReservedByMacOSError = true 80 | return 81 | } 82 | hotkey.keyCombination = keyCombination 83 | stopRecording() 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Ice/UI/IceBar/IceBarColorManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IceBarColorManager.swift 3 | // Ice 4 | // 5 | 6 | import Cocoa 7 | import Combine 8 | 9 | final class IceBarColorManager: ObservableObject { 10 | @Published private(set) var colorInfo: MenuBarAverageColorInfo? 11 | 12 | private weak var iceBarPanel: IceBarPanel? 13 | 14 | private var windowImage: CGImage? 15 | 16 | private var cancellables = Set() 17 | 18 | init(iceBarPanel: IceBarPanel) { 19 | self.iceBarPanel = iceBarPanel 20 | configureCancellables() 21 | } 22 | 23 | private func configureCancellables() { 24 | var c = Set() 25 | 26 | if let iceBarPanel { 27 | iceBarPanel.publisher(for: \.screen) 28 | .receive(on: DispatchQueue.main) 29 | .sink { [weak self] screen in 30 | guard 31 | let self, 32 | let screen, 33 | screen == .main 34 | else { 35 | return 36 | } 37 | updateWindowImage(for: screen) 38 | } 39 | .store(in: &c) 40 | 41 | Publishers.CombineLatest( 42 | iceBarPanel.publisher(for: \.frame), 43 | iceBarPanel.publisher(for: \.isVisible) 44 | ) 45 | .receive(on: DispatchQueue.main) 46 | .sink { [weak self] frame, isVisible in 47 | guard 48 | let self, 49 | let screen = iceBarPanel.screen, 50 | isVisible, 51 | screen == .main 52 | else { 53 | return 54 | } 55 | updateColorInfo(with: frame, screen: screen) 56 | } 57 | .store(in: &c) 58 | 59 | Publishers.Merge4( 60 | NSWorkspace.shared.notificationCenter 61 | .publisher(for: NSWorkspace.activeSpaceDidChangeNotification) 62 | .mapToVoid(), 63 | NotificationCenter.default 64 | .publisher(for: NSApplication.didChangeScreenParametersNotification) 65 | .mapToVoid(), 66 | DistributedNotificationCenter.default() 67 | .publisher(for: DistributedNotificationCenter.interfaceThemeChangedNotification) 68 | .mapToVoid(), 69 | Timer.publish(every: 5, on: .main, in: .default) 70 | .autoconnect() 71 | .mapToVoid() 72 | ) 73 | .receive(on: DispatchQueue.main) 74 | .sink { [weak self, weak iceBarPanel] in 75 | guard 76 | let self, 77 | let iceBarPanel, 78 | let screen = iceBarPanel.screen, 79 | screen == .main 80 | else { 81 | return 82 | } 83 | updateWindowImage(for: screen) 84 | if iceBarPanel.isVisible { 85 | updateColorInfo(with: iceBarPanel.frame, screen: screen) 86 | } 87 | } 88 | .store(in: &c) 89 | } 90 | 91 | cancellables = c 92 | } 93 | 94 | private func updateWindowImage(for screen: NSScreen) { 95 | let displayID = screen.displayID 96 | if 97 | let window = WindowInfo.getMenuBarWindow(for: displayID), 98 | let image = ScreenCapture.captureWindow(window.windowID, option: .nominalResolution) 99 | { 100 | windowImage = image 101 | } else { 102 | windowImage = nil 103 | } 104 | } 105 | 106 | private func updateColorInfo(with frame: CGRect, screen: NSScreen) { 107 | guard let windowImage else { 108 | colorInfo = nil 109 | return 110 | } 111 | 112 | let imageBounds = CGRect(x: 0, y: 0, width: windowImage.width, height: windowImage.height) 113 | let insetScreenFrame = screen.frame.insetBy(dx: frame.width / 2, dy: 0) 114 | let percentage = ((frame.midX - insetScreenFrame.minX) / insetScreenFrame.width).clamped(to: 0...1) 115 | let cropRect = CGRect(x: imageBounds.width * percentage, y: 0, width: 0, height: 1) 116 | .insetBy(dx: -50, dy: 0) 117 | .intersection(imageBounds) 118 | 119 | guard 120 | let croppedImage = windowImage.cropping(to: cropRect), 121 | let averageColor = croppedImage.averageColor() 122 | else { 123 | colorInfo = nil 124 | return 125 | } 126 | 127 | colorInfo = MenuBarAverageColorInfo(color: averageColor, source: .menuBarWindow) 128 | } 129 | 130 | func updateAllProperties(with frame: CGRect, screen: NSScreen) { 131 | updateWindowImage(for: screen) 132 | updateColorInfo(with: frame, screen: screen) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /Ice/UI/IceBar/IceBarLocation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IceBarLocation.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | /// Locations where the Ice Bar can appear. 9 | enum IceBarLocation: Int, CaseIterable, Identifiable { 10 | /// The Ice Bar will appear in different locations based on context. 11 | case dynamic = 0 12 | 13 | /// The Ice Bar will appear centered below the mouse pointer. 14 | case mousePointer = 1 15 | 16 | /// The Ice Bar will appear centered below the Ice icon. 17 | case iceIcon = 2 18 | 19 | var id: Int { rawValue } 20 | 21 | /// Localized string key representation. 22 | var localized: LocalizedStringKey { 23 | switch self { 24 | case .dynamic: "Dynamic" 25 | case .mousePointer: "Mouse pointer" 26 | case .iceIcon: "Ice icon" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Ice/UI/IceUI/IceForm.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IceForm.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct IceForm: View { 9 | @State private var contentFrame = CGRect.zero 10 | 11 | private let alignment: HorizontalAlignment 12 | private let padding: EdgeInsets 13 | private let spacing: CGFloat 14 | private let content: Content 15 | 16 | init( 17 | alignment: HorizontalAlignment = .center, 18 | padding: EdgeInsets, 19 | spacing: CGFloat = 10, 20 | @ViewBuilder content: () -> Content 21 | ) { 22 | self.alignment = alignment 23 | self.padding = padding 24 | self.spacing = spacing 25 | self.content = content() 26 | } 27 | 28 | init( 29 | alignment: HorizontalAlignment = .center, 30 | padding: CGFloat = 20, 31 | spacing: CGFloat = 10, 32 | @ViewBuilder content: () -> Content 33 | ) { 34 | self.init( 35 | alignment: alignment, 36 | padding: EdgeInsets(top: padding, leading: padding, bottom: padding, trailing: padding), 37 | spacing: spacing 38 | ) { 39 | content() 40 | } 41 | } 42 | 43 | var body: some View { 44 | GeometryReader { geometry in 45 | if contentFrame.height > geometry.size.height { 46 | ScrollView { 47 | contentStack 48 | } 49 | .scrollContentBackground(.hidden) 50 | } else { 51 | contentStack 52 | } 53 | } 54 | } 55 | 56 | @ViewBuilder 57 | private var contentStack: some View { 58 | VStack(alignment: alignment, spacing: spacing) { 59 | content 60 | .toggleStyle(IceFormToggleStyle()) 61 | } 62 | .padding(padding) 63 | .onFrameChange(update: $contentFrame) 64 | } 65 | } 66 | 67 | private struct IceFormToggleStyle: ToggleStyle { 68 | func makeBody(configuration: Configuration) -> some View { 69 | IceLabeledContent { 70 | Toggle(isOn: configuration.$isOn) { 71 | configuration.label 72 | } 73 | .labelsHidden() 74 | .toggleStyle(.switch) 75 | .controlSize(.mini) 76 | } label: { 77 | configuration.label 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Ice/UI/IceUI/IceGroupBox.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IceGroupBox.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct IceGroupBox: View { 9 | private let header: Header 10 | private let content: Content 11 | private let footer: Footer 12 | private let padding: CGFloat 13 | 14 | init( 15 | padding: CGFloat = 10, 16 | @ViewBuilder header: () -> Header, 17 | @ViewBuilder content: () -> Content, 18 | @ViewBuilder footer: () -> Footer 19 | ) { 20 | self.padding = padding 21 | self.header = header() 22 | self.content = content() 23 | self.footer = footer() 24 | } 25 | 26 | init( 27 | padding: CGFloat = 10, 28 | @ViewBuilder content: () -> Content, 29 | @ViewBuilder footer: () -> Footer 30 | ) where Header == EmptyView { 31 | self.init(padding: padding) { 32 | EmptyView() 33 | } content: { 34 | content() 35 | } footer: { 36 | footer() 37 | } 38 | } 39 | 40 | init( 41 | padding: CGFloat = 10, 42 | @ViewBuilder header: () -> Header, 43 | @ViewBuilder content: () -> Content 44 | ) where Footer == EmptyView { 45 | self.init(padding: padding) { 46 | header() 47 | } content: { 48 | content() 49 | } footer: { 50 | EmptyView() 51 | } 52 | } 53 | 54 | init( 55 | padding: CGFloat = 10, 56 | @ViewBuilder content: () -> Content 57 | ) where Header == EmptyView, Footer == EmptyView { 58 | self.init(padding: padding) { 59 | EmptyView() 60 | } content: { 61 | content() 62 | } footer: { 63 | EmptyView() 64 | } 65 | } 66 | 67 | init( 68 | _ title: LocalizedStringKey, 69 | padding: CGFloat = 10, 70 | @ViewBuilder content: () -> Content 71 | ) where Header == Text, Footer == EmptyView { 72 | self.init(padding: padding) { 73 | Text(title) 74 | .font(.headline) 75 | } content: { 76 | content() 77 | } 78 | } 79 | 80 | var body: some View { 81 | VStack(alignment: .leading) { 82 | header 83 | VStack { 84 | content 85 | } 86 | .padding(padding) 87 | .background { 88 | backgroundShape 89 | .fill(.quinary) 90 | .overlay { 91 | backgroundShape 92 | .stroke(.quaternary) 93 | } 94 | } 95 | footer 96 | } 97 | } 98 | 99 | @ViewBuilder 100 | private var backgroundShape: some Shape { 101 | RoundedRectangle(cornerRadius: 7, style: .circular) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Ice/UI/IceUI/IceLabeledContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IceLabeledContent.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct IceLabeledContent: View { 9 | private let label: Label 10 | private let content: Content 11 | 12 | init( 13 | @ViewBuilder content: () -> Content, 14 | @ViewBuilder label: () -> Label 15 | ) { 16 | self.label = label() 17 | self.content = content() 18 | } 19 | 20 | init( 21 | _ titleKey: LocalizedStringKey, 22 | @ViewBuilder content: () -> Content 23 | ) where Label == Text { 24 | self.init { 25 | content() 26 | } label: { 27 | Text(titleKey) 28 | } 29 | } 30 | 31 | var body: some View { 32 | LabeledContent { 33 | content 34 | .layoutPriority(1) 35 | } label: { 36 | label 37 | .frame(maxWidth: .infinity, alignment: .leading) 38 | .layoutPriority(0) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Ice/UI/IceUI/IceMenu.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IceMenu.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct IceMenu: View { 9 | @State private var isHovering = false 10 | 11 | private let title: Title 12 | private let label: Label 13 | private let content: Content 14 | 15 | /// Creates a menu with the given content, title, and label. 16 | /// 17 | /// - Parameters: 18 | /// - content: A group of menu items. 19 | /// - title: A view to display inside the menu. 20 | /// - label: A view to display as an external label for the menu. 21 | init( 22 | @ViewBuilder content: () -> Content, 23 | @ViewBuilder title: () -> Title, 24 | @ViewBuilder label: () -> Label 25 | ) { 26 | self.title = title() 27 | self.label = label() 28 | self.content = content() 29 | } 30 | 31 | /// Creates a menu with the given content, title, and label key. 32 | /// 33 | /// - Parameters: 34 | /// - labelKey: A string key for the menu's external label. 35 | /// - content: A group of menu items. 36 | /// - title: A view to display inside the menu. 37 | init( 38 | _ labelKey: LocalizedStringKey, 39 | @ViewBuilder content: () -> Content, 40 | @ViewBuilder title: () -> Title 41 | ) where Label == Text { 42 | self.init { 43 | content() 44 | } title: { 45 | title() 46 | } label: { 47 | Text(labelKey) 48 | } 49 | } 50 | 51 | var body: some View { 52 | IceLabeledContent { 53 | ZStack { 54 | IceMenuButtonView() 55 | .opacity(isHovering ? 1 : 0) 56 | .allowsHitTesting(false) 57 | 58 | _VariadicView.Tree(IceMenuLayout(title: title)) { 59 | content 60 | } 61 | .blendMode(.destinationOver) 62 | 63 | HStack(spacing: 5) { 64 | title 65 | .offset(y: -0.5) 66 | 67 | IcePopUpIndicator(isHovering: isHovering, isBordered: true, style: .pullDown) 68 | } 69 | .allowsHitTesting(false) 70 | .padding(.trailing, 2) 71 | .padding(.leading, 10) 72 | } 73 | .fixedSize() 74 | .onHover { hovering in 75 | isHovering = hovering 76 | } 77 | } label: { 78 | label 79 | } 80 | } 81 | } 82 | 83 | private struct IceMenuButtonView: NSViewRepresentable { 84 | func makeNSView(context: Context) -> NSButton { 85 | let button = NSButton() 86 | button.title = "" 87 | return button 88 | } 89 | 90 | func updateNSView(_ nsView: NSButton, context: Context) { } 91 | } 92 | 93 | private struct IceMenuLayout: _VariadicView_UnaryViewRoot { 94 | let title: Title 95 | 96 | func body(children: _VariadicView.Children) -> some View { 97 | Menu { 98 | ForEach(children) { child in 99 | IceMenuItem(child: child) 100 | } 101 | } label: { 102 | title 103 | } 104 | .menuStyle(.borderlessButton) 105 | .menuIndicator(.hidden) 106 | .labelStyle(.titleAndIcon) 107 | } 108 | } 109 | 110 | private final class IceMenuItemAction: Hashable { 111 | static let nullAction = IceMenuItemAction { 112 | Logger.iceMenu.warning("No action assigned to menu item") 113 | } 114 | 115 | let body: () -> Void 116 | 117 | init(body: @escaping () -> Void) { 118 | self.body = body 119 | } 120 | 121 | func hash(into hasher: inout Hasher) { 122 | hasher.combine(ObjectIdentifier(self)) 123 | } 124 | 125 | static func == (lhs: IceMenuItemAction, rhs: IceMenuItemAction) -> Bool { 126 | ObjectIdentifier(lhs) == ObjectIdentifier(rhs) 127 | } 128 | } 129 | 130 | private struct IceMenuItemActionKey: PreferenceKey { 131 | static let defaultValue = IceMenuItemAction.nullAction 132 | 133 | static func reduce(value: inout IceMenuItemAction, nextValue: () -> IceMenuItemAction) { 134 | value = nextValue() 135 | } 136 | } 137 | 138 | private struct IceMenuItem: View { 139 | @State private var action = IceMenuItemAction.nullAction 140 | 141 | let child: _VariadicView.Children.Element 142 | 143 | var body: some View { 144 | Button { 145 | action.body() 146 | } label: { 147 | child 148 | } 149 | .onPreferenceChange(IceMenuItemActionKey.self) { action in 150 | self.action = action 151 | } 152 | } 153 | } 154 | 155 | extension View { 156 | /// Adds an action to perform when this view is clicked inside an ``IceMenu``. 157 | /// 158 | /// - Parameter action: An action to perform. 159 | func iceMenuItemAction(_ action: @escaping () -> Void) -> some View { 160 | preference(key: IceMenuItemActionKey.self, value: IceMenuItemAction(body: action)) 161 | } 162 | } 163 | 164 | // MARK: - Logger 165 | private extension Logger { 166 | static let iceMenu = Logger(category: "IceMenu") 167 | } 168 | -------------------------------------------------------------------------------- /Ice/UI/IceUI/IcePicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IcePicker.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct IcePicker: View { 9 | @Binding var selection: SelectionValue 10 | @State private var isHovering = false 11 | 12 | let label: Label 13 | let content: Content 14 | 15 | init( 16 | selection: Binding, 17 | @ViewBuilder content: () -> Content, 18 | @ViewBuilder label: () -> Label 19 | ) { 20 | self._selection = selection 21 | self.label = label() 22 | self.content = content() 23 | } 24 | 25 | init( 26 | _ titleKey: LocalizedStringKey, 27 | selection: Binding, 28 | @ViewBuilder content: () -> Content 29 | ) where Label == Text { 30 | self.init(selection: selection) { 31 | content() 32 | } label: { 33 | Text(titleKey) 34 | } 35 | } 36 | 37 | var body: some View { 38 | IceLabeledContent { 39 | ZStack { 40 | IcePickerButtonView() 41 | .opacity(isHovering ? 1 : 0) 42 | .allowsHitTesting(false) 43 | 44 | Picker(selection: $selection) { 45 | content 46 | .labelStyle(.titleAndIcon) 47 | } label: { 48 | label 49 | } 50 | .labelsHidden() 51 | .pickerStyle(.menu) 52 | .buttonStyle(.plain) 53 | .menuIndicator(.hidden) 54 | .blendMode(.destinationOver) 55 | 56 | HStack(spacing: 5) { 57 | _VariadicView.Tree(IcePickerLayout(selection: $selection)) { 58 | content 59 | .labelStyle(.titleAndIcon) 60 | } 61 | .offset(y: -0.5) 62 | 63 | IcePopUpIndicator(isHovering: isHovering, isBordered: true, style: .popUp) 64 | } 65 | .allowsHitTesting(false) 66 | .padding(.trailing, 2) 67 | .padding(.leading, 10) 68 | } 69 | .fixedSize() 70 | .onHover { hovering in 71 | isHovering = hovering 72 | } 73 | } label: { 74 | label 75 | } 76 | } 77 | } 78 | 79 | private struct IcePickerButtonView: NSViewRepresentable { 80 | func makeNSView(context: Context) -> NSButton { 81 | let button = NSButton() 82 | button.title = "" 83 | return button 84 | } 85 | 86 | func updateNSView(_ nsView: NSButton, context: Context) { } 87 | } 88 | 89 | private struct IcePickerLayout: _VariadicView_UnaryViewRoot { 90 | @Binding var selection: SelectionValue 91 | 92 | @ViewBuilder 93 | func body(children: _VariadicView.Children) -> some View { 94 | if let child = children.first(where: { $0.id() == selection }) { 95 | child 96 | } 97 | } 98 | } 99 | 100 | extension View { 101 | /// Binds the identity of an item in an ``IcePicker`` to the given value. 102 | /// 103 | /// - Parameter id: A `Hashable` value to use as the view's identity. 104 | func icePickerID(_ id: ID) -> some View { 105 | tag(id).id(id) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Ice/UI/IceUI/IcePopUpIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IcePopUpIndicator.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct IcePopUpIndicator: View { 9 | private enum ChevronKind: String { 10 | case up, down 11 | } 12 | 13 | enum Style { 14 | case popUp, pullDown 15 | } 16 | 17 | let isHovering: Bool 18 | let isBordered: Bool 19 | let style: Style 20 | 21 | var body: some View { 22 | ZStack { 23 | if isBordered { 24 | RoundedRectangle(cornerRadius: 4, style: .circular) 25 | .fill(.quaternary) 26 | .opacity(isHovering ? 0 : 1) 27 | } 28 | 29 | switch style { 30 | case .popUp: 31 | VStack(spacing: 2) { 32 | chevron(.up) 33 | chevron(.down) 34 | } 35 | case .pullDown: 36 | chevron(.down) 37 | .offset(y: 0.5) 38 | } 39 | } 40 | .frame(width: 16, height: 16) 41 | } 42 | 43 | @ViewBuilder 44 | private func chevron(_ kind: ChevronKind) -> some View { 45 | Image(systemName: "chevron.\(kind.rawValue)") 46 | .resizable() 47 | .frame(width: 7.5, height: 5) 48 | .fontWeight(.black) 49 | .foregroundStyle(Color.primary) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Ice/UI/IceUI/IceSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IceSection.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct IceSection: View { 9 | private let header: Header 10 | private let content: Content 11 | private let footer: Footer 12 | private let spacing: CGFloat = 10 13 | private var isBordered = true 14 | private var hasDividers = true 15 | 16 | init( 17 | @ViewBuilder header: () -> Header, 18 | @ViewBuilder content: () -> Content, 19 | @ViewBuilder footer: () -> Footer 20 | ) { 21 | self.header = header() 22 | self.content = content() 23 | self.footer = footer() 24 | } 25 | 26 | init( 27 | @ViewBuilder content: () -> Content, 28 | @ViewBuilder footer: () -> Footer 29 | ) where Header == EmptyView { 30 | self.init { 31 | EmptyView() 32 | } content: { 33 | content() 34 | } footer: { 35 | footer() 36 | } 37 | } 38 | 39 | init( 40 | @ViewBuilder header: () -> Header, 41 | @ViewBuilder content: () -> Content 42 | ) where Footer == EmptyView { 43 | self.init { 44 | header() 45 | } content: { 46 | content() 47 | } footer: { 48 | EmptyView() 49 | } 50 | } 51 | 52 | init( 53 | @ViewBuilder content: () -> Content 54 | ) where Header == EmptyView, Footer == EmptyView { 55 | self.init { 56 | EmptyView() 57 | } content: { 58 | content() 59 | } footer: { 60 | EmptyView() 61 | } 62 | } 63 | 64 | init( 65 | _ title: LocalizedStringKey, 66 | @ViewBuilder content: () -> Content 67 | ) where Header == Text, Footer == EmptyView { 68 | self.init { 69 | Text(title) 70 | .font(.headline) 71 | } content: { 72 | content() 73 | } 74 | } 75 | 76 | var body: some View { 77 | if isBordered { 78 | IceGroupBox(padding: spacing) { 79 | header 80 | } content: { 81 | dividedContent 82 | } footer: { 83 | footer 84 | } 85 | } else { 86 | VStack(alignment: .leading) { 87 | header 88 | dividedContent 89 | footer 90 | } 91 | } 92 | } 93 | 94 | @ViewBuilder 95 | private var dividedContent: some View { 96 | if hasDividers { 97 | _VariadicView.Tree(IceSectionLayout(spacing: spacing)) { 98 | content 99 | .frame(maxWidth: .infinity) 100 | } 101 | } else { 102 | content 103 | .frame(maxWidth: .infinity) 104 | } 105 | } 106 | } 107 | 108 | extension IceSection { 109 | func bordered(_ isBordered: Bool = true) -> IceSection { 110 | with(self) { copy in 111 | copy.isBordered = isBordered 112 | } 113 | } 114 | 115 | func dividers(_ hasDividers: Bool = true) -> IceSection { 116 | with(self) { copy in 117 | copy.hasDividers = hasDividers 118 | } 119 | } 120 | } 121 | 122 | private struct IceSectionLayout: _VariadicView_UnaryViewRoot { 123 | let spacing: CGFloat 124 | 125 | @ViewBuilder 126 | func body(children: _VariadicView.Children) -> some View { 127 | let last = children.last?.id 128 | VStack(alignment: .leading, spacing: spacing) { 129 | ForEach(children) { child in 130 | child 131 | if child.id != last { 132 | Divider() 133 | } 134 | } 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /Ice/UI/IceUI/IceSlider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IceSlider.swift 3 | // Ice 4 | // 5 | 6 | import CompactSlider 7 | import SwiftUI 8 | 9 | struct IceSlider: View { 10 | private let value: Binding 11 | private let bounds: ClosedRange 12 | private let step: Value 13 | private let valueLabel: ValueLabel 14 | private let valueLabelSelectability: ValueLabelSelectability 15 | 16 | init( 17 | value: Binding, 18 | in bounds: ClosedRange = 0...1, 19 | step: Value = 0, 20 | valueLabelSelectability: ValueLabelSelectability = .disabled, 21 | @ViewBuilder valueLabel: () -> ValueLabel 22 | ) { 23 | self.value = value 24 | self.bounds = bounds 25 | self.step = step 26 | self.valueLabel = valueLabel() 27 | self.valueLabelSelectability = valueLabelSelectability 28 | } 29 | 30 | init( 31 | _ valueLabelKey: LocalizedStringKey, 32 | valueLabelSelectability: ValueLabelSelectability = .disabled, 33 | value: Binding, 34 | in bounds: ClosedRange = 0...1, 35 | step: Value = 0 36 | ) where ValueLabel == Text { 37 | self.init( 38 | value: value, 39 | in: bounds, 40 | step: step, 41 | valueLabelSelectability: valueLabelSelectability 42 | ) { 43 | Text(valueLabelKey) 44 | } 45 | } 46 | 47 | var body: some View { 48 | CompactSlider( 49 | value: value, 50 | in: bounds, 51 | step: step, 52 | handleVisibility: .hovering(width: 1) 53 | ) { 54 | valueLabel 55 | .textSelection(valueLabelSelectability) 56 | } 57 | .compactSliderDisabledHapticFeedback(true) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Ice/UI/LayoutBar/LayoutBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LayoutBar.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | struct LayoutBar: View { 9 | private struct Representable: NSViewRepresentable { 10 | let appState: AppState 11 | let section: MenuBarSection 12 | let spacing: CGFloat 13 | 14 | func makeNSView(context: Context) -> LayoutBarScrollView { 15 | LayoutBarScrollView(appState: appState, section: section, spacing: spacing) 16 | } 17 | 18 | func updateNSView(_ nsView: LayoutBarScrollView, context: Context) { 19 | nsView.spacing = spacing 20 | } 21 | } 22 | 23 | @EnvironmentObject var appState: AppState 24 | @EnvironmentObject var imageCache: MenuBarItemImageCache 25 | 26 | let section: MenuBarSection 27 | let spacing: CGFloat 28 | 29 | private var menuBarManager: MenuBarManager { 30 | appState.menuBarManager 31 | } 32 | 33 | init(section: MenuBarSection, spacing: CGFloat = 0) { 34 | self.section = section 35 | self.spacing = spacing 36 | } 37 | 38 | var body: some View { 39 | conditionalBody 40 | .frame(height: 50) 41 | .frame(maxWidth: .infinity) 42 | .layoutBarStyle(appState: appState, averageColorInfo: menuBarManager.averageColorInfo) 43 | .clipShape(roundedRectangle) 44 | .overlay { 45 | roundedRectangle 46 | .stroke(.quaternary) 47 | } 48 | } 49 | 50 | @ViewBuilder 51 | private var conditionalBody: some View { 52 | if imageCache.cacheFailed(for: section.name) { 53 | Text("Unable to display menu bar items") 54 | .foregroundStyle(menuBarManager.averageColorInfo?.color.brightness ?? 0 > 0.67 ? .black : .white) 55 | } else { 56 | Representable(appState: appState, section: section, spacing: spacing) 57 | } 58 | } 59 | 60 | @ViewBuilder 61 | private var roundedRectangle: some Shape { 62 | RoundedRectangle(cornerRadius: 11, style: .continuous) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Ice/UI/LayoutBar/LayoutBarScrollView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LayoutBarScrollView.swift 3 | // Ice 4 | // 5 | 6 | import Cocoa 7 | 8 | final class LayoutBarScrollView: NSScrollView { 9 | private let paddingView: LayoutBarPaddingView 10 | 11 | /// The amount of space between each arranged view. 12 | var spacing: CGFloat { 13 | get { paddingView.spacing } 14 | set { paddingView.spacing = newValue } 15 | } 16 | 17 | /// The layout view's arranged views. 18 | /// 19 | /// The views are laid out from left to right in the order that they appear in 20 | /// the array. The ``spacing`` property determines the amount of space between 21 | /// each view. 22 | var arrangedViews: [LayoutBarItemView] { 23 | get { paddingView.arrangedViews } 24 | set { paddingView.arrangedViews = newValue } 25 | } 26 | 27 | /// Creates a layout bar scroll view with the given app state, section, and spacing. 28 | /// 29 | /// - Parameters: 30 | /// - appState: The shared app state instance. 31 | /// - section: The section whose items are represented. 32 | /// - spacing: The amount of space between each arranged view. 33 | init(appState: AppState, section: MenuBarSection, spacing: CGFloat) { 34 | self.paddingView = LayoutBarPaddingView(appState: appState, section: section, spacing: spacing) 35 | 36 | super.init(frame: .zero) 37 | 38 | self.hasHorizontalScroller = true 39 | self.horizontalScroller = HorizontalScroller() 40 | 41 | self.autohidesScrollers = true 42 | 43 | self.verticalScrollElasticity = .none 44 | self.horizontalScrollElasticity = .none 45 | 46 | self.drawsBackground = false 47 | 48 | self.documentView = self.paddingView 49 | 50 | self.translatesAutoresizingMaskIntoConstraints = false 51 | NSLayoutConstraint.activate([ 52 | // constrain the padding view's height to the content view's height 53 | paddingView.heightAnchor.constraint(equalTo: contentView.heightAnchor), 54 | 55 | // constrain the padding view's width to greater than or equal to the content 56 | // view's width 57 | paddingView.widthAnchor.constraint(greaterThanOrEqualTo: contentView.widthAnchor), 58 | 59 | // constrain the padding view's trailing anchor to the content view's trailing 60 | // anchor; this, in combination with the above width constraint, aligns the 61 | // items in the layout bar to the trailing edge 62 | paddingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), 63 | ]) 64 | } 65 | 66 | @available(*, unavailable) 67 | required init?(coder: NSCoder) { 68 | fatalError("init(coder:) has not been implemented") 69 | } 70 | } 71 | 72 | extension LayoutBarScrollView { 73 | override func accessibilityChildren() -> [Any]? { 74 | return arrangedViews 75 | } 76 | } 77 | 78 | extension LayoutBarScrollView { 79 | /// A custom scroller that overrides its knob slot to be transparent. 80 | final class HorizontalScroller: NSScroller { 81 | override static var isCompatibleWithOverlayScrollers: Bool { true } 82 | 83 | override init(frame frameRect: NSRect) { 84 | super.init(frame: frameRect) 85 | self.controlSize = .mini 86 | } 87 | 88 | @available(*, unavailable) 89 | required init?(coder: NSCoder) { 90 | fatalError("init(coder:) has not been implemented") 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Ice/UI/Pickers/CustomColorPicker/CustomColorPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomColorPicker.swift 3 | // Ice 4 | // 5 | 6 | import Combine 7 | import SwiftUI 8 | 9 | struct CustomColorPicker: NSViewRepresentable { 10 | final class Coordinator { 11 | @Binding var selection: CGColor 12 | 13 | let supportsOpacity: Bool 14 | let mode: NSColorPanel.Mode 15 | 16 | private var cancellables = Set() 17 | 18 | init( 19 | selection: Binding, 20 | supportsOpacity: Bool, 21 | mode: NSColorPanel.Mode 22 | ) { 23 | self._selection = selection 24 | self.supportsOpacity = supportsOpacity 25 | self.mode = mode 26 | } 27 | 28 | func configure(with nsView: NSColorWell) { 29 | var c = Set() 30 | 31 | nsView 32 | .publisher(for: \.color) 33 | .removeDuplicates() 34 | .sink { [weak self] color in 35 | DispatchQueue.main.async { 36 | if self?.selection != color.cgColor { 37 | self?.selection = color.cgColor 38 | } 39 | } 40 | } 41 | .store(in: &c) 42 | 43 | NSColorPanel.shared 44 | .publisher(for: \.isVisible) 45 | .sink { [weak self, weak nsView] isVisible in 46 | guard 47 | let self, 48 | let nsView, 49 | isVisible, 50 | nsView.isActive 51 | else { 52 | return 53 | } 54 | NSColorPanel.shared.showsAlpha = supportsOpacity 55 | NSColorPanel.shared.mode = mode 56 | if let window = nsView.window { 57 | NSColorPanel.shared.level = window.level + 1 58 | } 59 | if NSColorPanel.shared.frame.origin == .zero { 60 | NSColorPanel.shared.center() 61 | } 62 | } 63 | .store(in: &c) 64 | 65 | NSColorPanel.shared 66 | .publisher(for: \.level) 67 | .sink { [weak nsView] level in 68 | guard 69 | let nsView, 70 | nsView.isActive, 71 | let window = nsView.window, 72 | level != window.level + 1 73 | else { 74 | return 75 | } 76 | NSColorPanel.shared.level = window.level + 1 77 | } 78 | .store(in: &c) 79 | 80 | cancellables = c 81 | } 82 | } 83 | 84 | @Binding var selection: CGColor 85 | 86 | let supportsOpacity: Bool 87 | let mode: NSColorPanel.Mode 88 | 89 | func makeNSView(context: Context) -> NSColorWell { 90 | let nsView = NSColorWell() 91 | context.coordinator.configure(with: nsView) 92 | return nsView 93 | } 94 | 95 | func updateNSView(_ nsView: NSColorWell, context: Context) { 96 | if let color = NSColor(cgColor: selection) { 97 | nsView.color = color 98 | } 99 | nsView.supportsAlpha = supportsOpacity 100 | } 101 | 102 | func makeCoordinator() -> Coordinator { 103 | Coordinator( 104 | selection: $selection, 105 | supportsOpacity: supportsOpacity, 106 | mode: mode 107 | ) 108 | } 109 | 110 | func sizeThatFits( 111 | _ proposal: ProposedViewSize, 112 | nsView: NSColorWell, 113 | context: Context 114 | ) -> CGSize? { 115 | switch nsView.controlSize { 116 | case .large: 117 | CGSize(width: 55, height: 30) 118 | case .regular: 119 | CGSize(width: 44, height: 24) 120 | case .small: 121 | CGSize(width: 33, height: 18) 122 | case .mini: 123 | CGSize(width: 29, height: 16) 124 | @unknown default: 125 | nsView.intrinsicContentSize 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /Ice/UI/Pickers/CustomGradientPicker/ColorStop.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorStop.swift 3 | // Ice 4 | // 5 | 6 | import CoreGraphics 7 | 8 | /// A color stop in a gradient. 9 | struct ColorStop: Hashable { 10 | /// The color of the stop. 11 | var color: CGColor 12 | /// The location of the stop relative to its gradient. 13 | var location: CGFloat 14 | 15 | /// Returns a copy of the color stop with the given alpha value. 16 | func withAlphaComponent(_ alpha: CGFloat) -> ColorStop? { 17 | guard let newColor = color.copy(alpha: alpha) else { 18 | return nil 19 | } 20 | return ColorStop(color: newColor, location: location) 21 | } 22 | } 23 | 24 | extension ColorStop: Codable { 25 | private enum CodingKeys: CodingKey { 26 | case color 27 | case location 28 | } 29 | 30 | init(from decoder: Decoder) throws { 31 | let container = try decoder.container(keyedBy: CodingKeys.self) 32 | self.color = try container.decode(CodableColor.self, forKey: .color).cgColor 33 | self.location = try container.decode(CGFloat.self, forKey: .location) 34 | } 35 | 36 | func encode(to encoder: Encoder) throws { 37 | var container = encoder.container(keyedBy: CodingKeys.self) 38 | try container.encode(CodableColor(cgColor: color), forKey: .color) 39 | try container.encode(location, forKey: .location) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Ice/UI/Pickers/CustomGradientPicker/CustomGradient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomGradient.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | /// A custom gradient for use with a ``GradientPicker``. 9 | struct CustomGradient: View { 10 | /// The color stops in the gradient. 11 | var stops: [ColorStop] 12 | 13 | /// The color stops in the gradient, sorted by location. 14 | var sortedStops: [ColorStop] { 15 | stops.sorted { lhs, rhs in 16 | lhs.location < rhs.location 17 | } 18 | } 19 | 20 | /// A Cocoa representation of this gradient. 21 | var nsGradient: NSGradient? { 22 | let sortedStops = sortedStops 23 | let colors = sortedStops.compactMap { stop in 24 | NSColor(cgColor: stop.color) 25 | } 26 | var locations = sortedStops.map { stop in 27 | stop.location 28 | } 29 | guard colors.count == locations.count else { 30 | return nil 31 | } 32 | return NSGradient( 33 | colors: colors, 34 | atLocations: &locations, 35 | colorSpace: .sRGB 36 | ) 37 | } 38 | 39 | var body: some View { 40 | GeometryReader { geometry in 41 | if stops.isEmpty { 42 | Color.clear 43 | } else { 44 | Image( 45 | nsImage: NSImage( 46 | size: geometry.size, 47 | flipped: false 48 | ) { bounds in 49 | guard let nsGradient else { 50 | return false 51 | } 52 | nsGradient.draw(in: bounds, angle: 0) 53 | return true 54 | } 55 | ) 56 | } 57 | } 58 | } 59 | 60 | /// Creates a gradient with the given unsorted stops. 61 | /// 62 | /// - Parameter stops: An array of color stops to sort and 63 | /// assign as the gradient's color stops. 64 | init(unsortedStops stops: [ColorStop]) { 65 | self.stops = stops.sorted { $0.location < $1.location } 66 | } 67 | 68 | init() { 69 | self.init(unsortedStops: []) 70 | } 71 | 72 | /// Returns the color at the given location in the gradient. 73 | /// 74 | /// - Parameter location: A value between 0 and 1 representing 75 | /// the location of the color that should be returned. 76 | func color(at location: CGFloat) -> CGColor? { 77 | guard 78 | let nsColor = nsGradient?.interpolatedColor(atLocation: location), 79 | let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) 80 | else { 81 | return nil 82 | } 83 | return nsColor.cgColor.converted( 84 | to: colorSpace, 85 | intent: .defaultIntent, 86 | options: nil 87 | ) 88 | } 89 | 90 | /// Returns a copy of the gradient with the given alpha value. 91 | func withAlphaComponent(_ alpha: CGFloat) -> CustomGradient { 92 | var copy = self 93 | copy.stops = copy.stops.map { stop in 94 | stop.withAlphaComponent(alpha) ?? stop 95 | } 96 | return copy 97 | } 98 | } 99 | 100 | extension CustomGradient { 101 | /// The default menu bar tint gradient. 102 | static let defaultMenuBarTint = CustomGradient( 103 | unsortedStops: [ 104 | ColorStop( 105 | color: CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1), 106 | location: 0 107 | ), 108 | ColorStop( 109 | color: CGColor(srgbRed: 0, green: 0, blue: 0, alpha: 1), 110 | location: 1 111 | ), 112 | ] 113 | ) 114 | } 115 | 116 | extension CustomGradient: Codable { } 117 | 118 | extension CustomGradient: Hashable { } 119 | -------------------------------------------------------------------------------- /Ice/UI/Shapes/AnyInsettableShape.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyInsettableShape.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | /// A type-erased insettable shape. 9 | struct AnyInsettableShape: InsettableShape { 10 | typealias InsetShape = AnyInsettableShape 11 | 12 | private let base: any InsettableShape 13 | 14 | /// Creates a type-erased insettable shape. 15 | init(_ shape: any InsettableShape) { 16 | self.base = shape 17 | } 18 | 19 | func path(in rect: CGRect) -> Path { 20 | base.path(in: rect) 21 | } 22 | 23 | func inset(by amount: CGFloat) -> AnyInsettableShape { 24 | AnyInsettableShape(base.inset(by: amount)) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Ice/UI/ViewModifiers/BottomBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomBar.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | extension View { 9 | /// Adds the given view as a bottom bar to the current view. 10 | /// 11 | /// - Parameter content: A view to be added as a bottom bar to the current view. 12 | func bottomBar(@ViewBuilder content: () -> Content) -> some View { 13 | safeAreaInset(edge: .bottom) { 14 | content() 15 | .background { 16 | Rectangle() 17 | .fill(.quinary.shadow(.inner(radius: 2))) 18 | .shadow(radius: 2) 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Ice/UI/ViewModifiers/ErasedToAnyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErasedToAnyView.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | extension View { 9 | /// Returns a view that has been erased to the `AnyView` type. 10 | func erasedToAnyView() -> AnyView { 11 | AnyView(erasing: self) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Ice/UI/ViewModifiers/LayoutBarStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LayoutBarStyle.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | extension View { 9 | /// Returns a view that is drawn in the style of a layout bar. 10 | /// 11 | /// - Note: The view this modifier is applied to must be transparent, or the style 12 | /// will be drawn incorrectly. 13 | @ViewBuilder 14 | func layoutBarStyle(appState: AppState, averageColorInfo: MenuBarAverageColorInfo?) -> some View { 15 | background { 16 | if appState.isActiveSpaceFullscreen { 17 | Color.black 18 | } else if let averageColorInfo { 19 | switch averageColorInfo.source { 20 | case .menuBarWindow: 21 | Color(cgColor: averageColorInfo.color) 22 | .overlay( 23 | Material.bar 24 | .opacity(0.2) 25 | .blendMode(.softLight) 26 | ) 27 | case .desktopWallpaper: 28 | Color(cgColor: averageColorInfo.color) 29 | .overlay( 30 | Material.bar 31 | .opacity(0.5) 32 | .blendMode(.softLight) 33 | ) 34 | } 35 | } else { 36 | Color.defaultLayoutBar 37 | } 38 | } 39 | .overlay { 40 | if !appState.isActiveSpaceFullscreen { 41 | switch appState.appearanceManager.configuration.current.tintKind { 42 | case .none: 43 | EmptyView() 44 | case .solid: 45 | Color(cgColor: appState.appearanceManager.configuration.current.tintColor) 46 | .opacity(0.2) 47 | .allowsHitTesting(false) 48 | case .gradient: 49 | appState.appearanceManager.configuration.current.tintGradient 50 | .opacity(0.2) 51 | .allowsHitTesting(false) 52 | } 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Ice/UI/ViewModifiers/LocalEventMonitorModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalEventMonitorModifier.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | private final class LocalEventMonitorModifierState: ObservableObject { 9 | let monitor: LocalEventMonitor 10 | 11 | init(mask: NSEvent.EventTypeMask, action: @escaping (NSEvent) -> NSEvent?) { 12 | self.monitor = LocalEventMonitor(mask: mask, handler: action) 13 | self.monitor.start() 14 | } 15 | 16 | deinit { 17 | monitor.stop() 18 | } 19 | } 20 | 21 | private struct LocalEventMonitorModifier: ViewModifier { 22 | @StateObject private var state: LocalEventMonitorModifierState 23 | 24 | init(mask: NSEvent.EventTypeMask, action: @escaping (NSEvent) -> NSEvent?) { 25 | let state = LocalEventMonitorModifierState(mask: mask, action: action) 26 | self._state = StateObject(wrappedValue: state) 27 | } 28 | 29 | func body(content: Content) -> some View { 30 | content 31 | } 32 | } 33 | 34 | extension View { 35 | /// Returns a view that performs the given action when events 36 | /// specified by the given mask are received. 37 | /// 38 | /// - Parameters: 39 | /// - mask: An event type mask specifying which events to monitor. 40 | /// - action: An action to perform when the event monitor receives 41 | /// an event corresponding to the event types in `mask`. 42 | func localEventMonitor( 43 | mask: NSEvent.EventTypeMask, 44 | action: @escaping (NSEvent) -> NSEvent? 45 | ) -> some View { 46 | modifier(LocalEventMonitorModifier(mask: mask, action: action)) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Ice/UI/ViewModifiers/OnFrameChange.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnFrameChange.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | private struct FramePreferenceKey: PreferenceKey { 9 | static let defaultValue = CGRect.zero 10 | 11 | static func reduce(value: inout CGRect, nextValue: () -> CGRect) { 12 | value = nextValue() 13 | } 14 | } 15 | 16 | extension View { 17 | /// Adds an action to perform when the view's frame changes. 18 | /// 19 | /// - Parameters: 20 | /// - coordinateSpace: The coordinate space to use as a reference 21 | /// when accessing the view's frame. 22 | /// - action: The action to perform when the view's frame changes. 23 | /// The `action` closure passes the new frame as its parameter. 24 | /// 25 | /// - Returns: A view that triggers `action` when its frame changes. 26 | func onFrameChange( 27 | in coordinateSpace: CoordinateSpace = .local, 28 | perform action: @escaping (CGRect) -> Void 29 | ) -> some View { 30 | background { 31 | GeometryReader { proxy in 32 | Color.clear 33 | .preference( 34 | key: FramePreferenceKey.self, 35 | value: proxy.frame(in: coordinateSpace) 36 | ) 37 | .onPreferenceChange(FramePreferenceKey.self, perform: action) 38 | } 39 | } 40 | } 41 | 42 | /// Returns a version of this view that updates the given binding 43 | /// when its frame changes. 44 | /// 45 | /// - Parameters: 46 | /// - coordinateSpace: The coordinate space to use as a reference 47 | /// when accessing the view's frame. 48 | /// - binding: A binding to update when the view's frame changes. 49 | /// 50 | /// - Returns: A view that updates `binding` when its frame changes. 51 | func onFrameChange( 52 | in coordinateSpace: CoordinateSpace = .local, 53 | update binding: Binding 54 | ) -> some View { 55 | onFrameChange(in: coordinateSpace) { frame in 56 | binding.wrappedValue = frame 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Ice/UI/ViewModifiers/OnKeyDown.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnKeyDown.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | extension View { 9 | /// Returns a view that performs the given action when 10 | /// the specified key is pressed. 11 | func onKeyDown(key: KeyCode, action: @escaping () -> Void) -> some View { 12 | localEventMonitor(mask: .keyDown) { event in 13 | if event.keyCode == key.rawValue { 14 | action() 15 | return nil 16 | } 17 | return event 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Ice/UI/ViewModifiers/Once.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Once.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | private struct OnceModifier: ViewModifier { 9 | @State private var hasAppeared = false 10 | 11 | let onAppear: () -> Void 12 | 13 | func body(content: Content) -> some View { 14 | content.onAppear { 15 | if !hasAppeared { 16 | onAppear() 17 | hasAppeared = true 18 | } 19 | } 20 | } 21 | } 22 | 23 | extension View { 24 | /// Adds an action to perform exactly once, before the first 25 | /// time the view appears. 26 | /// 27 | /// - Parameter action: The action to perform. 28 | func once(perform action: @escaping () -> Void) -> some View { 29 | modifier(OnceModifier(onAppear: action)) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Ice/UI/ViewModifiers/ReadWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReadWindow.swift 3 | // Ice 4 | // 5 | 6 | import Combine 7 | import SwiftUI 8 | 9 | private struct WindowReader: NSViewRepresentable { 10 | final class Coordinator: ObservableObject { 11 | private var cancellable: AnyCancellable? 12 | 13 | func configure(for view: NSView, onWindowChange: @MainActor @escaping (NSWindow?) -> Void) { 14 | cancellable = view.publisher(for: \.window).sink { window in 15 | Task { @MainActor in 16 | onWindowChange(window) 17 | } 18 | } 19 | } 20 | } 21 | 22 | let onWindowChange: @MainActor (NSWindow?) -> Void 23 | 24 | func makeNSView(context: Context) -> NSView { 25 | let view = NSView() 26 | context.coordinator.configure(for: view) { window in 27 | onWindowChange(window) 28 | } 29 | return view 30 | } 31 | 32 | func makeCoordinator() -> Coordinator { 33 | return Coordinator() 34 | } 35 | 36 | func updateNSView(_: NSView, context: Context) { } 37 | } 38 | 39 | extension View { 40 | /// Reads the window of this view, performing the given closure when 41 | /// the window changes. 42 | /// 43 | /// - Parameter onChange: A closure to perform when the window changes. 44 | func readWindow(onChange: @MainActor @escaping (_ window: NSWindow?) -> Void) -> some View { 45 | background { 46 | WindowReader(onWindowChange: onChange) 47 | } 48 | } 49 | 50 | /// Reads the window of this view, assigning it to the given binding. 51 | /// 52 | /// - Parameter window: A binding to use to store the view's window. 53 | func readWindow(window: Binding) -> some View { 54 | readWindow { window.wrappedValue = $0 } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Ice/UI/ViewModifiers/RemoveSidebarToggle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoveSidebarToggle.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | extension View { 9 | /// Removes the sidebar toggle button from the toolbar. 10 | func removeSidebarToggle() -> some View { 11 | toolbar(removing: .sidebarToggle) 12 | .toolbar { 13 | Color.clear 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Ice/UI/Views/BetaBadge.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BetaBadge.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | /// A view that displays a badge indicating a beta feature. 9 | struct BetaBadge: View { 10 | var body: some View { 11 | Text("BETA") 12 | .font(.caption.bold()) 13 | .padding(.horizontal, 6) 14 | .background { 15 | Capsule(style: .circular) 16 | .stroke() 17 | } 18 | .foregroundStyle(.green) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Ice/UI/Views/VisualEffectView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VisualEffectView.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | /// A SwiftUI view that wraps an `NSVisualEffectView`. 9 | struct VisualEffectView: NSViewRepresentable { 10 | let material: NSVisualEffectView.Material 11 | let blendingMode: NSVisualEffectView.BlendingMode 12 | 13 | func makeNSView(context: Context) -> NSVisualEffectView { 14 | let visualEffectView = NSVisualEffectView() 15 | visualEffectView.material = material 16 | visualEffectView.blendingMode = blendingMode 17 | visualEffectView.state = .active 18 | visualEffectView.isEmphasized = true 19 | return visualEffectView 20 | } 21 | 22 | func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) { 23 | visualEffectView.material = material 24 | visualEffectView.blendingMode = blendingMode 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Ice/Updates/UpdatesManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdatesManager.swift 3 | // Ice 4 | // 5 | 6 | import Sparkle 7 | import SwiftUI 8 | 9 | /// Manager for app updates. 10 | @MainActor 11 | final class UpdatesManager: NSObject, ObservableObject { 12 | /// A Boolean value that indicates whether the user can check for updates. 13 | @Published var canCheckForUpdates = false 14 | 15 | /// The date of the last update check. 16 | @Published var lastUpdateCheckDate: Date? 17 | 18 | /// The shared app state. 19 | private(set) weak var appState: AppState? 20 | 21 | /// The underlying updater controller. 22 | private(set) lazy var updaterController = SPUStandardUpdaterController( 23 | startingUpdater: true, 24 | updaterDelegate: self, 25 | userDriverDelegate: self 26 | ) 27 | 28 | /// The underlying updater. 29 | var updater: SPUUpdater { 30 | updaterController.updater 31 | } 32 | 33 | /// A Boolean value that indicates whether to automatically check for updates. 34 | var automaticallyChecksForUpdates: Bool { 35 | get { 36 | updater.automaticallyChecksForUpdates 37 | } 38 | set { 39 | objectWillChange.send() 40 | updater.automaticallyChecksForUpdates = newValue 41 | } 42 | } 43 | 44 | /// A Boolean value that indicates whether to automatically download updates. 45 | var automaticallyDownloadsUpdates: Bool { 46 | get { 47 | updater.automaticallyDownloadsUpdates 48 | } 49 | set { 50 | objectWillChange.send() 51 | updater.automaticallyDownloadsUpdates = newValue 52 | } 53 | } 54 | 55 | /// Creates an updates manager with the given app state. 56 | init(appState: AppState) { 57 | self.appState = appState 58 | super.init() 59 | } 60 | 61 | /// Sets up the manager. 62 | func performSetup() { 63 | _ = updaterController 64 | configureCancellables() 65 | } 66 | 67 | /// Configures the internal observers for the manager. 68 | private func configureCancellables() { 69 | updater.publisher(for: \.canCheckForUpdates) 70 | .assign(to: &$canCheckForUpdates) 71 | updater.publisher(for: \.lastUpdateCheckDate) 72 | .assign(to: &$lastUpdateCheckDate) 73 | } 74 | 75 | /// Checks for app updates. 76 | @objc func checkForUpdates() { 77 | #if DEBUG 78 | // Checking for updates hangs in debug mode. 79 | let alert = NSAlert() 80 | alert.messageText = "Checking for updates is not supported in debug mode." 81 | alert.runModal() 82 | #else 83 | guard let appState else { 84 | return 85 | } 86 | // Activate the app in case an alert needs to be displayed. 87 | appState.activate(withPolicy: .regular) 88 | appState.openSettingsWindow() 89 | updater.checkForUpdates() 90 | #endif 91 | } 92 | } 93 | 94 | // MARK: UpdatesManager: SPUUpdaterDelegate 95 | extension UpdatesManager: @preconcurrency SPUUpdaterDelegate { 96 | func updater(_ updater: SPUUpdater, willScheduleUpdateCheckAfterDelay delay: TimeInterval) { 97 | guard let appState else { 98 | return 99 | } 100 | appState.userNotificationManager.requestAuthorization() 101 | } 102 | } 103 | 104 | // MARK: UpdatesManager: SPUStandardUserDriverDelegate 105 | extension UpdatesManager: @preconcurrency SPUStandardUserDriverDelegate { 106 | var supportsGentleScheduledUpdateReminders: Bool { true } 107 | 108 | func standardUserDriverShouldHandleShowingScheduledUpdate( 109 | _ update: SUAppcastItem, 110 | andInImmediateFocus immediateFocus: Bool 111 | ) -> Bool { 112 | if NSApp.isActive { 113 | return immediateFocus 114 | } else { 115 | return false 116 | } 117 | } 118 | 119 | func standardUserDriverWillHandleShowingUpdate( 120 | _ handleShowingUpdate: Bool, 121 | forUpdate update: SUAppcastItem, 122 | state: SPUUserUpdateState 123 | ) { 124 | guard let appState else { 125 | return 126 | } 127 | if !state.userInitiated { 128 | appState.userNotificationManager.addRequest( 129 | with: .updateCheck, 130 | title: "A new update is available", 131 | body: "Version \(update.displayVersionString) is now available" 132 | ) 133 | } 134 | } 135 | 136 | func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) { 137 | guard let appState else { 138 | return 139 | } 140 | appState.userNotificationManager.removeDeliveredNotifications(with: [.updateCheck]) 141 | } 142 | } 143 | 144 | // MARK: UpdatesManager: BindingExposable 145 | extension UpdatesManager: BindingExposable { } 146 | -------------------------------------------------------------------------------- /Ice/UserNotifications/UserNotificationIdentifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserNotificationIdentifier.swift 3 | // Ice 4 | // 5 | 6 | /// An identifier for a user notification. 7 | enum UserNotificationIdentifier: String { 8 | case updateCheck = "UpdateCheck" 9 | } 10 | -------------------------------------------------------------------------------- /Ice/UserNotifications/UserNotificationManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserNotificationManager.swift 3 | // Ice 4 | // 5 | 6 | import UserNotifications 7 | 8 | /// Manager for user notifications. 9 | @MainActor 10 | final class UserNotificationManager: NSObject { 11 | /// The shared app state. 12 | private(set) weak var appState: AppState? 13 | 14 | /// The current notification center. 15 | var notificationCenter: UNUserNotificationCenter { .current() } 16 | 17 | /// Creates a user notification manager with the given app state. 18 | init(appState: AppState) { 19 | self.appState = appState 20 | super.init() 21 | } 22 | 23 | /// Sets up the manager. 24 | func performSetup() { 25 | notificationCenter.delegate = self 26 | } 27 | 28 | /// Requests authorization to allow user notifications for the app. 29 | func requestAuthorization() { 30 | Task { 31 | do { 32 | try await notificationCenter.requestAuthorization(options: [.badge, .alert, .sound]) 33 | } catch { 34 | Logger.userNotifications.error("Failed to request authorization for notifications: \(error)") 35 | } 36 | } 37 | } 38 | 39 | /// Schedules the delivery of a local notification. 40 | func addRequest(with identifier: UserNotificationIdentifier, title: String, body: String) { 41 | let content = UNMutableNotificationContent() 42 | content.title = title 43 | content.body = body 44 | 45 | let request = UNNotificationRequest( 46 | identifier: identifier.rawValue, 47 | content: content, 48 | trigger: nil 49 | ) 50 | 51 | notificationCenter.add(request) 52 | } 53 | 54 | /// Removes the notifications from Notification Center that match the given identifiers. 55 | func removeDeliveredNotifications(with identifiers: [UserNotificationIdentifier]) { 56 | notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers.map { $0.rawValue }) 57 | } 58 | } 59 | 60 | // MARK: UserNotificationManager: UNUserNotificationCenterDelegate 61 | extension UserNotificationManager: @preconcurrency UNUserNotificationCenterDelegate { 62 | func userNotificationCenter( 63 | _ center: UNUserNotificationCenter, 64 | didReceive response: UNNotificationResponse, 65 | withCompletionHandler completionHandler: @escaping () -> Void 66 | ) { 67 | defer { 68 | completionHandler() 69 | } 70 | 71 | guard let appState else { 72 | return 73 | } 74 | 75 | switch UserNotificationIdentifier(rawValue: response.notification.request.identifier) { 76 | case .updateCheck: 77 | guard response.actionIdentifier == UNNotificationDefaultActionIdentifier else { 78 | break 79 | } 80 | appState.updatesManager.checkForUpdates() 81 | case nil: 82 | break 83 | } 84 | } 85 | } 86 | 87 | // MARK: - Logger 88 | private extension Logger { 89 | static let userNotifications = Logger(category: "UserNotifications") 90 | } 91 | -------------------------------------------------------------------------------- /Ice/Utilities/BindingExposable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BindingExposable.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | /// A type that exposes its writable properties as bindings. 9 | protocol BindingExposable { 10 | /// A lens that exposes bindings to the writable properties of this type. 11 | typealias Bindings = ExposedBindings 12 | 13 | /// A lens that exposes bindings to the writable properties of this instance. 14 | var bindings: Bindings { get } 15 | } 16 | 17 | extension BindingExposable { 18 | var bindings: Bindings { 19 | Bindings(base: self) 20 | } 21 | } 22 | 23 | /// A lens that exposes bindings to the writable properties of a base object. 24 | @dynamicMemberLookup 25 | struct ExposedBindings { 26 | /// The object whose bindings are exposed. 27 | private let base: Base 28 | 29 | /// Creates a lens that exposes the bindings of the given object. 30 | init(base: Base) { 31 | self.base = base 32 | } 33 | 34 | /// Returns a binding to the property at the given key path. 35 | subscript(dynamicMember keyPath: ReferenceWritableKeyPath) -> Binding { 36 | Binding(get: { base[keyPath: keyPath] }, set: { base[keyPath: keyPath] = $0 }) 37 | } 38 | 39 | /// Returns a lens that exposes the bindings of the object at the given key path. 40 | subscript(dynamicMember keyPath: KeyPath) -> ExposedBindings { 41 | ExposedBindings(base: base[keyPath: keyPath]) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Ice/Utilities/CodableColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodableColor.swift 3 | // Ice 4 | // 5 | 6 | import CoreGraphics 7 | import Foundation 8 | 9 | /// A Codable wrapper around a CGColor. 10 | struct CodableColor { 11 | /// The CGColor contained within the wrapper. 12 | var cgColor: CGColor 13 | } 14 | 15 | // MARK: CodableColor: Codable 16 | extension CodableColor: Codable { 17 | private enum CodingKeys: CodingKey { 18 | case components 19 | case colorSpace 20 | } 21 | 22 | init(from decoder: Decoder) throws { 23 | let container = try decoder.container(keyedBy: CodingKeys.self) 24 | var components = try container.decode([CGFloat].self, forKey: .components) 25 | let iccData = try container.decode(Data.self, forKey: .colorSpace) as CFData 26 | guard let colorSpace = CGColorSpace(iccData: iccData) else { 27 | throw DecodingError.dataCorruptedError( 28 | forKey: .colorSpace, 29 | in: container, 30 | debugDescription: "Invalid ICC profile data" 31 | ) 32 | } 33 | guard let cgColor = CGColor(colorSpace: colorSpace, components: &components) else { 34 | throw DecodingError.dataCorrupted( 35 | DecodingError.Context( 36 | codingPath: decoder.codingPath, 37 | debugDescription: "Invalid color space or components" 38 | ) 39 | ) 40 | } 41 | self.cgColor = cgColor 42 | } 43 | 44 | func encode(to encoder: Encoder) throws { 45 | guard let components = cgColor.components else { 46 | throw EncodingError.invalidValue( 47 | cgColor, 48 | EncodingError.Context( 49 | codingPath: encoder.codingPath, 50 | debugDescription: "Missing color components" 51 | ) 52 | ) 53 | } 54 | guard let colorSpace = cgColor.colorSpace else { 55 | throw EncodingError.invalidValue( 56 | cgColor, 57 | EncodingError.Context( 58 | codingPath: encoder.codingPath, 59 | debugDescription: "Missing color space" 60 | ) 61 | ) 62 | } 63 | guard let iccData = colorSpace.copyICCData() else { 64 | throw EncodingError.invalidValue( 65 | colorSpace, 66 | EncodingError.Context( 67 | codingPath: encoder.codingPath, 68 | debugDescription: "Missing ICC profile data" 69 | ) 70 | ) 71 | } 72 | var container = encoder.container(keyedBy: CodingKeys.self) 73 | try container.encode(components, forKey: .components) 74 | try container.encode(iccData as Data, forKey: .colorSpace) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Ice/Utilities/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // Ice 4 | // 5 | 6 | import Foundation 7 | 8 | enum Constants { 9 | // swiftlint:disable force_unwrapping 10 | /// The version string in the app's bundle. 11 | static let appVersion = Bundle.main.versionString! 12 | 13 | /// The user-readable copyright string in the app's bundle. 14 | static let copyright = Bundle.main.copyrightString! 15 | 16 | /// The bundle identifier of the app. 17 | static let bundleIdentifier = Bundle.main.bundleIdentifier! 18 | // swiftlint:enable force_unwrapping 19 | 20 | /// The identifier for the settings window. 21 | static let settingsWindowID = "SettingsWindow" 22 | 23 | /// The identifier for the permissions window. 24 | static let permissionsWindowID = "PermissionsWindow" 25 | 26 | /// The title for the settings window. 27 | static let settingsWindowTitle = "Ice" 28 | 29 | /// The title for the permissions window. 30 | static let permissionsWindowTitle = "Permissions" 31 | } 32 | -------------------------------------------------------------------------------- /Ice/Utilities/IconResource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IconResource.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | /// A type that produces a view representing an icon. 9 | enum IconResource: Hashable { 10 | /// A resource derived from a system symbol. 11 | case systemSymbol(_ name: String) 12 | 13 | /// A resource derived from an asset catalog. 14 | case assetCatalog(_ resource: ImageResource) 15 | 16 | /// The view produced by the resource. 17 | @ViewBuilder 18 | var view: some View { 19 | image 20 | .resizable() 21 | .aspectRatio(contentMode: .fit) 22 | } 23 | 24 | /// The image produced by the resource. 25 | private var image: Image { 26 | switch self { 27 | case .systemSymbol(let name): 28 | Image(systemName: name) 29 | case .assetCatalog(let resource): 30 | Image(resource) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Ice/Utilities/Injection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Injection.swift 3 | // Ice 4 | // 5 | 6 | /// Updates the given value in place using a closure. 7 | /// 8 | /// Use this function to repeatedly update a value while ensuring it is only mutated once. 9 | func update(_ value: inout Value, body: (inout Value) throws -> Void) rethrows { 10 | try body(&value) 11 | } 12 | 13 | /// Updates the given value in place using a closure. 14 | /// 15 | /// Use this function to repeatedly update a value while ensuring it is only mutated once. 16 | func update(_ value: inout Value, body: (inout Value) async throws -> Void) async rethrows { 17 | try await body(&value) 18 | } 19 | 20 | /// Updates a copy of the given value using a closure and returns the updated value. 21 | @discardableResult 22 | func with(_ value: Value, update: (inout Value) throws -> Void) rethrows -> Value { 23 | var copy = value 24 | try update(©) 25 | return copy 26 | } 27 | 28 | /// Updates a copy of the given value using a closure and returns the updated value. 29 | @discardableResult 30 | func with(_ value: Value, update: (inout Value) async throws -> Void) async rethrows -> Value { 31 | var copy = value 32 | try await update(©) 33 | return copy 34 | } 35 | -------------------------------------------------------------------------------- /Ice/Utilities/LocalizedErrorWrapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalizedErrorWrapper.swift 3 | // Ice 4 | // 5 | 6 | import Foundation 7 | 8 | /// A type that wraps the information of any error inside a `LocalizedError`. 9 | /// 10 | /// If the error used to initialize the box is also a `LocalizedError`, its 11 | /// information is passed through to the box. Otherwise, a description of the 12 | /// error is passed to the wrapper. 13 | struct LocalizedErrorWrapper: LocalizedError { 14 | let errorDescription: String? 15 | let failureReason: String? 16 | let helpAnchor: String? 17 | let recoverySuggestion: String? 18 | 19 | /// Creates a wrapper with the given error. 20 | init(_ error: any Error) { 21 | if let error = error as? any LocalizedError { 22 | self.errorDescription = error.errorDescription 23 | self.failureReason = error.failureReason 24 | self.helpAnchor = error.helpAnchor 25 | self.recoverySuggestion = error.recoverySuggestion 26 | } else { 27 | self.errorDescription = error.localizedDescription 28 | self.failureReason = nil 29 | self.helpAnchor = nil 30 | self.recoverySuggestion = nil 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Ice/Utilities/Logging.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logging.swift 3 | // Ice 4 | // 5 | 6 | import OSLog 7 | 8 | /// A type that encapsulates logging behavior for Ice. 9 | struct Logger { 10 | /// The unified logger at the base of this logger. 11 | private let base: os.Logger 12 | 13 | /// Creates a logger for Ice using the specified category. 14 | init(category: String) { 15 | self.base = os.Logger(subsystem: Constants.bundleIdentifier, category: category) 16 | } 17 | 18 | /// Logs the given informative message to the logger. 19 | func info(_ message: String) { 20 | base.info("\(message, privacy: .public)") 21 | } 22 | 23 | /// Logs the given debug message to the logger. 24 | func debug(_ message: String) { 25 | base.debug("\(message, privacy: .public)") 26 | } 27 | 28 | /// Logs the given error message to the logger. 29 | func error(_ message: String) { 30 | base.error("\(message, privacy: .public)") 31 | } 32 | 33 | /// Logs the given warning message to the logger. 34 | func warning(_ message: String) { 35 | base.warning("\(message, privacy: .public)") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Ice/Utilities/MouseCursor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MouseCursor.swift 3 | // Ice 4 | // 5 | 6 | import CoreGraphics 7 | 8 | /// A namespace for mouse cursor operations. 9 | enum MouseCursor { 10 | /// Returns the location of the mouse cursor in the coordinate space used by 11 | /// the `AppKit` framework, with the origin at the bottom left of the screen. 12 | static var locationAppKit: CGPoint? { 13 | CGEvent(source: nil)?.unflippedLocation 14 | } 15 | 16 | /// Returns the location of the mouse cursor in the coordinate space used by 17 | /// the `CoreGraphics` framework, with the origin at the top left of the screen. 18 | static var locationCoreGraphics: CGPoint? { 19 | CGEvent(source: nil)?.location 20 | } 21 | 22 | /// Hides the mouse cursor and increments the hide cursor count. 23 | static func hide() { 24 | let result = CGDisplayHideCursor(CGMainDisplayID()) 25 | if result != .success { 26 | Logger.mouseCursor.error("CGDisplayHideCursor failed with error \(result.logString)") 27 | } 28 | } 29 | 30 | /// Decrements the hide cursor count and shows the mouse cursor if the count is `0`. 31 | static func show() { 32 | let result = CGDisplayShowCursor(CGMainDisplayID()) 33 | if result != .success { 34 | Logger.mouseCursor.error("CGDisplayShowCursor failed with error \(result.logString)") 35 | } 36 | } 37 | 38 | /// Moves the mouse cursor to the given point without generating events. 39 | /// 40 | /// - Parameter point: The point to move the cursor to in global display coordinates. 41 | static func warp(to point: CGPoint) { 42 | let result = CGWarpMouseCursorPosition(point) 43 | if result != .success { 44 | Logger.mouseCursor.error("CGWarpMouseCursorPosition failed with error \(result.logString)") 45 | } 46 | } 47 | } 48 | 49 | // MARK: - Logger 50 | private extension Logger { 51 | static let mouseCursor = Logger(category: "MouseCursor") 52 | } 53 | -------------------------------------------------------------------------------- /Ice/Utilities/Notifications.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Notifications.swift 3 | // Ice 4 | // 5 | 6 | import Foundation 7 | 8 | extension DistributedNotificationCenter { 9 | /// A notification posted whenever the system-wide interface theme changes. 10 | static let interfaceThemeChangedNotification = Notification.Name("AppleInterfaceThemeChangedNotification") 11 | } 12 | -------------------------------------------------------------------------------- /Ice/Utilities/ObjectStorage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObjectStorage.swift 3 | // Ice 4 | // 5 | 6 | import ObjectiveC 7 | 8 | // MARK: - Object Storage 9 | 10 | /// A type that uses the Objective-C runtime to store values of a given 11 | /// type with an object. 12 | final class ObjectStorage { 13 | /// The association policy to use for storage. 14 | /// 15 | /// - Note: Regardless of whether a value is stored with a strong or 16 | /// weak reference, the association is made strongly. Weak references 17 | /// are stored inside a `WeakReference` object. 18 | private let policy = objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC 19 | 20 | /// The key used for value lookup. 21 | /// 22 | /// The key is unique to this instance. 23 | private var key: UnsafeRawPointer { 24 | UnsafeRawPointer(Unmanaged.passUnretained(self).toOpaque()) 25 | } 26 | 27 | /// Sets the value for the given object. 28 | /// 29 | /// If the value is an object, it is stored with a strong reference. 30 | /// Use ``weakSet(_:for:)`` to store an object with a weak reference. 31 | /// 32 | /// - Parameters: 33 | /// - value: A value to set. 34 | /// - object: An object to set the value for. 35 | func set(_ value: Value?, for object: AnyObject) { 36 | objc_setAssociatedObject(object, key, value, policy) 37 | } 38 | 39 | /// Retrieves the value stored for the given object. 40 | /// 41 | /// - Parameter object: An object to retrieve the value for. 42 | func value(for object: AnyObject) -> Value? { 43 | let value = objc_getAssociatedObject(object, key) 44 | return if let container = value as? WeakReference { 45 | container.object as? Value 46 | } else { 47 | value as? Value 48 | } 49 | } 50 | } 51 | 52 | // MARK: - Weak Storage 53 | 54 | /// An object containing a weak reference to another object. 55 | private final class WeakReference { 56 | /// A weak reference to an object. 57 | private(set) weak var object: AnyObject? 58 | 59 | /// Creates a weak reference to an object. 60 | init(_ object: AnyObject) { 61 | self.object = object 62 | } 63 | } 64 | 65 | extension ObjectStorage where Value: AnyObject { 66 | /// Sets a weak reference to an object. 67 | /// 68 | /// - Parameters: 69 | /// - value: An object to set a weak reference to. 70 | /// - object: An object to set the weak reference for. 71 | func weakSet(_ value: Value?, for object: AnyObject) { 72 | objc_setAssociatedObject(object, key, value.map(WeakReference.init), policy) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Ice/Utilities/RehideStrategy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RehideStrategy.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | /// A type that determines how the auto-rehide feature works. 9 | enum RehideStrategy: Int, CaseIterable, Identifiable { 10 | /// Menu bar items are rehidden using a smart algorithm. 11 | case smart = 0 12 | /// Menu bar items are rehidden after a given time interval. 13 | case timed = 1 14 | /// Menu bar items are rehidden when the focused app changes. 15 | case focusedApp = 2 16 | 17 | var id: Int { rawValue } 18 | 19 | /// Localized string key representation. 20 | var localized: LocalizedStringKey { 21 | switch self { 22 | case .smart: "Smart" 23 | case .timed: "Timed" 24 | case .focusedApp: "Focused app" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Ice/Utilities/ScreenCapture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScreenCapture.swift 3 | // Ice 4 | // 5 | 6 | import CoreGraphics 7 | import ScreenCaptureKit 8 | 9 | /// A namespace for screen capture operations. 10 | enum ScreenCapture { 11 | /// Returns a Boolean value that indicates whether the app has been granted screen capture permissions. 12 | static func checkPermissions() -> Bool { 13 | for item in MenuBarItem.getMenuBarItems(onScreenOnly: false, activeSpaceOnly: true) { 14 | // Don't check items owned by Ice. 15 | if item.owningApplication == .current { 16 | continue 17 | } 18 | return item.title != nil 19 | } 20 | // CGPreflightScreenCaptureAccess() only returns an initial value for whether the app 21 | // has permissions, but we can use it as a fallback. 22 | return CGPreflightScreenCaptureAccess() 23 | } 24 | 25 | /// Returns a Boolean value that indicates whether the app has been granted screen capture permissions. 26 | /// 27 | /// The first time this function is called, the permissions state is computed, cached, and returned. 28 | /// Subsequent calls either return the cached value, or recompute the permissions state before caching 29 | /// and returning it. 30 | static func cachedCheckPermissions(reset: Bool = false) -> Bool { 31 | enum Context { 32 | static var lastCheckResult: Bool? 33 | } 34 | 35 | if !reset { 36 | if let lastCheckResult = Context.lastCheckResult { 37 | return lastCheckResult 38 | } 39 | } 40 | 41 | let realResult = checkPermissions() 42 | Context.lastCheckResult = realResult 43 | return realResult 44 | } 45 | 46 | /// Requests screen capture permissions. 47 | static func requestPermissions() { 48 | if #available(macOS 15.0, *) { 49 | // CGRequestScreenCaptureAccess() is broken on macOS 15. SCShareableContent requires 50 | // screen capture permissions, and triggers a request if the user doesn't have them. 51 | SCShareableContent.getWithCompletionHandler { _, _ in } 52 | } else { 53 | CGRequestScreenCaptureAccess() 54 | } 55 | } 56 | 57 | /// Captures a composite image of an array of windows. 58 | /// 59 | /// - Parameters: 60 | /// - windowIDs: The identifiers of the windows to capture. 61 | /// - screenBounds: The bounds to capture. Pass `nil` to capture the minimum rectangle that encloses the windows. 62 | /// - option: Options that specify the image to be captured. 63 | static func captureWindows(_ windowIDs: [CGWindowID], screenBounds: CGRect? = nil, option: CGWindowImageOption = []) -> CGImage? { 64 | let pointer = UnsafeMutablePointer.allocate(capacity: windowIDs.count) 65 | for (index, windowID) in windowIDs.enumerated() { 66 | pointer[index] = UnsafeRawPointer(bitPattern: UInt(windowID)) 67 | } 68 | guard let windowArray = CFArrayCreate(kCFAllocatorDefault, pointer, windowIDs.count, nil) else { 69 | return nil 70 | } 71 | return .windowListImage(from: screenBounds ?? .null, windowArray: windowArray, imageOption: option) 72 | } 73 | 74 | /// Captures an image of a window. 75 | /// 76 | /// - Parameters: 77 | /// - windowID: The identifier of the window to capture. 78 | /// - screenBounds: The bounds to capture. Pass `nil` to capture the minimum rectangle that encloses the window. 79 | /// - option: Options that specify the image to be captured. 80 | static func captureWindow(_ windowID: CGWindowID, screenBounds: CGRect? = nil, option: CGWindowImageOption = []) -> CGImage? { 81 | captureWindows([windowID], screenBounds: screenBounds, option: option) 82 | } 83 | } 84 | 85 | /// A protocol used to suppress deprecation warnings for the `CGWindowList` screen capture APIs. 86 | /// 87 | /// ScreenCaptureKit doesn't support capturing composite images of offscreen menu bar items, but 88 | /// this should be replaced once it does. 89 | private protocol WindowListImage { 90 | init?(windowListFromArrayScreenBounds: CGRect, windowArray: CFArray, imageOption: CGWindowImageOption) 91 | } 92 | 93 | private extension WindowListImage { 94 | static func windowListImage(from screenBounds: CGRect, windowArray: CFArray, imageOption: CGWindowImageOption) -> Self? { 95 | Self(windowListFromArrayScreenBounds: screenBounds, windowArray: windowArray, imageOption: imageOption) 96 | } 97 | } 98 | 99 | extension CGImage: WindowListImage { } 100 | -------------------------------------------------------------------------------- /Ice/Utilities/StatusItemDefaults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatusItemDefaults.swift 3 | // Ice 4 | // 5 | 6 | import Cocoa 7 | 8 | // MARK: - StatusItemDefaults 9 | 10 | /// Proxy getters and setters for a status item's user defaults values. 11 | enum StatusItemDefaults { 12 | /// Accesses the value associated with the specified key and autosave name. 13 | static subscript(key: Key, autosaveName: String) -> Value? { 14 | get { 15 | let stringKey = key.stringKey(for: autosaveName) 16 | return UserDefaults.standard.object(forKey: stringKey) as? Value 17 | } 18 | set { 19 | let stringKey = key.stringKey(for: autosaveName) 20 | return UserDefaults.standard.set(newValue, forKey: stringKey) 21 | } 22 | } 23 | 24 | /// Migrates the given status item defaults key from an old autosave name 25 | /// to a new autosave name. 26 | static func migrate(key: Key, from oldAutosaveName: String, to newAutosaveName: String) { 27 | guard newAutosaveName != oldAutosaveName else { 28 | return 29 | } 30 | Self[key, newAutosaveName] = Self[key, oldAutosaveName] 31 | Self[key, oldAutosaveName] = nil 32 | } 33 | } 34 | 35 | // MARK: - StatusItemDefaults.Key 36 | 37 | extension StatusItemDefaults { 38 | /// Keys used to look up user defaults values for status items. 39 | struct Key { 40 | /// The raw value of the key. 41 | let rawValue: String 42 | 43 | /// Returns the full string key for the given autosave name. 44 | func stringKey(for autosaveName: String) -> String { 45 | return "NSStatusItem \(rawValue) \(autosaveName)" 46 | } 47 | } 48 | } 49 | 50 | extension StatusItemDefaults.Key { 51 | /// String key: "NSStatusItem Preferred Position autosaveName" 52 | static let preferredPosition = Self(rawValue: "Preferred Position") 53 | } 54 | 55 | extension StatusItemDefaults.Key { 56 | /// String key: "NSStatusItem Visible autosaveName" 57 | static let visible = Self(rawValue: "Visible") 58 | } 59 | -------------------------------------------------------------------------------- /Ice/Utilities/SystemAppearance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SystemAppearance.swift 3 | // Ice 4 | // 5 | 6 | import SwiftUI 7 | 8 | /// A value corresponding to a light or dark appearance. 9 | enum SystemAppearance { 10 | /// A light appearance. 11 | case light 12 | /// A dark appearance. 13 | case dark 14 | 15 | /// The names of the light appearances used by the system. 16 | private static let systemLightAppearanceNames: Set = [ 17 | .aqua, 18 | .vibrantLight, 19 | .accessibilityHighContrastAqua, 20 | .accessibilityHighContrastVibrantLight, 21 | ] 22 | 23 | /// The names of the dark appearances used by the system. 24 | private static let systemDarkAppearanceNames: Set = [ 25 | .vibrantDark, 26 | .darkAqua, 27 | .accessibilityHighContrastDarkAqua, 28 | .accessibilityHighContrastVibrantDark, 29 | ] 30 | 31 | /// Returns the system appearance that exactly matches the given appearance, 32 | /// or `nil` if the system appearance cannot be determined. 33 | private static func exactMatch(for appearance: NSAppearance) -> SystemAppearance? { 34 | let name = appearance.name 35 | if systemDarkAppearanceNames.contains(name) { 36 | return .dark 37 | } 38 | if systemLightAppearanceNames.contains(name) { 39 | return .light 40 | } 41 | return nil 42 | } 43 | 44 | /// Returns the system appearance that best matches the given appearance, 45 | /// or `nil` if the system appearance cannot be determined. 46 | private static func bestMatch(for appearance: NSAppearance) -> SystemAppearance? { 47 | let lowercased = appearance.name.rawValue.lowercased() 48 | if lowercased.contains("dark") { 49 | return .dark 50 | } 51 | if lowercased.contains("light") || lowercased.contains("aqua") { 52 | return .light 53 | } 54 | return nil 55 | } 56 | 57 | /// Returns the system appearance of the given appearance. 58 | /// 59 | /// If a system appearance cannot be found that matches the given appearance, 60 | /// the ``light`` system appearance is returned. 61 | private static func systemAppearance(for appearance: NSAppearance) -> SystemAppearance { 62 | if let match = exactMatch(for: appearance) { 63 | return match 64 | } 65 | if let match = bestMatch(for: appearance) { 66 | return match 67 | } 68 | return .light 69 | } 70 | 71 | /// The current system appearance. 72 | static var current: SystemAppearance { 73 | systemAppearance(for: NSApp.effectiveAppearance) 74 | } 75 | 76 | /// The title key to display in the interface. 77 | var titleKey: LocalizedStringKey { 78 | switch self { 79 | case .light: "Light Appearance" 80 | case .dark: "Dark Appearance" 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Ice/Utilities/TaskTimeout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TaskTimeout.swift 3 | // Ice 4 | // 5 | 6 | import Foundation 7 | 8 | extension Task where Failure == any Error { 9 | /// Runs the given throwing operation asynchronously as part of a new top-level task 10 | /// on behalf of the current actor. 11 | /// 12 | /// - Parameters: 13 | /// - priority: The priority of the task. 14 | /// - timeout: The amount of time to wait before throwing a ``TaskTimeoutError``. 15 | /// - tolerance: The tolerance of the clock. 16 | /// - clock: The clock to use in the timeout operation. 17 | /// - operation: The operation to perform. 18 | @discardableResult 19 | init( 20 | priority: TaskPriority? = nil, 21 | timeout: C.Instant.Duration, 22 | tolerance: C.Instant.Duration? = nil, 23 | clock: C = ContinuousClock(), 24 | operation: @escaping @Sendable () async throws -> Success 25 | ) { 26 | self.init(priority: priority) { 27 | try await Task.run(operation: operation, withTimeout: timeout, tolerance: tolerance, clock: clock) 28 | } 29 | } 30 | 31 | /// Runs the given throwing operation asynchronously as part of a new top-level task. 32 | /// 33 | /// - Parameters: 34 | /// - priority: The priority of the task. 35 | /// - timeout: The amount of time to wait before throwing a ``TaskTimeoutError``. 36 | /// - tolerance: The tolerance of the clock. 37 | /// - clock: The clock to use in the timeout operation. 38 | /// - operation: The operation to perform. 39 | /// 40 | /// - Returns: A reference to the task. 41 | @discardableResult 42 | static func detached( 43 | priority: TaskPriority? = nil, 44 | timeout: C.Instant.Duration, 45 | tolerance: C.Instant.Duration? = nil, 46 | clock: C = ContinuousClock(), 47 | operation: @escaping @Sendable () async throws -> Success 48 | ) -> Task { 49 | detached(priority: priority) { 50 | try await run(operation: operation, withTimeout: timeout, tolerance: tolerance, clock: clock) 51 | } 52 | } 53 | 54 | private static func run( 55 | operation: @escaping @Sendable () async throws -> Success, 56 | withTimeout timeout: C.Instant.Duration, 57 | tolerance: C.Instant.Duration?, 58 | clock: C 59 | ) async throws -> Success { 60 | try await withThrowingTaskGroup(of: Success.self) { group in 61 | group.addTask(operation: operation) 62 | group.addTask { 63 | try await _Concurrency.Task.sleep(for: timeout, tolerance: tolerance, clock: clock) 64 | throw TaskTimeoutError() 65 | } 66 | guard let success = try await group.next() else { 67 | throw _Concurrency.CancellationError() 68 | } 69 | group.cancelAll() 70 | return success 71 | } 72 | } 73 | } 74 | 75 | // MARK: - TaskTimeoutError 76 | 77 | /// An error that indicates that a task timed out. 78 | struct TaskTimeoutError: Error, CustomStringConvertible { 79 | let description = "Task timed out before completion" 80 | } 81 | 82 | // MARK: TaskTimeoutError: LocalizedError 83 | extension TaskTimeoutError: LocalizedError { 84 | var errorDescription: String? { description } 85 | } 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

Ice

4 |
5 | 6 | Ice is a powerful menu bar management tool. While its primary function is hiding and showing menu bar items, it aims to cover a wide variety of additional features to make it one of the most versatile menu bar tools available. 7 | 8 | ![Banner](https://github.com/user-attachments/assets/4423085c-4e4b-4f3d-ad0f-90a217c03470) 9 | 10 | [![Download](https://img.shields.io/badge/download-latest-brightgreen?style=flat-square)](https://github.com/jordanbaird/Ice/releases/latest) 11 | ![Platform](https://img.shields.io/badge/platform-macOS-blue?style=flat-square) 12 | ![Requirements](https://img.shields.io/badge/requirements-macOS%2014%2B-fa4e49?style=flat-square) 13 | [![Sponsor](https://img.shields.io/badge/Sponsor%20%E2%9D%A4%EF%B8%8F-8A2BE2?style=flat-square)](https://github.com/sponsors/jordanbaird) 14 | [![Website](https://img.shields.io/badge/Website-015FBA?style=flat-square)](https://icemenubar.app) 15 | [![License](https://img.shields.io/github/license/jordanbaird/Ice?style=flat-square)](LICENSE) 16 | 17 | > [!NOTE] 18 | > Ice is currently in active development. Some features have not yet been implemented. Download the latest release [here](https://github.com/jordanbaird/Ice/releases/latest) and see the roadmap below for upcoming features. 19 | 20 | 21 | Buy Me A Coffee 22 | 23 | 24 | ## Install 25 | 26 | ### Manual Installation 27 | 28 | Download the "Ice.zip" file from the [latest release](https://github.com/jordanbaird/Ice/releases/latest) and move the unzipped app into your `Applications` folder. 29 | 30 | ### Homebrew 31 | 32 | Install Ice using the following command: 33 | 34 | ```sh 35 | brew install jordanbaird-ice 36 | ``` 37 | 38 | ## Features/Roadmap 39 | 40 | ### Menu bar item management 41 | 42 | - [x] Hide menu bar items 43 | - [x] "Always-hidden" menu bar section 44 | - [x] Show hidden menu bar items when hovering over the menu bar 45 | - [x] Show hidden menu bar items when an empty area in the menu bar is clicked 46 | - [x] Show hidden menu bar items by scrolling or swiping in the menu bar 47 | - [x] Automatically rehide menu bar items 48 | - [x] Hide application menus when they overlap with shown menu bar items 49 | - [x] Drag and drop interface to arrange individual menu bar items 50 | - [x] Display hidden menu bar items in a separate bar (e.g. for MacBooks with the notch) 51 | - [x] Search menu bar items 52 | - [x] Menu bar item spacing (BETA) 53 | - [ ] Profiles for menu bar layout 54 | - [ ] Individual spacer items 55 | - [ ] Menu bar item groups 56 | - [ ] Show menu bar items when trigger conditions are met 57 | 58 | ### Menu bar appearance 59 | 60 | - [x] Menu bar tint (solid and gradient) 61 | - [x] Menu bar shadow 62 | - [x] Menu bar border 63 | - [x] Custom menu bar shapes (rounded and/or split) 64 | - [ ] Remove background behind menu bar 65 | - [ ] Rounded screen corners 66 | - [ ] Different settings for light/dark mode 67 | 68 | ### Hotkeys 69 | 70 | - [x] Toggle individual menu bar sections 71 | - [x] Show the search panel 72 | - [x] Enable/disable the Ice Bar 73 | - [x] Show/hide section divider icons 74 | - [x] Toggle application menus 75 | - [ ] Enable/disable auto rehide 76 | - [ ] Temporarily show individual menu bar items 77 | 78 | ### Other 79 | 80 | - [x] Launch at login 81 | - [x] Automatic updates 82 | - [ ] Menu bar widgets 83 | 84 | ## Why does Ice only support macOS 14 and later? 85 | 86 | Ice uses a number of system APIs that are available starting in macOS 14. As such, there are no plans to support earlier versions of macOS. 87 | 88 | ## Gallery 89 | 90 | #### Show hidden menu bar items below the menu bar 91 | 92 | ![Ice Bar](https://github.com/user-attachments/assets/f1429589-6186-4e1b-8aef-592219d49b9b) 93 | 94 | #### Drag-and-drop interface to arrange menu bar items 95 | 96 | ![Menu Bar Layout](https://github.com/user-attachments/assets/095442ba-f2d0-4bb4-9632-91e26ef8d45b) 97 | 98 | #### Customize the menu bar's appearance 99 | 100 | ![Menu Bar Appearance](https://github.com/user-attachments/assets/8c22c185-c3d2-49bb-971e-e1fc17df04b3) 101 | 102 | #### Menu bar item search 103 | 104 | ![Menu Bar Item Search](https://github.com/user-attachments/assets/d1a7df3a-4989-4077-a0b1-8e7d5a1ba5b8) 105 | 106 | #### Custom menu bar item spacing 107 | 108 | ![Menu Bar Item Spacing](https://github.com/user-attachments/assets/b196aa7e-184a-4d4c-b040-502f4aae40a6) 109 | 110 | ## License 111 | 112 | Ice is available under the [GPL-3.0 license](LICENSE). 113 | -------------------------------------------------------------------------------- /Resources/Icon.fig: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Resources/Icon.fig -------------------------------------------------------------------------------- /Resources/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Resources/Icon.png -------------------------------------------------------------------------------- /Resources/rearranging.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Resources/rearranging.gif -------------------------------------------------------------------------------- /Resources/rearranging.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jordanbaird/Ice/16222b344f7fe31c6b61cfeb5aae5978d4fe6874/Resources/rearranging.mov --------------------------------------------------------------------------------