├── .github └── FUNDING.yml ├── resources ├── header-image.png ├── preview-video.mp4 ├── preview-image-dark.png ├── preview-image-light.png └── raycast-menu-items.jpeg ├── Barik ├── Resources │ └── Assets.xcassets │ │ ├── Contents.json │ │ ├── Colors │ │ ├── Contents.json │ │ ├── Icon.colorset │ │ │ └── Contents.json │ │ ├── Active.colorset │ │ │ └── Contents.json │ │ ├── Foreground.colorset │ │ │ └── Contents.json │ │ ├── NoActive.colorset │ │ │ └── Contents.json │ │ ├── Selected.colorset │ │ │ └── Contents.json │ │ ├── Shadow.colorset │ │ │ └── Contents.json │ │ ├── Icon Shadow.colorset │ │ │ └── Contents.json │ │ ├── Foreground Outside.colorset │ │ │ └── Contents.json │ │ ├── Foreground Shadow.colorset │ │ │ └── Contents.json │ │ ├── Foreground Outside Invert.colorset │ │ │ └── Contents.json │ │ └── Foreground Shadow Outside.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ ├── Icon 128.png │ │ ├── Icon 129.png │ │ ├── Icon 16.png │ │ ├── Icon 17.png │ │ ├── Icon 256.png │ │ ├── Icon 257.png │ │ ├── Icon 33.png │ │ ├── Icon 34.png │ │ ├── Icon 512.png │ │ ├── Icon 513.png │ │ └── Contents.json │ │ └── AccentColor.colorset │ │ └── Contents.json ├── Info.plist ├── Constants.swift ├── BarikApp.swift ├── Barik.entitlements ├── Widgets │ ├── SystemBanner │ │ ├── UpdateBannerWidget.swift │ │ ├── ChangelogBannerWidget.swift │ │ ├── SystemBannerWidget.swift │ │ ├── ChangelogPopup.swift │ │ └── MarkdownTheme.swift │ ├── Spaces │ │ ├── Aerospace │ │ │ ├── AerospaceModels.swift │ │ │ └── AerospaceProvider.swift │ │ ├── Yabai │ │ │ ├── YabaiModels.swift │ │ │ └── YabaiProvider.swift │ │ ├── SpacesViewModel.swift │ │ ├── SpacesModels.swift │ │ └── SpacesWidget.swift │ ├── Battery │ │ ├── BatteryPopup.swift │ │ ├── BatteryManager.swift │ │ └── BatteryWidget.swift │ ├── Network │ │ ├── NetworkWidget.swift │ │ ├── NetworkPopup.swift │ │ └── NetworkViewModel.swift │ ├── Time+Calendar │ │ ├── TimeWidget.swift │ │ ├── CalendarManager.swift │ │ └── CalendarPopup.swift │ └── NowPlaying │ │ ├── NowPlayingWidget.swift │ │ ├── NowPlayingPopup.swift │ │ └── NowPlayingManager.swift ├── Views │ ├── BackgroundView.swift │ ├── MenuBarView.swift │ └── AppUpdater.swift ├── Utils │ ├── VersionChecker.swift │ ├── ExperimentalConfigurationModifier.swift │ └── ImageCache.swift ├── AppDelegate.swift ├── MenuBarPopup │ ├── MenuBarPopup.swift │ ├── MenuBarPopupVariantView.swift │ └── MenuBarPopupView.swift └── Config │ └── ConfigManager.swift ├── Barik.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── swiftpm │ │ └── Package.resolved ├── xcuserdata │ └── mocki-toki.xcuserdatad │ │ └── xcschemes │ │ └── xcschememanagement.plist └── xcshareddata │ └── xcschemes │ └── Barik.xcscheme ├── LICENSE ├── CHANGELOG.md ├── example └── .yabairc ├── .gitignore └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: mocki_toki 2 | -------------------------------------------------------------------------------- /resources/header-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mocki-toki/barik/HEAD/resources/header-image.png -------------------------------------------------------------------------------- /resources/preview-video.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mocki-toki/barik/HEAD/resources/preview-video.mp4 -------------------------------------------------------------------------------- /resources/preview-image-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mocki-toki/barik/HEAD/resources/preview-image-dark.png -------------------------------------------------------------------------------- /resources/preview-image-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mocki-toki/barik/HEAD/resources/preview-image-light.png -------------------------------------------------------------------------------- /resources/raycast-menu-items.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mocki-toki/barik/HEAD/resources/raycast-menu-items.jpeg -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/Colors/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mocki-toki/barik/HEAD/Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 128.png -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 129.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mocki-toki/barik/HEAD/Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 129.png -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mocki-toki/barik/HEAD/Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 16.png -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mocki-toki/barik/HEAD/Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 17.png -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mocki-toki/barik/HEAD/Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 256.png -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 257.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mocki-toki/barik/HEAD/Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 257.png -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 33.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mocki-toki/barik/HEAD/Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 33.png -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 34.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mocki-toki/barik/HEAD/Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 34.png -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mocki-toki/barik/HEAD/Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 512.png -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 513.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mocki-toki/barik/HEAD/Barik/Resources/Assets.xcassets/AppIcon.appiconset/Icon 513.png -------------------------------------------------------------------------------- /Barik/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Barik.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Barik/Resources/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 | -------------------------------------------------------------------------------- /Barik/Constants.swift: -------------------------------------------------------------------------------- 1 | import CoreFoundation 2 | 3 | struct Constants { 4 | static let menuBarHeight = CGFloat(55) 5 | static let menuBarPopupAnimationDurationInMilliseconds = 350 6 | static let menuBarHorizontalPadding = CGFloat(25) 7 | } 8 | -------------------------------------------------------------------------------- /Barik/BarikApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct BarikApp: App { 5 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 6 | 7 | var body: some Scene { 8 | Settings { 9 | EmptyView() 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Barik/Barik.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.automation.apple-events 6 | 7 | com.apple.security.personal-information.calendars 8 | 9 | com.apple.security.personal-information.location 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Barik.xcodeproj/xcuserdata/mocki-toki.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Barik.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | C10E2F892D41235900AC957C 16 | 17 | primary 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/Colors/Icon.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.900", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "0.900", 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 | -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/Colors/Active.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.800", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "0.400", 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 | -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/Colors/Foreground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.900", 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" : "srgb", 24 | "components" : { 25 | "alpha" : "0.900", 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 | -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/Colors/NoActive.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.400", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "0.100", 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 | -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/Colors/Selected.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.100", 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" : "srgb", 24 | "components" : { 25 | "alpha" : "0.400", 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 | -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/Colors/Shadow.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.100", 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" : "srgb", 24 | "components" : { 25 | "alpha" : "0.500", 26 | "blue" : "0.000", 27 | "green" : "0.000", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/Colors/Icon Shadow.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.100", 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" : "srgb", 24 | "components" : { 25 | "alpha" : "0.100", 26 | "blue" : "0.000", 27 | "green" : "0.000", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/Colors/Foreground Outside.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.900", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "0.900", 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 | -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/Colors/Foreground Shadow.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.000", 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" : "srgb", 24 | "components" : { 25 | "alpha" : "0.500", 26 | "blue" : "0.000", 27 | "green" : "0.000", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/Colors/Foreground Outside Invert.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.800", 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" : "srgb", 24 | "components" : { 25 | "alpha" : "0.800", 26 | "blue" : "0.000", 27 | "green" : "0.000", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/Colors/Foreground Shadow Outside.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.300", 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" : "srgb", 24 | "components" : { 25 | "alpha" : "0.500", 26 | "blue" : "0.000", 27 | "green" : "0.000", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Simon Butenko 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Barik/Widgets/SystemBanner/UpdateBannerWidget.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct UpdateBannerWidget: View { 4 | @ObservedObject private var configManager = ConfigManager.shared 5 | @StateObject private var updater = AppUpdater() 6 | @State private var isUpdating = false 7 | 8 | var body: some View { 9 | if updater.updateAvailable { 10 | Button(action: handleUpdate) { 11 | Text(isUpdating ? "Updating" : "Update") 12 | .fontWeight(.semibold) 13 | } 14 | .buttonStyle(BannerButtonStyle(color: .blue)) 15 | .disabled(isUpdating) 16 | .opacity(isUpdating ? 0.5 : 1) 17 | .animation(.easeInOut, value: isUpdating) 18 | } 19 | } 20 | 21 | /// Downloads and installs the update, then terminates the application. 22 | private func handleUpdate() { 23 | isUpdating = true 24 | updater.downloadAndInstall(latest: updater.latestVersion ?? "") { 25 | DispatchQueue.main.async { 26 | NSApplication.shared.terminate(nil) 27 | } 28 | } 29 | } 30 | } 31 | 32 | struct UpdateBannerWidget_Previews: PreviewProvider { 33 | static var previews: some View { 34 | UpdateBannerWidget() 35 | .frame(width: 200, height: 100) 36 | .background(Color.black) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Barik/Views/BackgroundView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct BackgroundView: View { 4 | @ObservedObject var configManager = ConfigManager.shared 5 | 6 | private func spacer(_ geometry: GeometryProxy) -> some View { 7 | let theme: ColorScheme? = { 8 | switch configManager.config.rootToml.theme { 9 | case "dark": return .dark 10 | case "light": return .light 11 | default: return nil 12 | } 13 | }() 14 | 15 | let height = configManager.config.experimental.background.resolveHeight() 16 | 17 | return Color.clear 18 | .frame(height: height ?? geometry.size.height) 19 | .preferredColorScheme(theme) 20 | 21 | } 22 | 23 | var body: some View { 24 | if configManager.config.experimental.background.displayed { 25 | GeometryReader { geometry in 26 | if configManager.config.experimental.background.black { 27 | spacer(geometry) 28 | .background(.black) 29 | .id("black") 30 | } else { 31 | spacer(geometry) 32 | .background(configManager.config.experimental.background.blur) 33 | .id("blur") 34 | } 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Barik/Utils/VersionChecker.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct VersionChecker { 4 | private static let versionFileName = "current_barik_version" 5 | 6 | static var currentVersion: String? { 7 | Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String 8 | } 9 | 10 | static var versionFileURL: URL? { 11 | guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { 12 | return nil 13 | } 14 | 15 | let barikFolder = appSupport.appendingPathComponent("barik") 16 | return barikFolder.appendingPathComponent(versionFileName) 17 | } 18 | 19 | static func isLatestVersion() -> Bool { 20 | guard let current = currentVersion, 21 | let url = versionFileURL, 22 | let savedVersion = try? String(contentsOf: url) else { 23 | return false 24 | } 25 | return savedVersion == current 26 | } 27 | 28 | static func updateVersionFile() { 29 | guard let current = currentVersion, 30 | let url = versionFileURL else { return } 31 | 32 | let directory = url.deletingLastPathComponent() 33 | try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) 34 | try? current.write(to: url, atomically: true, encoding: .utf8) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Barik.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "2c1edbb9aa4aed7663501e3338d324c7016302b32182fae733db881bfb22a2dc", 3 | "pins" : [ 4 | { 5 | "identity" : "networkimage", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/gonzalezreal/NetworkImage", 8 | "state" : { 9 | "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", 10 | "version" : "6.0.1" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-cmark", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/swiftlang/swift-cmark", 17 | "state" : { 18 | "revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53", 19 | "version" : "0.5.0" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-markdown-ui", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/gonzalezreal/swift-markdown-ui", 26 | "state" : { 27 | "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", 28 | "version" : "2.4.1" 29 | } 30 | }, 31 | { 32 | "identity" : "tomldecoder", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/dduan/TOMLDecoder", 35 | "state" : { 36 | "revision" : "018127977b7f67e4d60938ca69db2c56ff265109", 37 | "version" : "0.3.1" 38 | } 39 | } 40 | ], 41 | "version" : 3 42 | } 43 | -------------------------------------------------------------------------------- /Barik/Widgets/SystemBanner/ChangelogBannerWidget.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ChangelogBannerWidget: View { 4 | @State private var rect: CGRect = .zero 5 | 6 | var body: some View { 7 | 8 | Button(action: { 9 | MenuBarPopup.show(rect: rect, id: "changelog") { 10 | ChangelogPopup() 11 | } 12 | }) { 13 | HStack(alignment: .center) { 14 | Text("What's new") 15 | .fontWeight(.semibold) 16 | Image(systemName: "xmark.circle.fill") 17 | .onTapGesture { 18 | NotificationCenter.default.post(name: Notification.Name("HideWhatsNewBanner"), object: nil) 19 | } 20 | } 21 | } 22 | .background( 23 | GeometryReader { geometry in 24 | Color.clear 25 | .onAppear { rect = geometry.frame(in: .global) } 26 | .onChange(of: geometry.frame(in: .global)) { 27 | _, newValue in 28 | rect = newValue 29 | } 30 | } 31 | ) 32 | .buttonStyle(BannerButtonStyle(color: .green.opacity(0.8))) 33 | .transition(.blurReplace) 34 | 35 | } 36 | } 37 | 38 | struct ChangelogBannerWidget_Previews: PreviewProvider { 39 | static var previews: some View { 40 | ChangelogBannerWidget() 41 | .frame(width: 200, height: 100) 42 | .background(Color.black) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Barik/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon 16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "Icon 17.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "Icon 33.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "Icon 34.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "Icon 128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "Icon 129.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "Icon 256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "Icon 257.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "Icon 512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "Icon 513.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Barik/Utils/ExperimentalConfigurationModifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | private struct ExperimentalConfigurationModifier: ViewModifier { 4 | @ObservedObject var configManager = ConfigManager.shared 5 | var foregroundHeight: CGFloat { configManager.config.experimental.foreground.resolveHeight() } 6 | 7 | let horizontalPadding: CGFloat 8 | let cornerRadius: CGFloat 9 | 10 | func body(content: Content) -> some View { 11 | Group { 12 | if !configManager.config.experimental.foreground.widgetsBackground.displayed { 13 | content 14 | } else { 15 | content 16 | .frame(height: foregroundHeight < 45 ? 30 : 38) 17 | .padding(.horizontal, foregroundHeight < 45 && horizontalPadding != 15 ? 0 : 18 | foregroundHeight < 30 ? 0 : horizontalPadding 19 | ) 20 | .background(configManager.config.experimental.foreground.widgetsBackground.blur) 21 | .cornerRadius(foregroundHeight < 30 ? 0 : cornerRadius) 22 | .overlay( 23 | foregroundHeight < 30 ? nil : 24 | Capsule().stroke(Color.noActive, lineWidth: 1) 25 | ) 26 | } 27 | }.scaleEffect(foregroundHeight < 25 ? 0.9 : 1, anchor: .leading) 28 | } 29 | } 30 | 31 | extension View { 32 | func experimentalConfiguration( 33 | horizontalPadding: CGFloat = 15, 34 | cornerRadius: CGFloat 35 | ) -> some View { 36 | self.modifier(ExperimentalConfigurationModifier( 37 | horizontalPadding: horizontalPadding, 38 | cornerRadius: cornerRadius 39 | )) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Barik/Widgets/SystemBanner/SystemBannerWidget.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import SwiftUI 3 | 4 | struct BannerButtonStyle: ButtonStyle { 5 | let color: Color 6 | 7 | func makeBody(configuration: Configuration) -> some View { 8 | configuration.label 9 | .foregroundColor(.white) 10 | .padding(.vertical, 5) 11 | .padding(.horizontal, 10) 12 | .background( 13 | configuration.isPressed ? color.opacity(0.7) : color 14 | ) 15 | .clipShape(.capsule) 16 | } 17 | } 18 | 19 | struct SystemBannerWidget: View { 20 | let withLeftPadding: Bool 21 | 22 | @State private var showWhatsNew: Bool = false 23 | 24 | init(withLeftPadding: Bool = false) { 25 | self.withLeftPadding = withLeftPadding 26 | } 27 | 28 | var body: some View { 29 | HStack(spacing: 15) { 30 | if withLeftPadding { 31 | Color.clear.frame(width: 0) 32 | } 33 | UpdateBannerWidget() 34 | if showWhatsNew { 35 | ChangelogBannerWidget() 36 | } 37 | }.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ShowWhatsNewBanner"))) { _ in 38 | withAnimation { 39 | showWhatsNew = true 40 | } 41 | }.onReceive(NotificationCenter.default.publisher(for: Notification.Name("HideWhatsNewBanner"))) { _ in 42 | withAnimation { 43 | showWhatsNew = false 44 | } 45 | } 46 | } 47 | } 48 | 49 | struct SystemBannerWidget_Previews: PreviewProvider { 50 | static var previews: some View { 51 | SystemBannerWidget() 52 | .frame(width: 200, height: 100) 53 | .background(Color.black) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Barik/Widgets/Spaces/Aerospace/AerospaceModels.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | struct AeroWindow: WindowModel { 4 | let id: Int 5 | let title: String 6 | let appName: String? 7 | var isFocused: Bool = false 8 | var appIcon: NSImage? 9 | let workspace: String? 10 | 11 | enum CodingKeys: String, CodingKey { 12 | case id = "window-id" 13 | case title = "window-title" 14 | case appName = "app-name" 15 | case workspace 16 | } 17 | 18 | init(from decoder: Decoder) throws { 19 | let container = try decoder.container(keyedBy: CodingKeys.self) 20 | id = try container.decode(Int.self, forKey: .id) 21 | title = try container.decode(String.self, forKey: .title) 22 | appName = try container.decodeIfPresent(String.self, forKey: .appName) 23 | workspace = try container.decodeIfPresent( 24 | String.self, forKey: .workspace) 25 | isFocused = false 26 | if let name = appName { 27 | appIcon = IconCache.shared.icon(for: name) 28 | } 29 | } 30 | } 31 | 32 | struct AeroSpace: SpaceModel { 33 | typealias WindowType = AeroWindow 34 | let workspace: String 35 | var id: String { workspace } 36 | var isFocused: Bool = false 37 | var windows: [AeroWindow] = [] 38 | 39 | enum CodingKeys: String, CodingKey { 40 | case workspace 41 | } 42 | 43 | init(from decoder: Decoder) throws { 44 | let container = try decoder.container(keyedBy: CodingKeys.self) 45 | workspace = try container.decode(String.self, forKey: .workspace) 46 | } 47 | 48 | init(workspace: String, isFocused: Bool = false, windows: [AeroWindow] = []) 49 | { 50 | self.workspace = workspace 51 | self.isFocused = isFocused 52 | self.windows = windows 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Barik/Widgets/Spaces/Yabai/YabaiModels.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | struct YabaiWindow: WindowModel { 4 | let id: Int 5 | let title: String 6 | let appName: String? 7 | let isFocused: Bool 8 | let stackIndex: Int 9 | var appIcon: NSImage? 10 | let isHidden: Bool 11 | let isFloating: Bool 12 | let isSticky: Bool 13 | let spaceId: Int 14 | 15 | enum CodingKeys: String, CodingKey { 16 | case id 17 | case spaceId = "space" 18 | case title 19 | case appName = "app" 20 | case isFocused = "has-focus" 21 | case stackIndex = "stack-index" 22 | case isHidden = "is-hidden" 23 | case isFloating = "is-floating" 24 | case isSticky = "is-sticky" 25 | } 26 | 27 | init(from decoder: Decoder) throws { 28 | let container = try decoder.container(keyedBy: CodingKeys.self) 29 | id = try container.decode(Int.self, forKey: .id) 30 | spaceId = try container.decode(Int.self, forKey: .spaceId) 31 | title = 32 | try container.decodeIfPresent(String.self, forKey: .title) 33 | ?? "Unnamed" 34 | appName = try container.decodeIfPresent(String.self, forKey: .appName) 35 | isFocused = try container.decode(Bool.self, forKey: .isFocused) 36 | stackIndex = 37 | try container.decodeIfPresent(Int.self, forKey: .stackIndex) ?? 0 38 | isHidden = try container.decode(Bool.self, forKey: .isHidden) 39 | isFloating = try container.decode(Bool.self, forKey: .isFloating) 40 | isSticky = try container.decode(Bool.self, forKey: .isSticky) 41 | if let name = appName { 42 | appIcon = IconCache.shared.icon(for: name) 43 | } 44 | } 45 | } 46 | 47 | struct YabaiSpace: SpaceModel { 48 | typealias WindowType = YabaiWindow 49 | let id: Int 50 | var isFocused: Bool 51 | var windows: [YabaiWindow] = [] 52 | 53 | enum CodingKeys: String, CodingKey { 54 | case id = "index" 55 | case isFocused = "has-focus" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Barik/Widgets/Battery/BatteryPopup.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | import SwiftUI 3 | 4 | struct BatteryPopup: View { 5 | @StateObject private var batteryManager = BatteryManager() 6 | 7 | var body: some View { 8 | ZStack { 9 | Circle() 10 | .stroke(Color.gray.opacity(0.3), lineWidth: 6) 11 | Circle() 12 | .trim(from: 0, to: CGFloat(batteryManager.batteryLevel) / 100) 13 | .stroke( 14 | batteryColor, 15 | style: StrokeStyle(lineWidth: 6, lineCap: .round) 16 | ) 17 | .rotationEffect(Angle(degrees: -90)) 18 | .animation( 19 | .easeOut(duration: 0.5), value: batteryManager.batteryLevel) 20 | Image(systemName: "laptopcomputer") 21 | .resizable() 22 | .scaledToFit() 23 | .padding(14) 24 | .foregroundColor(.white) 25 | if batteryManager.isPluggedIn { 26 | Image( 27 | systemName: batteryManager.isCharging 28 | ? "bolt.fill" : "powerplug.portrait.fill" 29 | ) 30 | .foregroundColor(.white) 31 | .offset(y: -30) 32 | .shadow(color: Color.black, radius: 2, x: 0, y: 0) 33 | .shadow(color: Color.black, radius: 2, x: 0, y: 0) 34 | .transition(.blurReplace) 35 | } 36 | } 37 | .frame(width: 60, height: 60) 38 | .padding(30) 39 | } 40 | 41 | private var batteryColor: Color { 42 | if batteryManager.isCharging { 43 | return .green 44 | } else { 45 | if batteryManager.batteryLevel <= 10 { 46 | return .red 47 | } else if batteryManager.batteryLevel <= 20 { 48 | return .yellow 49 | } else { 50 | return .white 51 | } 52 | } 53 | } 54 | } 55 | 56 | struct BatteryPopup_Previews: PreviewProvider { 57 | static var previews: some View { 58 | BatteryPopup() 59 | .background(Color.black) 60 | .previewLayout(.sizeThatFits) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Barik/Widgets/Battery/BatteryManager.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import IOKit.ps 4 | 5 | /// This class monitors the battery status. 6 | class BatteryManager: ObservableObject { 7 | @Published var batteryLevel: Int = 0 8 | @Published var isCharging: Bool = false 9 | @Published var isPluggedIn: Bool = false 10 | private var timer: Timer? 11 | 12 | init() { 13 | startMonitoring() 14 | } 15 | 16 | deinit { 17 | stopMonitoring() 18 | } 19 | 20 | private func startMonitoring() { 21 | // Update every 1 second. 22 | timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { 23 | [weak self] _ in 24 | self?.updateBatteryStatus() 25 | } 26 | updateBatteryStatus() 27 | } 28 | 29 | private func stopMonitoring() { 30 | timer?.invalidate() 31 | timer = nil 32 | } 33 | 34 | /// This method updates the battery level and charging state. 35 | func updateBatteryStatus() { 36 | guard let snapshot = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(), 37 | let sources = IOPSCopyPowerSourcesList(snapshot)? 38 | .takeRetainedValue() as? [CFTypeRef] 39 | else { 40 | return 41 | } 42 | 43 | for source in sources { 44 | if let description = IOPSGetPowerSourceDescription( 45 | snapshot, source)?.takeUnretainedValue() as? [String: Any], 46 | let currentCapacity = description[ 47 | kIOPSCurrentCapacityKey as String] as? Int, 48 | let maxCapacity = description[kIOPSMaxCapacityKey as String] 49 | as? Int, 50 | let charging = description[kIOPSIsChargingKey as String] 51 | as? Bool, 52 | let powerSourceState = description[ 53 | kIOPSPowerSourceStateKey as String] as? String 54 | { 55 | let isAC = (powerSourceState == kIOPSACPowerValue) 56 | 57 | DispatchQueue.main.async { 58 | self.batteryLevel = (currentCapacity * 100) / maxCapacity 59 | self.isCharging = charging 60 | self.isPluggedIn = isAC 61 | } 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Barik/Views/MenuBarView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct MenuBarView: View { 4 | @ObservedObject var configManager = ConfigManager.shared 5 | 6 | var body: some View { 7 | let theme: ColorScheme? = 8 | switch configManager.config.rootToml.theme { 9 | case "dark": 10 | .dark 11 | case "light": 12 | .light 13 | default: 14 | .none 15 | } 16 | 17 | let items = configManager.config.rootToml.widgets.displayed 18 | 19 | HStack(spacing: 0) { 20 | HStack(spacing: configManager.config.experimental.foreground.spacing) { 21 | ForEach(0.. some View { 41 | let config = ConfigProvider( 42 | config: configManager.resolvedWidgetConfig(for: item)) 43 | 44 | switch item.id { 45 | case "default.spaces": 46 | SpacesWidget().environmentObject(config) 47 | 48 | case "default.network": 49 | NetworkWidget().environmentObject(config) 50 | 51 | case "default.battery": 52 | BatteryWidget().environmentObject(config) 53 | 54 | case "default.time": 55 | TimeWidget(calendarManager: CalendarManager(configProvider: config)) 56 | .environmentObject(config) 57 | 58 | case "default.nowplaying": 59 | NowPlayingWidget() 60 | .environmentObject(config) 61 | 62 | case "spacer": 63 | Spacer().frame(minWidth: 50, maxWidth: .infinity) 64 | 65 | case "divider": 66 | Rectangle() 67 | .fill(Color.active) 68 | .frame(width: 2, height: 15) 69 | .clipShape(Capsule()) 70 | 71 | case "system-banner": 72 | SystemBannerWidget() 73 | 74 | default: 75 | Text("?\(item.id)?").foregroundColor(.red) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Barik/Widgets/Spaces/SpacesViewModel.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Combine 3 | import Foundation 4 | 5 | class SpacesViewModel: ObservableObject { 6 | @Published var spaces: [AnySpace] = [] 7 | private var timer: Timer? 8 | private var provider: AnySpacesProvider? 9 | 10 | init() { 11 | let runningApps = NSWorkspace.shared.runningApplications.compactMap { 12 | $0.localizedName?.lowercased() 13 | } 14 | if runningApps.contains("yabai") { 15 | provider = AnySpacesProvider(YabaiSpacesProvider()) 16 | } else if runningApps.contains("aerospace") { 17 | provider = AnySpacesProvider(AerospaceSpacesProvider()) 18 | } else { 19 | provider = nil 20 | } 21 | startMonitoring() 22 | } 23 | 24 | deinit { 25 | stopMonitoring() 26 | } 27 | 28 | private func startMonitoring() { 29 | timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { 30 | [weak self] _ in 31 | self?.loadSpaces() 32 | } 33 | loadSpaces() 34 | } 35 | 36 | private func stopMonitoring() { 37 | timer?.invalidate() 38 | timer = nil 39 | } 40 | 41 | private func loadSpaces() { 42 | DispatchQueue.global(qos: .background).async { 43 | guard let provider = self.provider, 44 | let spaces = provider.getSpacesWithWindows() 45 | else { 46 | DispatchQueue.main.async { 47 | self.spaces = [] 48 | } 49 | return 50 | } 51 | let sortedSpaces = spaces.sorted { $0.id < $1.id } 52 | DispatchQueue.main.async { 53 | self.spaces = sortedSpaces 54 | } 55 | } 56 | } 57 | 58 | func switchToSpace(_ space: AnySpace, needWindowFocus: Bool = false) { 59 | DispatchQueue.global(qos: .userInitiated).async { 60 | self.provider?.focusSpace( 61 | spaceId: space.id, needWindowFocus: needWindowFocus) 62 | } 63 | } 64 | 65 | func switchToWindow(_ window: AnyWindow) { 66 | DispatchQueue.global(qos: .userInitiated).async { 67 | self.provider?.focusWindow(windowId: String(window.id)) 68 | } 69 | } 70 | } 71 | 72 | class IconCache { 73 | static let shared = IconCache() 74 | private let cache = NSCache() 75 | private init() {} 76 | func icon(for appName: String) -> NSImage? { 77 | if let cached = cache.object(forKey: appName as NSString) { 78 | return cached 79 | } 80 | let workspace = NSWorkspace.shared 81 | if let app = workspace.runningApplications.first(where: { 82 | $0.localizedName == appName 83 | }), 84 | let bundleURL = app.bundleURL 85 | { 86 | let icon = workspace.icon(forFile: bundleURL.path) 87 | cache.setObject(icon, forKey: appName as NSString) 88 | return icon 89 | } 90 | return nil 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Barik.xcodeproj/xcshareddata/xcschemes/Barik.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Barik/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | final class AppDelegate: NSObject, NSApplicationDelegate { 4 | private var backgroundPanel: NSPanel? 5 | private var menuBarPanel: NSPanel? 6 | 7 | func applicationDidFinishLaunching(_ notification: Notification) { 8 | if let error = ConfigManager.shared.initError { 9 | showFatalConfigError(message: error) 10 | return 11 | } 12 | 13 | // Show "What's New" banner if the app version is outdated 14 | if !VersionChecker.isLatestVersion() { 15 | VersionChecker.updateVersionFile() 16 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 17 | NotificationCenter.default.post( 18 | name: Notification.Name("ShowWhatsNewBanner"), object: nil) 19 | } 20 | } 21 | 22 | MenuBarPopup.setup() 23 | setupPanels() 24 | 25 | NotificationCenter.default.addObserver( 26 | self, 27 | selector: #selector(screenParametersDidChange(_:)), 28 | name: NSApplication.didChangeScreenParametersNotification, 29 | object: nil) 30 | } 31 | 32 | @objc private func screenParametersDidChange(_ notification: Notification) { 33 | setupPanels() 34 | } 35 | 36 | /// Configures and displays the background and menu bar panels. 37 | private func setupPanels() { 38 | guard let screenFrame = NSScreen.main?.frame else { return } 39 | setupPanel( 40 | &backgroundPanel, 41 | frame: screenFrame, 42 | level: Int(CGWindowLevelForKey(.desktopWindow)), 43 | hostingRootView: AnyView(BackgroundView())) 44 | setupPanel( 45 | &menuBarPanel, 46 | frame: screenFrame, 47 | level: Int(CGWindowLevelForKey(.backstopMenu)), 48 | hostingRootView: AnyView(MenuBarView())) 49 | } 50 | 51 | /// Sets up an NSPanel with the provided parameters. 52 | private func setupPanel( 53 | _ panel: inout NSPanel?, frame: CGRect, level: Int, 54 | hostingRootView: AnyView 55 | ) { 56 | if let existingPanel = panel { 57 | existingPanel.setFrame(frame, display: true) 58 | return 59 | } 60 | 61 | let newPanel = NSPanel( 62 | contentRect: frame, 63 | styleMask: [.nonactivatingPanel], 64 | backing: .buffered, 65 | defer: false) 66 | newPanel.level = NSWindow.Level(rawValue: level) 67 | newPanel.backgroundColor = .clear 68 | newPanel.hasShadow = false 69 | newPanel.collectionBehavior = [.canJoinAllSpaces] 70 | newPanel.contentView = NSHostingView(rootView: hostingRootView) 71 | newPanel.orderFront(nil) 72 | panel = newPanel 73 | } 74 | 75 | private func showFatalConfigError(message: String) { 76 | let alert = NSAlert() 77 | alert.messageText = "Configuration Error" 78 | alert.informativeText = "\(message)\n\nPlease double check ~/.barik-config.toml and try again." 79 | alert.alertStyle = .critical 80 | alert.addButton(withTitle: "Quit") 81 | 82 | alert.runModal() 83 | NSApplication.shared.terminate(nil) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Barik/Widgets/Network/NetworkWidget.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Widget for the menu, displaying Wi‑Fi and Ethernet icons. 4 | struct NetworkWidget: View { 5 | @StateObject private var viewModel = NetworkStatusViewModel() 6 | @State private var rect: CGRect = .zero 7 | 8 | var body: some View { 9 | HStack(spacing: 15) { 10 | if viewModel.wifiState != .notSupported { 11 | wifiIcon 12 | } 13 | if viewModel.ethernetState != .notSupported { 14 | ethernetIcon 15 | } 16 | } 17 | .background( 18 | GeometryReader { geometry in 19 | Color.clear 20 | .onAppear { rect = geometry.frame(in: .global) } 21 | .onChange(of: geometry.frame(in: .global)) { _, newValue in 22 | rect = newValue 23 | } 24 | } 25 | ) 26 | .contentShape(Rectangle()) 27 | .font(.system(size: 15)) 28 | .experimentalConfiguration(cornerRadius: 15) 29 | .frame(maxHeight: .infinity) 30 | .background(.black.opacity(0.001)) 31 | .onTapGesture { 32 | MenuBarPopup.show(rect: rect, id: "network") { NetworkPopup() } 33 | } 34 | } 35 | 36 | private var wifiIcon: some View { 37 | if viewModel.ssid == "Not connected" { 38 | return Image(systemName: "wifi.slash") 39 | .foregroundColor(.red) 40 | } 41 | switch viewModel.wifiState { 42 | case .connected: 43 | return Image(systemName: "wifi") 44 | .foregroundColor(.foregroundOutside) 45 | case .connecting: 46 | return Image(systemName: "wifi") 47 | .foregroundColor(.yellow) 48 | case .connectedWithoutInternet: 49 | return Image(systemName: "wifi.exclamationmark") 50 | .foregroundColor(.yellow) 51 | case .disconnected: 52 | return Image(systemName: "wifi.slash") 53 | .foregroundColor(.gray) 54 | case .disabled: 55 | return Image(systemName: "wifi.slash") 56 | .foregroundColor(.red) 57 | case .notSupported: 58 | return Image(systemName: "wifi.exclamationmark") 59 | .foregroundColor(.gray) 60 | } 61 | } 62 | 63 | private var ethernetIcon: some View { 64 | switch viewModel.ethernetState { 65 | case .connected: 66 | return Image(systemName: "network") 67 | .foregroundColor(.primary) 68 | case .connectedWithoutInternet: 69 | return Image(systemName: "network") 70 | .foregroundColor(.yellow) 71 | case .connecting: 72 | return Image(systemName: "network.slash") 73 | .foregroundColor(.yellow) 74 | case .disconnected: 75 | return Image(systemName: "network.slash") 76 | .foregroundColor(.red) 77 | case .disabled, .notSupported: 78 | return Image(systemName: "questionmark.circle") 79 | .foregroundColor(.gray) 80 | } 81 | } 82 | } 83 | 84 | struct NetworkWidget_Previews: PreviewProvider { 85 | static var previews: some View { 86 | NetworkWidget() 87 | .frame(width: 200, height: 100) 88 | .background(Color.black) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Barik/Widgets/Spaces/SpacesModels.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | protocol SpaceModel: Identifiable, Equatable, Codable { 4 | associatedtype WindowType: WindowModel 5 | var isFocused: Bool { get set } 6 | var windows: [WindowType] { get set } 7 | } 8 | 9 | protocol WindowModel: Identifiable, Equatable, Codable { 10 | var id: Int { get } 11 | var title: String { get } 12 | var appName: String? { get } 13 | var isFocused: Bool { get } 14 | var appIcon: NSImage? { get set } 15 | } 16 | 17 | protocol SpacesProvider { 18 | associatedtype SpaceType: SpaceModel 19 | func getSpacesWithWindows() -> [SpaceType]? 20 | } 21 | 22 | protocol SwitchableSpacesProvider: SpacesProvider { 23 | func focusSpace(spaceId: String, needWindowFocus: Bool) 24 | func focusWindow(windowId: String) 25 | } 26 | 27 | struct AnyWindow: Identifiable, Equatable { 28 | let id: Int 29 | let title: String 30 | let appName: String? 31 | let isFocused: Bool 32 | let appIcon: NSImage? 33 | 34 | init(_ window: W) { 35 | self.id = window.id 36 | self.title = window.title 37 | self.appName = window.appName 38 | self.isFocused = window.isFocused 39 | self.appIcon = window.appIcon 40 | } 41 | 42 | static func == (lhs: AnyWindow, rhs: AnyWindow) -> Bool { 43 | return lhs.id == rhs.id && lhs.title == rhs.title 44 | && lhs.appName == rhs.appName && lhs.isFocused == rhs.isFocused 45 | } 46 | } 47 | 48 | struct AnySpace: Identifiable, Equatable { 49 | let id: String 50 | let isFocused: Bool 51 | let windows: [AnyWindow] 52 | 53 | init(_ space: S) { 54 | if let aero = space as? AeroSpace { 55 | self.id = aero.workspace 56 | } else if let yabai = space as? YabaiSpace { 57 | self.id = String(yabai.id) 58 | } else { 59 | self.id = "0" 60 | } 61 | self.isFocused = space.isFocused 62 | self.windows = space.windows.map { AnyWindow($0) } 63 | } 64 | 65 | static func == (lhs: AnySpace, rhs: AnySpace) -> Bool { 66 | return lhs.id == rhs.id && lhs.isFocused == rhs.isFocused 67 | && lhs.windows == rhs.windows 68 | } 69 | } 70 | 71 | class AnySpacesProvider { 72 | private let _getSpacesWithWindows: () -> [AnySpace]? 73 | private let _focusSpace: ((String, Bool) -> Void)? 74 | private let _focusWindow: ((String) -> Void)? 75 | 76 | init(_ provider: P) { 77 | _getSpacesWithWindows = { 78 | provider.getSpacesWithWindows()?.map { AnySpace($0) } 79 | } 80 | if let switchable = provider as? any SwitchableSpacesProvider { 81 | _focusSpace = { spaceId, needWindowFocus in 82 | switchable.focusSpace( 83 | spaceId: spaceId, needWindowFocus: needWindowFocus) 84 | } 85 | _focusWindow = { windowId in 86 | switchable.focusWindow(windowId: windowId) 87 | } 88 | } else { 89 | _focusSpace = nil 90 | _focusWindow = nil 91 | } 92 | } 93 | 94 | func getSpacesWithWindows() -> [AnySpace]? { 95 | _getSpacesWithWindows() 96 | } 97 | 98 | func focusSpace(spaceId: String, needWindowFocus: Bool) { 99 | _focusSpace?(spaceId, needWindowFocus) 100 | } 101 | 102 | func focusWindow(windowId: String) { 103 | _focusWindow?(windowId) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.5.1 4 | 5 | > This release was supported by **ALinuxPerson** _(help with the appearance configuration, 1 issue)_, **bake** _(1 issue)_ and **Oery** _(1 issue)_ 6 | 7 | - Added yabai.path and aerospace.path config properties 8 | - Fixed popup design 9 | - Fixed Apple Music integration in Now Playing widget 10 | - Added experimental appearance configuration: 11 | 12 | ```toml 13 | ### EXPERIMENTAL, WILL BE REPLACED BY STYLE API IN THE FUTURE 14 | [experimental.background] # settings for blurred background 15 | displayed = true # display blurred background 16 | height = "default" # available values: default (stretch to full screen), menu-bar (height like system menu bar), (e.g., 40, 33.5) 17 | blur = 3 # background type: from 1 to 6 for blur intensity, 7 for black color 18 | 19 | [experimental.foreground] # settings for menu bar 20 | height = "default" # available values: default (55.0), menu-bar (height like system menu bar), (e.g., 40, 33.5) 21 | horizontal-padding = 25 # padding on the left and right corners 22 | spacing = 15 # spacing between widgets 23 | 24 | [experimental.foreground.widgets-background] # settings for widgets background 25 | displayed = false # wrap widgets in their own background 26 | blur = 3 # background type: from 1 to 6 for blur intensity 27 | ``` 28 | 29 | ## 0.5.0 30 | 31 | ![Header](https://github.com/user-attachments/assets/182e7930-feb8-4e46-a691-7a54028d21a1) 32 | 33 | > This release was supported by **AltaCursor** _([2 cups of coffee](https://ko-fi.com/mocki_toki), 3 issues)_ and **farhanmansurii** _(help with Spotify player)_ 34 | 35 | **Popup** — a new feature that allows opening an extended and interactive view of a widget (e.g., the battery charge indicator widget) by clicking on it. Currently, popups are available for the following **barik** widgets: Now Playing, Network, Battery, and Time (Calendar). 36 | 37 | We want to make **barik** more useful, powerful, and convenient, so feel free to share your ideas in [Issues](https://github.com/mocki-toki/barik/issues/new), and contribute your work through [Pull Requests](https://github.com/mocki-toki/barik/pulls). We’ll definitely review everything! 38 | 39 | Other changes: 40 | 41 | - Added a new **Now Playing** widget — allowing control of music in desktop applications like Apple Music and Spotify. We welcome your suggestions for supporting other music services: https://github.com/mocki-toki/barik/issues/new 42 | - More customization: Space key and title visibility, as well as a list of applications that will always be displayed by application name. 43 | - Added the ability to switch windows and spaces by mouse click. 44 | - Fixed the `calendar.show-events` config property functionality. 45 | - Fixed screen resolution readjust 46 | - Added auto update functionality, what's new popup 47 | 48 | ## 0.4.1 49 | 50 | > This release was supported by **Oery** _(1 issue)_ 51 | 52 | - Fixed a display issue with the Notch. 53 | 54 | ## 0.4.0 55 | 56 | > This release was supported by **AltaCursor** _(2 issues)_ 57 | 58 | - Added support for the `~/.barik-config.toml` configuration file. 59 | - Added AeroSpace support 🎉. 60 | - Fixed 24-hour time format. 61 | - Fixed a desktop icon display issue. 62 | 63 | ## 0.3.0 64 | 65 | - Added a network widget (Wi-Fi/Ethernet status). 66 | - Fixed an incorrect color in the events indicator. 67 | - Prioritized displaying events that are not all-day events. 68 | - Added a maximum length for the focused window title. 69 | - Updated the application icon. 70 | - Added power plug battery status. 71 | 72 | ## 0.2.0 73 | 74 | - Added support for a light theme. 75 | - Added the application icon. 76 | 77 | ## 0.1.0 78 | 79 | - Initial release. 80 | -------------------------------------------------------------------------------- /Barik/Widgets/Spaces/Yabai/YabaiProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class YabaiSpacesProvider: SpacesProvider, SwitchableSpacesProvider { 4 | typealias SpaceType = YabaiSpace 5 | let executablePath = ConfigManager.shared.config.yabai.path 6 | 7 | private func runYabaiCommand(arguments: [String]) -> Data? { 8 | let process = Process() 9 | process.executableURL = URL(fileURLWithPath: executablePath) 10 | process.arguments = arguments 11 | let pipe = Pipe() 12 | process.standardOutput = pipe 13 | do { 14 | try process.run() 15 | } catch { 16 | print("Yabai error: \(error)") 17 | return nil 18 | } 19 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 20 | process.waitUntilExit() 21 | return data 22 | } 23 | 24 | private func fetchSpaces() -> [YabaiSpace]? { 25 | guard 26 | let data = runYabaiCommand(arguments: ["-m", "query", "--spaces"]) 27 | else { 28 | return nil 29 | } 30 | let decoder = JSONDecoder() 31 | do { 32 | let spaces = try decoder.decode([YabaiSpace].self, from: data) 33 | return spaces 34 | } catch { 35 | print("Decode yabai spaces error: \(error)") 36 | return nil 37 | } 38 | } 39 | 40 | private func fetchWindows() -> [YabaiWindow]? { 41 | guard 42 | let data = runYabaiCommand(arguments: ["-m", "query", "--windows"]) 43 | else { 44 | return nil 45 | } 46 | let decoder = JSONDecoder() 47 | do { 48 | let windows = try decoder.decode([YabaiWindow].self, from: data) 49 | return windows 50 | } catch { 51 | print("Decode yabai windows error: \(error)") 52 | return nil 53 | } 54 | } 55 | 56 | func getSpacesWithWindows() -> [YabaiSpace]? { 57 | guard let spaces = fetchSpaces(), let windows = fetchWindows() else { 58 | return nil 59 | } 60 | let filteredWindows = windows.filter { 61 | !($0.isHidden || $0.isFloating || $0.isSticky) 62 | } 63 | var spaceDict = Dictionary( 64 | uniqueKeysWithValues: spaces.map { ($0.id, $0) }) 65 | for window in filteredWindows { 66 | if var space = spaceDict[window.spaceId] { 67 | space.windows.append(window) 68 | spaceDict[window.spaceId] = space 69 | } 70 | } 71 | var resultSpaces = Array(spaceDict.values) 72 | for i in 0.. String { 70 | let formatter = DateFormatter() 71 | formatter.setLocalizedDateFormatFromTemplate(pattern) 72 | 73 | if let timeZone = timeZone, 74 | let tz = TimeZone(identifier: timeZone) 75 | { 76 | formatter.timeZone = tz 77 | } else { 78 | formatter.timeZone = TimeZone.current 79 | } 80 | 81 | return formatter.string(from: time) 82 | } 83 | 84 | // Create text for the calendar event. 85 | private func eventText(for event: EKEvent) -> String { 86 | var text = event.title ?? "" 87 | if !event.isAllDay { 88 | text += " (" 89 | text += formattedTime( 90 | pattern: calendarFormat, from: event.startDate) 91 | text += ")" 92 | } 93 | return text 94 | } 95 | } 96 | 97 | struct TimeWidget_Previews: PreviewProvider { 98 | static var previews: some View { 99 | let provider = ConfigProvider(config: ConfigData()) 100 | let manager = CalendarManager(configProvider: provider) 101 | 102 | ZStack { 103 | TimeWidget(calendarManager: manager) 104 | .environmentObject(provider) 105 | }.frame(width: 500, height: 100) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Barik/Widgets/SystemBanner/ChangelogPopup.swift: -------------------------------------------------------------------------------- 1 | import MarkdownUI 2 | import SwiftUI 3 | 4 | struct ChangelogPopup: View { 5 | @State private var changelogText: String = "Loading..." 6 | 7 | private var bundleVersion: String { 8 | Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String 9 | ?? "0.0.0" 10 | } 11 | 12 | var body: some View { 13 | VStack(spacing: 0) { 14 | Text("v\(bundleVersion) Changelog") 15 | .padding(15) 16 | .font(.system(size: 14)) 17 | .fontWeight(.medium) 18 | Rectangle().fill(.white).opacity(0.2).frame(height: 0.5) 19 | ScrollView { 20 | Markdown(changelogText) 21 | .padding(.horizontal, 5) 22 | .padding(.vertical, 20) 23 | .padding(.trailing, 15) 24 | .markdownTheme(.barik) 25 | .foregroundStyle(.white) 26 | }.offset(x: 15) 27 | .markdownImageProvider(WebImageProvider()) 28 | } 29 | .scrollIndicators(.hidden) 30 | .frame(width: 500) 31 | .frame(maxHeight: 600) 32 | .task { 33 | await loadChangelog() 34 | } 35 | } 36 | 37 | // Asynchronously loads the changelog from the remote URL 38 | private func loadChangelog() async { 39 | guard 40 | let url = URL( 41 | string: 42 | "https://raw.githubusercontent.com/mocki-toki/barik/main/CHANGELOG.md" 43 | ) 44 | else { 45 | return 46 | } 47 | 48 | do { 49 | let (data, _) = try await URLSession.shared.data(from: url) 50 | guard let fullChangelog = String(data: data, encoding: .utf8) else { 51 | updateChangelogText("Failed to load CHANGELOG.") 52 | return 53 | } 54 | 55 | let extractedSection = extractSection( 56 | forVersion: bundleVersion, from: fullChangelog) 57 | let displayText = 58 | extractedSection.isEmpty 59 | ? "Changelog for v\(bundleVersion) not found" 60 | : extractedSection 61 | 62 | updateChangelogText(displayText) 63 | } catch { 64 | updateChangelogText("Failed to load CHANGELOG.") 65 | } 66 | } 67 | 68 | // Updates the changelog text on the main thread 69 | private func updateChangelogText(_ text: String) { 70 | DispatchQueue.main.async { 71 | self.changelogText = text 72 | } 73 | } 74 | 75 | // Extracts the section corresponding to the specified version from the changelog 76 | private func extractSection( 77 | forVersion version: String, from changelog: String 78 | ) -> String { 79 | let lines = changelog.components(separatedBy: .newlines) 80 | 81 | guard 82 | let versionIndex = lines.firstIndex(where: { 83 | $0.contains("## \(version)") 84 | }) 85 | else { 86 | return "" 87 | } 88 | 89 | var sectionLines: [String] = [] 90 | for i in versionIndex.." with a markdown header if encountered 103 | if line == "
" { 104 | sectionLines.append("### ") 105 | } else { 106 | sectionLines.append(line) 107 | } 108 | } 109 | 110 | return sectionLines.joined(separator: "\n") 111 | } 112 | } 113 | 114 | // MARK: - Preview 115 | 116 | struct ChangelogPopup_Previews: PreviewProvider { 117 | static var previews: some View { 118 | ChangelogPopup() 119 | .background(Color.black) 120 | .previewLayout(.sizeThatFits) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /example/.yabairc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ## Global settings 4 | yabai -m config split_ratio 0.5 5 | yabai -m config window_placement first_child 6 | yabai -m config window_shadow off 7 | 8 | ## General space settings 9 | yabai -m config layout bsp 10 | yabai -m config top_padding 55 11 | yabai -m config right_padding 10 12 | yabai -m config bottom_padding 10 13 | yabai -m config left_padding 10 14 | yabai -m config window_gap 5 15 | 16 | yabai -m config --space 2 layout stack 17 | yabai -m config --space 6 layout stack 18 | yabai -m config --space 8 layout stack 19 | 20 | ## Space - Browser 21 | yabai -m rule --add space=1 label="Arc" app="^Arc$" 22 | yabai -m rule --add space=1 label="Safari" app="^Safari$" 23 | 24 | ## Space - Development 25 | yabai -m rule --add space=2 label="Cursor" app="^Cursor$" 26 | yabai -m rule --add space=2 label="Visual Studio Code" app="^Visual Studio Code$" 27 | yabai -m rule --add space=2 label="Xcode" app="^Xcode$" 28 | yabai -m rule --add space=2 label="Simulator" app="^Simulator$" manage=off 29 | yabai -m rule --add space=2 label="OrbStack" app="^OrbStack$" 30 | yabai -m rule --add space=2 label="Lens" app="^Lens$" 31 | yabai -m rule --add space=2 label="Chrome" app="^Google Chrome$" 32 | yabai -m rule --add space=2 label="GitKraken" app="^GitKraken$" 33 | yabai -m rule --add space=2 label="Postman" app="^Postman$" 34 | yabai -m rule --add space=2 label="Proxyman" app="^Proxyman$" 35 | yabai -m rule --add space=2 label="Android Emulator" app="^Android Emulator$" manage=off 36 | 37 | ## Space - Terminal 38 | yabai -m rule --add space=3 label="Terminal" app="^Terminal$" 39 | yabai -m rule --add space=3 label="Warp" app="^Warp$" 40 | 41 | ## Space - Communication 42 | yabai -m rule --add space=4 label="Telegram" app="^Telegram$" 43 | yabai -m rule --add space=4 label="Discord" app="^Discord$" 44 | 45 | ## Space - Finder 46 | yabai -m rule --add space=5 label="Finder" app="^Finder$" 47 | 48 | ## Space - Events 49 | yabai -m rule --add space=6 label="Calendar" app="^Calendar$" 50 | yabai -m rule --add space=6 label="Mail" app="^Mail$" 51 | 52 | ## Space - Design 53 | yabai -m rule --add space=7 label="Figma" app="^Figma$" 54 | 55 | ## Space - Productivity 56 | yabai -m rule --add space=8 label="Obsidian" app="^Obsidian$" 57 | yabai -m rule --add space=8 label="Todoist" app="^Todoist$" 58 | 59 | ## Space - Media 60 | yabai -m rule --add space=9 label="Spotify" app="^Spotify$" 61 | 62 | 63 | #### All Screens 64 | #################################################################################################### 65 | 66 | ## Sticky windows 67 | yabai -m rule --add manage=off label="System Settings" app="System Settings" sticky="on" 68 | yabai -m rule --add manage=off label="Activity Monitor" app="Activity Monitor" sticky="on" 69 | yabai -m rule --add manage=off label="Toggl Track" app="Toggl Track" sticky="on" 70 | yabai -m rule --add manage=off label="AdGuard" app="AdGuard" sticky="on" 71 | yabai -m rule --add manage=off label="App Store" app="App Store" sticky="on" 72 | yabai -m rule --add manage=off app="Raycast" sticky="on" 73 | yabai -m rule --add manage=off label="Tunnelblick" app="Tunnelblick" sticky="on" 74 | yabai -m rule --add manage=off label="Calculator" app="Calculator" sticky="on" 75 | yabai -m rule --add manage=off label="Console" app="Console" sticky="on" 76 | yabai -m rule --add manage=off label="Contexts" app="Contexts" sticky="on" 77 | yabai -m rule --add manage=off label="Dictionary" app="Dictionary" sticky="on" 78 | yabai -m rule --add manage=off label="Preview" app="Preview" sticky="on" 79 | yabai -m rule --add manage=off label="Stats" app="Stats" sticky="on" 80 | yabai -m rule --add manage=off label="System Information" app="System Information" sticky="on" 81 | yabai -m rule --add manage=off label="VoiceOver Utility" app="VoiceOver Utility" sticky="on" 82 | yabai -m rule --add manage=off label="Shottr" app="Shottr" sticky="on" 83 | yabai -m rule --add manage=off label="iPhone Mirroring" app="iPhone Mirroring" sticky="on" 84 | yabai -m rule --add manage=off label="Bartender 5" app="Bartender 5" sticky="on" 85 | yabai -m rule --add manage=off label="ChatGPT" app="ChatGPT" sticky="on" 86 | 87 | ## Unmanaged windows 88 | yabai -m rule --add manage=off title="(Copy|Bin|About This Mac|Info)" 89 | yabai -m rule --add manage=off title="(Settings|Preferences)" 90 | yabai -m rule --add manage=off title="^(General|(Tab|Password|Website|Extension)s|AutoFill|Se(arch|curity)|Privacy|Advanced)$" 91 | yabai -m rule --add manage=off title="^Exports" 92 | yabai -m rule --add manage=off title="^Opening" 93 | 94 | #### Events 95 | #################################################################################################### 96 | 97 | yabai -m signal --add event=dock_did_restart action="sudo yabai --load-sa" 98 | sudo yabai --load-sa -------------------------------------------------------------------------------- /Barik/MenuBarPopup/MenuBarPopup.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | private var panel: NSPanel? 4 | 5 | class HidingPanel: NSPanel, NSWindowDelegate { 6 | var hideTimer: Timer? 7 | 8 | override var canBecomeKey: Bool { 9 | return true 10 | } 11 | 12 | override init( 13 | contentRect: NSRect, 14 | styleMask style: NSWindow.StyleMask, 15 | backing bufferingType: NSWindow.BackingStoreType, 16 | defer flag: Bool 17 | ) { 18 | super.init( 19 | contentRect: contentRect, styleMask: style, backing: bufferingType, 20 | defer: flag) 21 | self.delegate = self 22 | } 23 | 24 | func windowDidResignKey(_ notification: Notification) { 25 | NotificationCenter.default.post(name: .willHideWindow, object: nil) 26 | hideTimer = Timer.scheduledTimer( 27 | withTimeInterval: TimeInterval( 28 | Constants.menuBarPopupAnimationDurationInMilliseconds) / 1000.0, 29 | repeats: false 30 | ) { [weak self] _ in 31 | self?.orderOut(nil) 32 | } 33 | } 34 | } 35 | 36 | class MenuBarPopup { 37 | static var lastContentIdentifier: String? = nil 38 | 39 | static func show( 40 | rect: CGRect, id: String, @ViewBuilder content: @escaping () -> Content 41 | ) { 42 | guard let panel = panel else { return } 43 | 44 | if panel.isKeyWindow, lastContentIdentifier == id { 45 | NotificationCenter.default.post(name: .willHideWindow, object: nil) 46 | let duration = 47 | Double(Constants.menuBarPopupAnimationDurationInMilliseconds) 48 | / 1000.0 49 | DispatchQueue.main.asyncAfter(deadline: .now() + duration) { 50 | panel.orderOut(nil) 51 | lastContentIdentifier = nil 52 | } 53 | return 54 | } 55 | 56 | let isContentChange = 57 | panel.isKeyWindow 58 | && (lastContentIdentifier != nil && lastContentIdentifier != id) 59 | lastContentIdentifier = id 60 | 61 | if let hidingPanel = panel as? HidingPanel { 62 | hidingPanel.hideTimer?.invalidate() 63 | hidingPanel.hideTimer = nil 64 | } 65 | 66 | if panel.isKeyWindow { 67 | NotificationCenter.default.post( 68 | name: .willChangeContent, object: nil) 69 | let baseDuration = 70 | Double(Constants.menuBarPopupAnimationDurationInMilliseconds) 71 | / 1000.0 72 | let duration = isContentChange ? baseDuration / 2 : baseDuration 73 | DispatchQueue.main.asyncAfter(deadline: .now() + duration) { 74 | panel.contentView = NSHostingView( 75 | rootView: 76 | ZStack { 77 | MenuBarPopupView { 78 | content() 79 | } 80 | .position(x: rect.midX) 81 | } 82 | .frame(maxWidth: .infinity, maxHeight: .infinity) 83 | .id(UUID()) 84 | ) 85 | panel.makeKeyAndOrderFront(nil) 86 | DispatchQueue.main.async { 87 | NotificationCenter.default.post( 88 | name: .willShowWindow, object: nil) 89 | } 90 | } 91 | } else { 92 | panel.contentView = NSHostingView( 93 | rootView: 94 | ZStack { 95 | MenuBarPopupView { 96 | content() 97 | } 98 | .position(x: rect.midX) 99 | } 100 | .frame(maxWidth: .infinity, maxHeight: .infinity) 101 | ) 102 | panel.makeKeyAndOrderFront(nil) 103 | DispatchQueue.main.async { 104 | NotificationCenter.default.post( 105 | name: .willShowWindow, object: nil) 106 | } 107 | } 108 | } 109 | 110 | static func setup() { 111 | guard let screen = NSScreen.main?.visibleFrame else { return } 112 | let panelFrame = NSRect( 113 | x: 0, 114 | y: 0, 115 | width: screen.size.width, 116 | height: screen.size.height 117 | ) 118 | 119 | let newPanel = HidingPanel( 120 | contentRect: panelFrame, 121 | styleMask: [.nonactivatingPanel], 122 | backing: .buffered, 123 | defer: false 124 | ) 125 | 126 | newPanel.level = NSWindow.Level( 127 | rawValue: Int(CGWindowLevelForKey(.floatingWindow))) 128 | newPanel.backgroundColor = .clear 129 | newPanel.hasShadow = false 130 | newPanel.collectionBehavior = [.canJoinAllSpaces] 131 | 132 | panel = newPanel 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /Barik/Widgets/Spaces/Aerospace/AerospaceProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class AerospaceSpacesProvider: SpacesProvider, SwitchableSpacesProvider { 4 | typealias SpaceType = AeroSpace 5 | let executablePath = ConfigManager.shared.config.aerospace.path 6 | 7 | func getSpacesWithWindows() -> [AeroSpace]? { 8 | guard var spaces = fetchSpaces(), let windows = fetchWindows() else { 9 | return nil 10 | } 11 | if let focusedSpace = fetchFocusedSpace() { 12 | for i in 0.. Data? { 52 | let process = Process() 53 | process.executableURL = URL(fileURLWithPath: executablePath) 54 | process.arguments = arguments 55 | let pipe = Pipe() 56 | process.standardOutput = pipe 57 | do { 58 | try process.run() 59 | } catch { 60 | print("Aerospace error: \(error)") 61 | return nil 62 | } 63 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 64 | process.waitUntilExit() 65 | return data 66 | } 67 | 68 | private func fetchSpaces() -> [AeroSpace]? { 69 | guard 70 | let data = runAerospaceCommand(arguments: [ 71 | "list-workspaces", "--all", "--json", 72 | ]) 73 | else { 74 | return nil 75 | } 76 | let decoder = JSONDecoder() 77 | do { 78 | return try decoder.decode([AeroSpace].self, from: data) 79 | } catch { 80 | print("Decode spaces error: \(error)") 81 | return nil 82 | } 83 | } 84 | 85 | private func fetchWindows() -> [AeroWindow]? { 86 | guard 87 | let data = runAerospaceCommand(arguments: [ 88 | "list-windows", "--all", "--json", "--format", 89 | "%{window-id} %{app-name} %{window-title} %{workspace}", 90 | ]) 91 | else { 92 | return nil 93 | } 94 | let decoder = JSONDecoder() 95 | do { 96 | return try decoder.decode([AeroWindow].self, from: data) 97 | } catch { 98 | print("Decode windows error: \(error)") 99 | return nil 100 | } 101 | } 102 | 103 | private func fetchFocusedSpace() -> AeroSpace? { 104 | guard 105 | let data = runAerospaceCommand(arguments: [ 106 | "list-workspaces", "--focused", "--json", 107 | ]) 108 | else { 109 | return nil 110 | } 111 | let decoder = JSONDecoder() 112 | do { 113 | return try decoder.decode([AeroSpace].self, from: data).first 114 | } catch { 115 | print("Decode focused space error: \(error)") 116 | return nil 117 | } 118 | } 119 | 120 | private func fetchFocusedWindow() -> AeroWindow? { 121 | guard 122 | let data = runAerospaceCommand(arguments: [ 123 | "list-windows", "--focused", "--json", 124 | ]) 125 | else { 126 | return nil 127 | } 128 | let decoder = JSONDecoder() 129 | do { 130 | return try decoder.decode([AeroWindow].self, from: data).first 131 | } catch { 132 | print("Decode focused window error: \(error)") 133 | return nil 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Barik/Widgets/Network/NetworkPopup.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Window displaying detailed network status information. 4 | struct NetworkPopup: View { 5 | @StateObject private var viewModel = NetworkStatusViewModel() 6 | 7 | var body: some View { 8 | VStack(alignment: .leading, spacing: 16) { 9 | if viewModel.wifiState != .notSupported { 10 | HStack(spacing: 8) { 11 | wifiIcon 12 | Text(viewModel.ssid) 13 | .foregroundColor(.white) 14 | .font(.headline) 15 | } 16 | 17 | if viewModel.ssid != "Not connected" 18 | && viewModel.ssid != "No interface" 19 | { 20 | VStack(alignment: .leading, spacing: 4) { 21 | Text( 22 | "Signal strength: \(viewModel.wifiSignalStrength.rawValue)" 23 | ) 24 | Text("RSSI: \(viewModel.rssi)") 25 | Text("Noise: \(viewModel.noise)") 26 | Text("Channel: \(viewModel.channel)") 27 | } 28 | .font(.subheadline) 29 | } 30 | } 31 | 32 | // Ethernet section 33 | if viewModel.ethernetState != .notSupported { 34 | HStack(spacing: 8) { 35 | ethernetIcon 36 | Text("Ethernet: \(viewModel.ethernetState.rawValue)") 37 | .foregroundColor(.white) 38 | .font(.headline) 39 | } 40 | } 41 | } 42 | .padding(25) 43 | .background(Color.black) 44 | } 45 | 46 | /// Chooses the Wi‑Fi icon based on the status and connection availability. 47 | private var wifiIcon: some View { 48 | if viewModel.ssid == "Not connected" { 49 | return Image(systemName: "wifi.slash") 50 | .padding(8) 51 | .background(Color.red.opacity(0.8)) 52 | .clipShape(Circle()) 53 | .foregroundStyle(.white) 54 | } 55 | switch viewModel.wifiState { 56 | case .connected: 57 | return Image(systemName: "wifi") 58 | .padding(8) 59 | .background(Color.blue.opacity(0.8)) 60 | .clipShape(Circle()) 61 | .foregroundStyle(.white) 62 | case .connecting: 63 | return Image(systemName: "wifi") 64 | .padding(8) 65 | .background(Color.yellow.opacity(0.8)) 66 | .clipShape(Circle()) 67 | .foregroundStyle(.white) 68 | case .connectedWithoutInternet: 69 | return Image(systemName: "wifi.exclamationmark") 70 | .padding(8) 71 | .background(Color.yellow.opacity(0.8)) 72 | .clipShape(Circle()) 73 | .foregroundStyle(.white) 74 | case .disconnected: 75 | return Image(systemName: "wifi.slash") 76 | .padding(8) 77 | .background(Color.gray.opacity(0.8)) 78 | .clipShape(Circle()) 79 | .foregroundStyle(.white) 80 | case .disabled: 81 | return Image(systemName: "wifi.slash") 82 | .padding(8) 83 | .background(Color.red.opacity(0.8)) 84 | .clipShape(Circle()) 85 | .foregroundStyle(.white) 86 | case .notSupported: 87 | return Image(systemName: "wifi.exclamationmark") 88 | .padding(8) 89 | .background(Color.gray.opacity(0.8)) 90 | .clipShape(Circle()) 91 | .foregroundStyle(.white) 92 | } 93 | } 94 | 95 | private var ethernetIcon: some View { 96 | switch viewModel.ethernetState { 97 | case .connected: 98 | return Image(systemName: "network") 99 | .padding(8) 100 | .background(Color.blue.opacity(0.8)) 101 | .clipShape(Circle()) 102 | case .connectedWithoutInternet: 103 | return Image(systemName: "network") 104 | .padding(8) 105 | .background(Color.yellow.opacity(0.8)) 106 | .clipShape(Circle()) 107 | case .connecting: 108 | return Image(systemName: "network.slash") 109 | .padding(8) 110 | .background(Color.yellow.opacity(0.8)) 111 | .clipShape(Circle()) 112 | case .disconnected: 113 | return Image(systemName: "network.slash") 114 | .padding(8) 115 | .background(Color.gray.opacity(0.8)) 116 | .clipShape(Circle()) 117 | case .disabled: 118 | return Image(systemName: "network.slash") 119 | .padding(8) 120 | .background(Color.red.opacity(0.8)) 121 | .clipShape(Circle()) 122 | case .notSupported: 123 | return Image(systemName: "questionmark.circle") 124 | .padding(8) 125 | .background(Color.gray.opacity(0.8)) 126 | .clipShape(Circle()) 127 | } 128 | } 129 | } 130 | 131 | struct NetworkPopup_Previews: PreviewProvider { 132 | static var previews: some View { 133 | NetworkPopup() 134 | .previewLayout(.sizeThatFits) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Barik/Widgets/SystemBanner/MarkdownTheme.swift: -------------------------------------------------------------------------------- 1 | import MarkdownUI 2 | import SwiftUI 3 | 4 | extension Theme { 5 | static let barik = Theme() 6 | .text { 7 | ForegroundColor(.white.opacity(0.8)) 8 | BackgroundColor(.clear) 9 | FontSize(14) 10 | } 11 | .code { 12 | FontFamilyVariant(.monospaced) 13 | FontSize(.em(0.85)) 14 | BackgroundColor(.white.opacity(0.1)) 15 | } 16 | .codeBlock { configuration in 17 | ScrollView(.horizontal) { 18 | configuration.label 19 | .fixedSize(horizontal: false, vertical: true) 20 | .relativeLineSpacing(.em(0.225)) 21 | .markdownTextStyle { 22 | FontFamilyVariant(.monospaced) 23 | FontSize(.em(0.85)) 24 | } 25 | .padding(16) 26 | } 27 | .background(.white.opacity(0.1)) 28 | .clipShape(RoundedRectangle(cornerRadius: 6)) 29 | .markdownMargin(top: 0, bottom: 16) 30 | } 31 | .strong { 32 | FontWeight(.semibold) 33 | ForegroundColor(.white) 34 | } 35 | .link { 36 | ForegroundColor(.blue) 37 | } 38 | .heading2 { configuration in 39 | VStack(alignment: .leading, spacing: 0) { 40 | configuration.label 41 | .relativePadding(.bottom, length: .em(0.3)) 42 | .relativeLineSpacing(.em(0.125)) 43 | .markdownMargin(top: 24, bottom: 16) 44 | .markdownTextStyle { 45 | FontWeight(.semibold) 46 | FontSize(.em(1.5)) 47 | } 48 | } 49 | } 50 | .heading3 { configuration in 51 | configuration.label 52 | .relativeLineSpacing(.em(0.125)) 53 | .markdownMargin(top: 24, bottom: 16) 54 | .markdownTextStyle { 55 | FontWeight(.semibold) 56 | FontSize(.em(1.25)) 57 | } 58 | } 59 | .heading4 { configuration in 60 | configuration.label 61 | .relativeLineSpacing(.em(0.125)) 62 | .markdownMargin(top: 24, bottom: 16) 63 | .markdownTextStyle { 64 | FontWeight(.semibold) 65 | } 66 | } 67 | .heading5 { configuration in 68 | configuration.label 69 | .relativeLineSpacing(.em(0.125)) 70 | .markdownMargin(top: 24, bottom: 16) 71 | .markdownTextStyle { 72 | FontWeight(.semibold) 73 | FontSize(.em(0.875)) 74 | } 75 | } 76 | .heading6 { configuration in 77 | configuration.label 78 | .relativeLineSpacing(.em(0.125)) 79 | .markdownMargin(top: 24, bottom: 16) 80 | .markdownTextStyle { 81 | FontWeight(.semibold) 82 | FontSize(.em(0.85)) 83 | ForegroundColor(.gray) 84 | } 85 | } 86 | .blockquote { configuration in 87 | HStack(spacing: 0) { 88 | configuration.label 89 | .markdownTextStyle { 90 | ForegroundColor(.gray) 91 | FontSize(12) 92 | }.markdownMargin(bottom: 20) 93 | } 94 | .fixedSize(horizontal: false, vertical: true) 95 | } 96 | .listItem { configuration in 97 | configuration.label 98 | .markdownMargin(top: .em(0.25)) 99 | } 100 | .image { configuration in 101 | configuration.label.clipShape( 102 | RoundedRectangle(cornerRadius: 10, style: .continuous)) 103 | } 104 | .paragraph { configuration in 105 | configuration.label 106 | .fixedSize(horizontal: false, vertical: true) 107 | .relativeLineSpacing(.em(0.25)) 108 | .markdownMargin(top: 0, bottom: 16) 109 | } 110 | } 111 | 112 | struct WebImageProvider: ImageProvider { 113 | func makeImage(url: URL?) -> some View { 114 | ResizeToFit { 115 | FadeAnimatedCachedImage(url: url) { image in 116 | image 117 | .resizable() 118 | .interpolation(.high) 119 | .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) 120 | } 121 | } 122 | } 123 | } 124 | 125 | /// A layout that resizes its content to fit the container **only** if the content width is greater than the container width. 126 | struct ResizeToFit: Layout { 127 | func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { 128 | guard let view = subviews.first else { 129 | return .zero 130 | } 131 | 132 | var size = view.sizeThatFits(.unspecified) 133 | 134 | if let width = proposal.width, size.width > width { 135 | let aspectRatio = size.width / size.height 136 | size.width = width 137 | size.height = width / aspectRatio 138 | } 139 | return size 140 | } 141 | 142 | func placeSubviews( 143 | in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout () 144 | ) { 145 | guard let view = subviews.first else { return } 146 | view.place(at: bounds.origin, proposal: .init(bounds.size)) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Barik/Widgets/Time+Calendar/CalendarManager.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import EventKit 3 | import Foundation 4 | 5 | class CalendarManager: ObservableObject { 6 | let configProvider: ConfigProvider 7 | var config: ConfigData? { 8 | configProvider.config["calendar"]?.dictionaryValue 9 | } 10 | var allowList: [String] { 11 | Array( 12 | (config?["allow-list"]?.arrayValue?.map { $0.stringValue ?? "" } 13 | .drop(while: { $0 == "" })) ?? []) 14 | } 15 | var denyList: [String] { 16 | Array( 17 | (config?["deny-list"]?.arrayValue?.map { $0.stringValue ?? "" } 18 | .drop(while: { $0 == "" })) ?? []) 19 | } 20 | 21 | @Published var nextEvent: EKEvent? 22 | @Published var todaysEvents: [EKEvent] = [] 23 | @Published var tomorrowsEvents: [EKEvent] = [] 24 | private let eventStore = EKEventStore() 25 | private var timer: Timer? 26 | 27 | init(configProvider: ConfigProvider) { 28 | self.configProvider = configProvider 29 | requestAccess() 30 | startMonitoring() 31 | } 32 | 33 | deinit { 34 | stopMonitoring() 35 | } 36 | 37 | private func startMonitoring() { 38 | timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { 39 | [weak self] _ in 40 | self?.fetchTodaysEvents() 41 | self?.fetchTomorrowsEvents() 42 | self?.fetchNextEvent() 43 | } 44 | fetchTodaysEvents() 45 | fetchTomorrowsEvents() 46 | fetchNextEvent() 47 | } 48 | 49 | private func stopMonitoring() { 50 | timer?.invalidate() 51 | timer = nil 52 | } 53 | 54 | private func requestAccess() { 55 | eventStore.requestFullAccessToEvents { [weak self] granted, error in 56 | if granted && error == nil { 57 | self?.fetchTodaysEvents() 58 | self?.fetchTomorrowsEvents() 59 | self?.fetchNextEvent() 60 | } else { 61 | print( 62 | "Calendar access not granted: \(String(describing: error))") 63 | } 64 | } 65 | } 66 | 67 | private func filterEvents(_ events: [EKEvent]) -> [EKEvent] { 68 | var filtered = events 69 | if !allowList.isEmpty { 70 | filtered = filtered.filter { allowList.contains($0.calendar.title) } 71 | } 72 | if !denyList.isEmpty { 73 | filtered = filtered.filter { !denyList.contains($0.calendar.title) } 74 | } 75 | return filtered 76 | } 77 | 78 | func fetchNextEvent() { 79 | let calendars = eventStore.calendars(for: .event) 80 | let now = Date() 81 | let calendar = Calendar.current 82 | guard 83 | let endOfDay = calendar.date( 84 | bySettingHour: 23, minute: 59, second: 59, of: now) 85 | else { 86 | print("Failed to get end of day.") 87 | return 88 | } 89 | let predicate = eventStore.predicateForEvents( 90 | withStart: now, end: endOfDay, calendars: calendars) 91 | let events = eventStore.events(matching: predicate).sorted { 92 | $0.startDate < $1.startDate 93 | } 94 | let filteredEvents = filterEvents(events) 95 | let regularEvents = filteredEvents.filter { !$0.isAllDay } 96 | let next = regularEvents.first ?? filteredEvents.first 97 | DispatchQueue.main.async { 98 | self.nextEvent = next 99 | } 100 | } 101 | 102 | func fetchTodaysEvents() { 103 | let calendars = eventStore.calendars(for: .event) 104 | let now = Date() 105 | let calendar = Calendar.current 106 | let startOfDay = calendar.startOfDay(for: now) 107 | guard 108 | let endOfDay = calendar.date( 109 | bySettingHour: 23, minute: 59, second: 59, of: now) 110 | else { 111 | print("Failed to get end of day.") 112 | return 113 | } 114 | let predicate = eventStore.predicateForEvents( 115 | withStart: startOfDay, end: endOfDay, calendars: calendars) 116 | let events = eventStore.events(matching: predicate) 117 | .filter { $0.endDate >= now } 118 | .sorted { $0.startDate < $1.startDate } 119 | let filteredEvents = filterEvents(events) 120 | DispatchQueue.main.async { 121 | self.todaysEvents = filteredEvents 122 | } 123 | } 124 | 125 | func fetchTomorrowsEvents() { 126 | let calendars = eventStore.calendars(for: .event) 127 | let now = Date() 128 | let calendar = Calendar.current 129 | let startOfToday = calendar.startOfDay(for: now) 130 | guard 131 | let startOfTomorrow = calendar.date( 132 | byAdding: .day, value: 1, to: startOfToday), 133 | let endOfTomorrow = calendar.date( 134 | bySettingHour: 23, minute: 59, second: 59, of: startOfTomorrow) 135 | else { 136 | print("Failed to get tomorrow's date range.") 137 | return 138 | } 139 | let predicate = eventStore.predicateForEvents( 140 | withStart: startOfTomorrow, end: endOfTomorrow, calendars: calendars 141 | ) 142 | let events = eventStore.events(matching: predicate).sorted { 143 | $0.startDate < $1.startDate 144 | } 145 | let filteredEvents = filterEvents(events) 146 | DispatchQueue.main.async { 147 | self.tomorrowsEvents = filteredEvents 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /Barik/MenuBarPopup/MenuBarPopupVariantView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum MenuBarPopupVariant: String, Equatable { 4 | case box, vertical, horizontal, settings 5 | } 6 | 7 | struct MenuBarPopupVariantView: View { 8 | private let box: AnyView? 9 | private let vertical: AnyView? 10 | private let horizontal: AnyView? 11 | private let settings: AnyView? 12 | 13 | var selectedVariant: MenuBarPopupVariant 14 | @State private var hovered = false 15 | @State private var animationValue = 0.0 16 | 17 | var onVariantSelected: ((MenuBarPopupVariant) -> Void)? 18 | 19 | init( 20 | selectedVariant: MenuBarPopupVariant, 21 | onVariantSelected: ((MenuBarPopupVariant) -> Void)? = nil, 22 | @ViewBuilder box: () -> some View = { EmptyView() }, 23 | @ViewBuilder vertical: () -> some View = { EmptyView() }, 24 | @ViewBuilder horizontal: () -> some View = { EmptyView() }, 25 | @ViewBuilder settings: () -> some View = { EmptyView() } 26 | ) { 27 | self.selectedVariant = selectedVariant 28 | self.onVariantSelected = onVariantSelected 29 | 30 | let boxView = box() 31 | let verticalView = vertical() 32 | let horizontalView = horizontal() 33 | let settingsView = settings() 34 | 35 | self.box = (boxView is EmptyView) ? nil : AnyView(boxView) 36 | self.vertical = 37 | (verticalView is EmptyView) ? nil : AnyView(verticalView) 38 | self.horizontal = 39 | (horizontalView is EmptyView) ? nil : AnyView(horizontalView) 40 | self.settings = 41 | (settingsView is EmptyView) ? nil : AnyView(settingsView) 42 | } 43 | 44 | var body: some View { 45 | ZStack(alignment: .topTrailing) { 46 | content(for: selectedVariant) 47 | .blur(radius: animationValue * 30) 48 | .transition(.opacity) 49 | } 50 | .overlay(alignment: .bottomTrailing) { 51 | HStack(spacing: 3) { 52 | if box != nil { 53 | variantButton( 54 | variant: .box, systemImageName: "square.inset.filled") 55 | } 56 | if vertical != nil { 57 | variantButton( 58 | variant: .vertical, 59 | systemImageName: "rectangle.portrait.inset.filled") 60 | } 61 | if horizontal != nil { 62 | variantButton( 63 | variant: .horizontal, 64 | systemImageName: "rectangle.inset.filled") 65 | } 66 | if settings != nil { 67 | variantButton( 68 | variant: .settings, systemImageName: "gearshape.fill") 69 | } 70 | } 71 | .padding(.horizontal, 20) 72 | .padding(.bottom, 5) 73 | .contentShape(Rectangle()) 74 | .opacity(hovered ? 1 : 0.0) 75 | .onHover { value in 76 | withAnimation(.easeIn(duration: 0.3)) { 77 | hovered = value 78 | } 79 | } 80 | } 81 | } 82 | 83 | @ViewBuilder 84 | private func content(for variant: MenuBarPopupVariant) -> some View { 85 | switch variant { 86 | case .box: 87 | if let view = box { view } 88 | case .vertical: 89 | if let view = vertical { view } 90 | case .horizontal: 91 | if let view = horizontal { view } 92 | case .settings: 93 | if let view = settings { view } 94 | } 95 | } 96 | 97 | private func variantButton( 98 | variant: MenuBarPopupVariant, systemImageName: String 99 | ) -> some View { 100 | Button { 101 | if selectedVariant != variant { 102 | withAnimation(.smooth(duration: 0.3)) { 103 | animationValue = 1 104 | } 105 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 106 | withAnimation(.smooth(duration: 0.3)) { 107 | onVariantSelected?(variant) 108 | } 109 | } 110 | 111 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { 112 | withAnimation(.smooth(duration: 0.3)) { 113 | animationValue = 0 114 | } 115 | } 116 | } 117 | } label: { 118 | Image(systemName: systemImageName) 119 | .foregroundColor(.white.opacity(0.5)) 120 | .frame(width: 13, height: 10) 121 | } 122 | .buttonStyle(HoverButtonStyle()) 123 | .overlay( 124 | Group { 125 | if selectedVariant == variant { 126 | RoundedRectangle(cornerRadius: 8) 127 | .stroke(Color.white.opacity(0.3), lineWidth: 1) 128 | .opacity(1 - animationValue * 10) 129 | } 130 | } 131 | ) 132 | } 133 | } 134 | 135 | private struct HoverButtonStyle: ButtonStyle { 136 | func makeBody(configuration: Configuration) -> some View { 137 | HoverButton(configuration: configuration) 138 | } 139 | 140 | struct HoverButton: View { 141 | let configuration: Configuration 142 | @State private var isHovered = false 143 | 144 | var body: some View { 145 | configuration.label 146 | .padding(8) 147 | .background(isHovered ? Color.gray.opacity(0.4) : Color.clear) 148 | .cornerRadius(8) 149 | .onHover { hovering in 150 | isHovered = hovering 151 | } 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /Barik/Widgets/Battery/BatteryWidget.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct BatteryWidget: View { 4 | @EnvironmentObject var configProvider: ConfigProvider 5 | var config: ConfigData { configProvider.config } 6 | var showPercentage: Bool { config["show-percentage"]?.boolValue ?? true } 7 | var warningLevel: Int { config["warning-level"]?.intValue ?? 20 } 8 | var criticalLevel: Int { config["critical-level"]?.intValue ?? 10 } 9 | 10 | @StateObject private var batteryManager = BatteryManager() 11 | private var level: Int { batteryManager.batteryLevel } 12 | private var isCharging: Bool { batteryManager.isCharging } 13 | private var isPluggedIn: Bool { batteryManager.isPluggedIn } 14 | 15 | @State private var rect: CGRect = CGRect() 16 | 17 | var body: some View { 18 | ZStack { 19 | ZStack(alignment: .leading) { 20 | BatteryBodyView(mask: false) 21 | .opacity(showPercentage ? 0.3 : 0.4) 22 | BatteryBodyView(mask: true) 23 | .clipShape( 24 | Rectangle().path( 25 | in: CGRect( 26 | x: showPercentage ? 0 : 2, 27 | y: 0, 28 | width: 30 * Int(level) 29 | / (showPercentage ? 110 : 130), 30 | height: .bitWidth 31 | ) 32 | ) 33 | ) 34 | .foregroundStyle(batteryColor) 35 | BatteryText( 36 | level: level, isCharging: isCharging, 37 | isPluggedIn: isPluggedIn 38 | ) 39 | .foregroundStyle(batteryTextColor) 40 | } 41 | .frame(width: 30, height: 10) 42 | .background( 43 | GeometryReader { geometry in 44 | Color.clear 45 | .onAppear { 46 | rect = geometry.frame(in: .global) 47 | } 48 | .onChange(of: geometry.frame(in: .global)) { 49 | oldState, newState in 50 | rect = newState 51 | } 52 | } 53 | ) 54 | } 55 | .experimentalConfiguration(cornerRadius: 15) 56 | .frame(maxHeight: .infinity) 57 | .background(.black.opacity(0.001)) 58 | .onTapGesture { 59 | MenuBarPopup.show(rect: rect, id: "battery") { BatteryPopup() } 60 | } 61 | 62 | } 63 | 64 | private var batteryTextColor: Color { 65 | if isCharging { 66 | return .foregroundOutsideInvert 67 | } else { 68 | return level > warningLevel ? .foregroundOutsideInvert : .black 69 | } 70 | } 71 | 72 | private var batteryColor: Color { 73 | if isCharging { 74 | return .green 75 | } else { 76 | if level <= criticalLevel { 77 | return .red 78 | } else if level <= warningLevel { 79 | return .yellow 80 | } else { 81 | return .icon 82 | } 83 | } 84 | } 85 | } 86 | 87 | private struct BatteryText: View { 88 | @EnvironmentObject var configProvider: ConfigProvider 89 | var config: ConfigData { configProvider.config } 90 | var showPercentage: Bool { config["show-percentage"]?.boolValue ?? true } 91 | 92 | let level: Int 93 | let isCharging: Bool 94 | let isPluggedIn: Bool 95 | 96 | var body: some View { 97 | HStack(alignment: .center, spacing: -1) { 98 | if showPercentage { 99 | Text("\(level)") 100 | .font(.system(size: 12)) 101 | .transition(.blurReplace) 102 | } 103 | 104 | if isCharging && level != 100 { 105 | Image(systemName: "bolt.fill") 106 | .font(.system(size: showPercentage ? 8 : 10)) 107 | } 108 | 109 | if !isCharging && isPluggedIn && level != 100 { 110 | Image(systemName: "powerplug.portrait.fill") 111 | .font(.system(size: 8)) 112 | .padding(.leading, 1) 113 | } 114 | } 115 | .foregroundStyle( 116 | showPercentage ? .foregroundOutsideInvert : .foregroundOutside 117 | ) 118 | .fontWeight(.semibold) 119 | .transition(.blurReplace) 120 | .animation(.smooth, value: isCharging) 121 | .frame(width: 26, height: 15) 122 | } 123 | } 124 | 125 | private struct BatteryBodyView: View { 126 | let mask: Bool 127 | 128 | @EnvironmentObject var configProvider: ConfigProvider 129 | var config: ConfigData { configProvider.config } 130 | var showPercentage: Bool { config["show-percentage"]?.boolValue ?? true } 131 | 132 | var body: some View { 133 | ZStack { 134 | if showPercentage || !mask { 135 | Image(systemName: "battery.0") 136 | .resizable() 137 | .scaledToFit() 138 | } 139 | if showPercentage || mask { 140 | Rectangle() 141 | .clipShape(RoundedRectangle(cornerRadius: 2)) 142 | .padding(.horizontal, showPercentage ? 3 : 4.4) 143 | .padding(.vertical, showPercentage ? 2 : 3.5) 144 | .offset( 145 | x: showPercentage ? -2 : -1.77, 146 | y: showPercentage ? 0 : 0.2) 147 | } 148 | } 149 | .compositingGroup() 150 | } 151 | } 152 | 153 | struct BatteryWidget_Previews: PreviewProvider { 154 | static var previews: some View { 155 | ZStack { 156 | BatteryWidget() 157 | }.frame(width: 200, height: 100) 158 | .background(.yellow) 159 | .environmentObject(ConfigProvider(config: [:])) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /Barik/Widgets/Spaces/SpacesWidget.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SpacesWidget: View { 4 | @StateObject var viewModel = SpacesViewModel() 5 | 6 | @ObservedObject var configManager = ConfigManager.shared 7 | var foregroundHeight: CGFloat { configManager.config.experimental.foreground.resolveHeight() } 8 | 9 | var body: some View { 10 | HStack(spacing: foregroundHeight < 30 ? 0 : 8) { 11 | ForEach(viewModel.spaces) { space in 12 | SpaceView(space: space) 13 | } 14 | } 15 | .experimentalConfiguration(horizontalPadding: 5, cornerRadius: 10) 16 | .animation(.smooth(duration: 0.3), value: viewModel.spaces) 17 | .foregroundStyle(Color.foreground) 18 | .environmentObject(viewModel) 19 | } 20 | } 21 | 22 | /// This view shows a space with its windows. 23 | private struct SpaceView: View { 24 | @EnvironmentObject var configProvider: ConfigProvider 25 | @EnvironmentObject var viewModel: SpacesViewModel 26 | 27 | var config: ConfigData { configProvider.config } 28 | var spaceConfig: ConfigData { config["space"]?.dictionaryValue ?? [:] } 29 | 30 | @ObservedObject var configManager = ConfigManager.shared 31 | var foregroundHeight: CGFloat { configManager.config.experimental.foreground.resolveHeight() } 32 | 33 | var showKey: Bool { spaceConfig["show-key"]?.boolValue ?? true } 34 | 35 | let space: AnySpace 36 | 37 | @State var isHovered = false 38 | 39 | var body: some View { 40 | let isFocused = space.windows.contains { $0.isFocused } || space.isFocused 41 | HStack(spacing: 0) { 42 | Spacer().frame(width: 10) 43 | if showKey { 44 | Text(space.id) 45 | .font(.headline) 46 | .frame(minWidth: 15) 47 | .fixedSize(horizontal: true, vertical: false) 48 | Spacer().frame(width: 5) 49 | } 50 | HStack(spacing: 2) { 51 | ForEach(space.windows) { window in 52 | WindowView(window: window, space: space) 53 | } 54 | } 55 | Spacer().frame(width: 10) 56 | } 57 | .frame(height: 30) 58 | .background( 59 | foregroundHeight < 30 ? 60 | (isFocused 61 | ? Color.noActive 62 | : Color.clear) : 63 | (isFocused 64 | ? Color.active 65 | : isHovered ? Color.noActive : Color.noActive) 66 | ) 67 | .clipShape(RoundedRectangle(cornerRadius: foregroundHeight < 30 ? 0 : 8, style: .continuous)) 68 | .shadow(color: .shadow, radius: foregroundHeight < 30 ? 0 : 2) 69 | .transition(.blurReplace) 70 | .onTapGesture { 71 | viewModel.switchToSpace(space, needWindowFocus: true) 72 | } 73 | .animation(.smooth, value: isHovered) 74 | .onHover { value in 75 | isHovered = value 76 | } 77 | } 78 | } 79 | 80 | /// This view shows a window and its icon. 81 | private struct WindowView: View { 82 | @EnvironmentObject var configProvider: ConfigProvider 83 | @EnvironmentObject var viewModel: SpacesViewModel 84 | 85 | var config: ConfigData { configProvider.config } 86 | var windowConfig: ConfigData { config["window"]?.dictionaryValue ?? [:] } 87 | var titleConfig: ConfigData { 88 | windowConfig["title"]?.dictionaryValue ?? [:] 89 | } 90 | 91 | var showTitle: Bool { windowConfig["show-title"]?.boolValue ?? true } 92 | var maxLength: Int { titleConfig["max-length"]?.intValue ?? 50 } 93 | var alwaysDisplayAppTitleFor: [String] { titleConfig["always-display-app-name-for"]?.arrayValue?.filter({ $0.stringValue != nil }).map { $0.stringValue! } ?? [] } 94 | 95 | let window: AnyWindow 96 | let space: AnySpace 97 | 98 | @State var isHovered = false 99 | 100 | var body: some View { 101 | let titleMaxLength = maxLength 102 | let size: CGFloat = 21 103 | let sameAppCount = space.windows.filter { $0.appName == window.appName } 104 | .count 105 | let title = sameAppCount > 1 && !alwaysDisplayAppTitleFor.contains { $0 == window.appName } ? window.title : (window.appName ?? "") 106 | let spaceIsFocused = space.windows.contains { $0.isFocused } 107 | HStack { 108 | ZStack { 109 | if let icon = window.appIcon { 110 | Image(nsImage: icon) 111 | .resizable() 112 | .frame(width: size, height: size) 113 | .shadow( 114 | color: .iconShadow, 115 | radius: 2 116 | ) 117 | } else { 118 | Image(systemName: "questionmark.circle") 119 | .resizable() 120 | .frame(width: size, height: size) 121 | } 122 | } 123 | .opacity(spaceIsFocused && !window.isFocused ? 0.5 : 1) 124 | .transition(.blurReplace) 125 | 126 | if window.isFocused, !title.isEmpty, showTitle { 127 | HStack { 128 | Text( 129 | title.count > titleMaxLength 130 | ? String(title.prefix(titleMaxLength)) + "..." 131 | : title 132 | ) 133 | .fixedSize(horizontal: true, vertical: false) 134 | .shadow(color: .foregroundShadow, radius: 3) 135 | .fontWeight(.semibold) 136 | Spacer().frame(width: 5) 137 | } 138 | .transition(.blurReplace) 139 | } 140 | } 141 | .padding(.all, 2) 142 | .background(isHovered || (!showTitle && window.isFocused) ? .selected : .clear) 143 | .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) 144 | .animation(.smooth, value: isHovered) 145 | .frame(height: 30) 146 | .contentShape(Rectangle()) 147 | .onTapGesture { 148 | viewModel.switchToSpace(space) 149 | usleep(100_000) 150 | viewModel.switchToWindow(window) 151 | } 152 | .onHover { value in 153 | isHovered = value 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Barik/Widgets/Network/NetworkViewModel.swift: -------------------------------------------------------------------------------- 1 | import CoreLocation 2 | import CoreWLAN 3 | import Network 4 | import SwiftUI 5 | 6 | enum NetworkState: String { 7 | case connected = "Connected" 8 | case connectedWithoutInternet = "No Internet" 9 | case connecting = "Connecting" 10 | case disconnected = "Disconnected" 11 | case disabled = "Disabled" 12 | case notSupported = "Not Supported" 13 | } 14 | 15 | enum WifiSignalStrength: String { 16 | case low = "Low" 17 | case medium = "Medium" 18 | case high = "High" 19 | case unknown = "Unknown" 20 | } 21 | 22 | /// Unified view model for monitoring network and Wi‑Fi status. 23 | final class NetworkStatusViewModel: NSObject, ObservableObject, 24 | CLLocationManagerDelegate 25 | { 26 | 27 | // States for Wi‑Fi and Ethernet obtained via NWPathMonitor. 28 | @Published var wifiState: NetworkState = .disconnected 29 | @Published var ethernetState: NetworkState = .disconnected 30 | 31 | // Wi‑Fi details obtained via CoreWLAN. 32 | @Published var ssid: String = "Not connected" 33 | @Published var rssi: Int = 0 34 | @Published var noise: Int = 0 35 | @Published var channel: String = "N/A" 36 | 37 | /// Computed property for signal strength. 38 | var wifiSignalStrength: WifiSignalStrength { 39 | // If Wi‑Fi is not connected or the interface is missing – return unknown. 40 | if ssid == "Not connected" || ssid == "No interface" { 41 | return .unknown 42 | } 43 | if rssi >= -50 { 44 | return .high 45 | } else if rssi >= -70 { 46 | return .medium 47 | } else { 48 | return .low 49 | } 50 | } 51 | 52 | private let monitor = NWPathMonitor() 53 | private let monitorQueue = DispatchQueue(label: "NetworkMonitor") 54 | 55 | private var timer: Timer? 56 | private let locationManager = CLLocationManager() 57 | 58 | override init() { 59 | super.init() 60 | locationManager.delegate = self 61 | locationManager.requestWhenInUseAuthorization() 62 | startNetworkMonitoring() 63 | startWiFiMonitoring() 64 | } 65 | 66 | deinit { 67 | stopNetworkMonitoring() 68 | stopWiFiMonitoring() 69 | } 70 | 71 | // MARK: — NWPathMonitor for overall network status. 72 | 73 | private func startNetworkMonitoring() { 74 | monitor.pathUpdateHandler = { [weak self] path in 75 | guard let self = self else { return } 76 | DispatchQueue.main.async { 77 | // Wi‑Fi 78 | if path.availableInterfaces.contains(where: { $0.type == .wifi } 79 | ) { 80 | if path.usesInterfaceType(.wifi) { 81 | switch path.status { 82 | case .satisfied: 83 | self.wifiState = .connected 84 | case .requiresConnection: 85 | self.wifiState = .connecting 86 | default: 87 | self.wifiState = .connectedWithoutInternet 88 | } 89 | } else { 90 | // If the Wi‑Fi interface is available but not in use – consider it enabled but not connected. 91 | self.wifiState = .disconnected 92 | } 93 | } else { 94 | self.wifiState = .notSupported 95 | } 96 | 97 | // Ethernet 98 | if path.availableInterfaces.contains(where: { 99 | $0.type == .wiredEthernet 100 | }) { 101 | if path.usesInterfaceType(.wiredEthernet) { 102 | switch path.status { 103 | case .satisfied: 104 | self.ethernetState = .connected 105 | case .requiresConnection: 106 | self.ethernetState = .connecting 107 | default: 108 | self.ethernetState = .disconnected 109 | } 110 | } else { 111 | self.ethernetState = .disconnected 112 | } 113 | } else { 114 | self.ethernetState = .notSupported 115 | } 116 | } 117 | } 118 | monitor.start(queue: monitorQueue) 119 | } 120 | 121 | private func stopNetworkMonitoring() { 122 | monitor.cancel() 123 | } 124 | 125 | // MARK: — Updating Wi‑Fi information via CoreWLAN. 126 | 127 | private func startWiFiMonitoring() { 128 | timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { 129 | [weak self] _ in 130 | self?.updateWiFiInfo() 131 | } 132 | updateWiFiInfo() 133 | } 134 | 135 | private func stopWiFiMonitoring() { 136 | timer?.invalidate() 137 | timer = nil 138 | } 139 | 140 | private func updateWiFiInfo() { 141 | let client = CWWiFiClient.shared() 142 | if let interface = client.interface() { 143 | self.ssid = interface.ssid() ?? "Not connected" 144 | self.rssi = interface.rssiValue() 145 | self.noise = interface.noiseMeasurement() 146 | if let wlanChannel = interface.wlanChannel() { 147 | let band: String 148 | switch wlanChannel.channelBand { 149 | case .bandUnknown: 150 | band = "unknown" 151 | case .band2GHz: 152 | band = "2GHz" 153 | case .band5GHz: 154 | band = "5GHz" 155 | case .band6GHz: 156 | band = "6GHz" 157 | @unknown default: 158 | band = "unknown" 159 | } 160 | self.channel = "\(wlanChannel.channelNumber) (\(band))" 161 | } else { 162 | self.channel = "N/A" 163 | } 164 | } else { 165 | // Interface not available – Wi‑Fi is off. 166 | self.ssid = "No interface" 167 | self.rssi = 0 168 | self.noise = 0 169 | self.channel = "N/A" 170 | } 171 | } 172 | 173 | // MARK: — CLLocationManagerDelegate. 174 | 175 | func locationManager( 176 | _ manager: CLLocationManager, 177 | didChangeAuthorization status: CLAuthorizationStatus 178 | ) { 179 | updateWiFiInfo() 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /Barik/MenuBarPopup/MenuBarPopupView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct MenuBarPopupView: View { 4 | let content: Content 5 | let isPreview: Bool 6 | 7 | @ObservedObject var configManager = ConfigManager.shared 8 | var foregroundHeight: CGFloat { configManager.config.experimental.foreground.resolveHeight() } 9 | 10 | @State private var contentHeight: CGFloat = 0 11 | @State private var viewFrame: CGRect = .zero 12 | @State private var animationValue: Double = 0.01 13 | private var animated: Bool { isShowAnimation || isHideAnimation } 14 | @State private var isShowAnimation = false 15 | @State private var isHideAnimation = false 16 | 17 | private let willShowWindow = NotificationCenter.default.publisher( 18 | for: .willShowWindow) 19 | private let willHideWindow = NotificationCenter.default.publisher( 20 | for: .willHideWindow) 21 | private let willChangeContent = NotificationCenter.default.publisher( 22 | for: .willChangeContent) 23 | 24 | init(isPreview: Bool = false, @ViewBuilder content: () -> Content) { 25 | self.content = content() 26 | self.isPreview = isPreview 27 | if isPreview { 28 | _animationValue = State(initialValue: 1.0) 29 | } 30 | } 31 | 32 | var body: some View { 33 | ZStack(alignment: .topTrailing) { 34 | content 35 | .background(Color.black) 36 | .cornerRadius(((1.0 - animationValue) * 1) + 40) 37 | .padding(.top, foregroundHeight + 5) 38 | .offset(x: computedOffset, y: computedYOffset) 39 | .shadow(radius: 30) 40 | .blur(radius: (1.0 - (0.1 + 0.9 * animationValue)) * 20) 41 | .scaleEffect(x: 0.2 + 0.8 * animationValue, y: animationValue) 42 | .opacity(animationValue) 43 | .transaction { transaction in 44 | if isHideAnimation { 45 | transaction.animation = .linear(duration: 0.1) 46 | } 47 | } 48 | .onReceive(willShowWindow) { _ in 49 | isShowAnimation = true 50 | withAnimation( 51 | .smooth( 52 | duration: Double( 53 | Constants 54 | .menuBarPopupAnimationDurationInMilliseconds 55 | ) / 1000.0, extraBounce: 0.3) 56 | ) { 57 | animationValue = 1.0 58 | } 59 | DispatchQueue.main.asyncAfter( 60 | deadline: .now() 61 | + .milliseconds( 62 | Constants 63 | .menuBarPopupAnimationDurationInMilliseconds 64 | ) 65 | ) { 66 | isShowAnimation = false 67 | } 68 | } 69 | .onReceive(willHideWindow) { _ in 70 | isHideAnimation = true 71 | withAnimation( 72 | .interactiveSpring( 73 | duration: Double( 74 | Constants 75 | .menuBarPopupAnimationDurationInMilliseconds 76 | ) / 1000.0) 77 | ) { 78 | animationValue = 0.01 79 | } 80 | DispatchQueue.main.asyncAfter( 81 | deadline: .now() 82 | + .milliseconds( 83 | Constants 84 | .menuBarPopupAnimationDurationInMilliseconds 85 | ) 86 | ) { 87 | isHideAnimation = false 88 | } 89 | } 90 | .onReceive(willChangeContent) { _ in 91 | isHideAnimation = true 92 | withAnimation( 93 | .spring( 94 | duration: Double( 95 | Constants 96 | .menuBarPopupAnimationDurationInMilliseconds 97 | ) / 1000.0) 98 | ) { 99 | animationValue = 0.01 100 | } 101 | DispatchQueue.main.asyncAfter( 102 | deadline: .now() 103 | + .milliseconds( 104 | Constants 105 | .menuBarPopupAnimationDurationInMilliseconds 106 | ) 107 | ) { 108 | isHideAnimation = false 109 | } 110 | } 111 | .animation( 112 | .smooth(duration: 0.3), value: animated ? 0 : computedOffset 113 | ) 114 | .animation( 115 | .smooth(duration: 0.3), 116 | value: animated ? 0 : computedYOffset 117 | ) 118 | } 119 | .background( 120 | GeometryReader { geometry in 121 | Color.clear 122 | .onAppear { 123 | DispatchQueue.main.async { 124 | viewFrame = geometry.frame(in: .global) 125 | contentHeight = geometry.size.height 126 | } 127 | } 128 | .onChange(of: geometry.size) { _, __ in 129 | viewFrame = geometry.frame(in: .global) 130 | contentHeight = geometry.size.height 131 | } 132 | } 133 | ) 134 | .foregroundStyle(.white) 135 | .preferredColorScheme(.dark) 136 | } 137 | 138 | var computedOffset: CGFloat { 139 | let screenWidth = NSScreen.main?.frame.width ?? 0 140 | let W = viewFrame.width 141 | let M = viewFrame.midX 142 | let newLeft = (M - W / 2) - 20 143 | let newRight = (M + W / 2) + 20 144 | 145 | if newRight > screenWidth { 146 | return screenWidth - newRight 147 | } else if newLeft < 0 { 148 | return -newLeft 149 | } 150 | return 0 151 | } 152 | 153 | var computedYOffset: CGFloat { 154 | return viewFrame.height / 2 155 | } 156 | } 157 | 158 | extension Notification.Name { 159 | static let willShowWindow = Notification.Name("willShowWindow") 160 | static let willHideWindow = Notification.Name("willHideWindow") 161 | static let willChangeContent = Notification.Name("willChangeContent") 162 | } 163 | -------------------------------------------------------------------------------- /Barik/Widgets/NowPlaying/NowPlayingWidget.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: - Now Playing Widget 4 | 5 | struct NowPlayingWidget: View { 6 | @EnvironmentObject var configProvider: ConfigProvider 7 | @ObservedObject var playingManager = NowPlayingManager.shared 8 | 9 | @State private var widgetFrame: CGRect = .zero 10 | @State private var animatedWidth: CGFloat = 0 11 | 12 | var body: some View { 13 | ZStack(alignment: .trailing) { 14 | if let song = playingManager.nowPlaying { 15 | // Hidden view for measuring the intrinsic width. 16 | MeasurableNowPlayingContent(song: song) { measuredWidth in 17 | if animatedWidth == 0 { 18 | animatedWidth = measuredWidth 19 | } else if animatedWidth != measuredWidth { 20 | withAnimation(.smooth) { 21 | animatedWidth = measuredWidth 22 | } 23 | } 24 | } 25 | .hidden() 26 | 27 | // Visible content with fixed animated width. 28 | VisibleNowPlayingContent(song: song, width: animatedWidth) 29 | .onTapGesture { 30 | MenuBarPopup.show(rect: widgetFrame, id: "nowplaying") { 31 | NowPlayingPopup(configProvider: configProvider) 32 | } 33 | } 34 | } 35 | } 36 | .background( 37 | GeometryReader { geometry in 38 | Color.clear 39 | .onAppear { 40 | widgetFrame = geometry.frame(in: .global) 41 | } 42 | .onChange(of: geometry.frame(in: .global)) { _, newFrame in 43 | widgetFrame = newFrame 44 | } 45 | } 46 | ) 47 | } 48 | } 49 | 50 | // MARK: - Now Playing Content 51 | 52 | /// A view that composes the album art and song text into a capsule-shaped content view. 53 | struct NowPlayingContent: View { 54 | let song: NowPlayingSong 55 | @ObservedObject var configManager = ConfigManager.shared 56 | var foregroundHeight: CGFloat { configManager.config.experimental.foreground.resolveHeight() } 57 | 58 | var body: some View { 59 | Group { 60 | if foregroundHeight < 38 { 61 | HStack(spacing: 8) { 62 | AlbumArtView(song: song) 63 | SongTextView(song: song) 64 | } 65 | } else { 66 | HStack(spacing: 8) { 67 | AlbumArtView(song: song) 68 | SongTextView(song: song) 69 | } 70 | .padding(.horizontal, foregroundHeight < 45 ? 8 : 12) 71 | .frame(height: foregroundHeight < 45 ? 30 : 38) 72 | .background(configManager.config.experimental.foreground.widgetsBackground.blur) 73 | .clipShape(Capsule()) 74 | .overlay( 75 | Capsule().stroke(Color.noActive, lineWidth: 1) 76 | ) 77 | } 78 | } 79 | .foregroundColor(.foreground) 80 | } 81 | } 82 | 83 | // MARK: - Measurable Now Playing Content 84 | 85 | /// A wrapper view that measures the intrinsic width of the now playing content. 86 | struct MeasurableNowPlayingContent: View { 87 | let song: NowPlayingSong 88 | let onSizeChange: (CGFloat) -> Void 89 | 90 | var body: some View { 91 | NowPlayingContent(song: song) 92 | .background( 93 | GeometryReader { geometry in 94 | Color.clear 95 | .onAppear { 96 | onSizeChange(geometry.size.width) 97 | } 98 | .onChange(of: geometry.size.width) { _, newWidth in 99 | onSizeChange(newWidth) 100 | } 101 | } 102 | ) 103 | } 104 | } 105 | 106 | // MARK: - Visible Now Playing Content 107 | 108 | /// A view that displays now playing content with a fixed, animated width and transition. 109 | struct VisibleNowPlayingContent: View { 110 | let song: NowPlayingSong 111 | let width: CGFloat 112 | 113 | var body: some View { 114 | NowPlayingContent(song: song) 115 | .frame(width: width, height: 38) 116 | .animation(.smooth(duration: 0.1), value: song) 117 | .transition(.blurReplace) 118 | } 119 | } 120 | 121 | // MARK: - Album Art View 122 | 123 | /// A view that displays the album art with a fade animation and a pause indicator if needed. 124 | struct AlbumArtView: View { 125 | let song: NowPlayingSong 126 | 127 | var body: some View { 128 | ZStack { 129 | FadeAnimatedCachedImage( 130 | url: song.albumArtURL, 131 | targetSize: CGSize(width: 20, height: 20) 132 | ) 133 | .frame(width: 20, height: 20) 134 | .clipShape(RoundedRectangle(cornerRadius: 4)) 135 | .scaleEffect(song.state == .paused ? 0.9 : 1) 136 | .brightness(song.state == .paused ? -0.3 : 0) 137 | 138 | if song.state == .paused { 139 | Image(systemName: "pause.fill") 140 | .foregroundColor(.icon) 141 | .transition(.blurReplace) 142 | } 143 | } 144 | .animation(.smooth(duration: 0.1), value: song.state == .paused) 145 | } 146 | } 147 | 148 | // MARK: - Song Text View 149 | 150 | /// A view that displays the song title and artist. 151 | struct SongTextView: View { 152 | let song: NowPlayingSong 153 | @ObservedObject var configManager = ConfigManager.shared 154 | var foregroundHeight: CGFloat { configManager.config.experimental.foreground.resolveHeight() } 155 | 156 | var body: some View { 157 | 158 | VStack(alignment: .leading, spacing: -1) { 159 | if foregroundHeight >= 30 { 160 | Text(song.title) 161 | .font(.system(size: 11)) 162 | .fontWeight(.medium) 163 | .padding(.trailing, 2) 164 | Text(song.artist) 165 | .opacity(0.8) 166 | .font(.system(size: 10)) 167 | .padding(.trailing, 2) 168 | } else { 169 | Text(song.artist + " — " + song.title) 170 | .font(.system(size: 12)) 171 | } 172 | } 173 | // Disable animations for text changes. 174 | .transaction { transaction in 175 | transaction.animation = nil 176 | } 177 | } 178 | } 179 | 180 | // MARK: - Preview 181 | 182 | struct NowPlayingWidget_Previews: PreviewProvider { 183 | static var previews: some View { 184 | ZStack { 185 | NowPlayingWidget() 186 | } 187 | .frame(width: 500, height: 100) 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ######################### 2 | # .gitignore file for Xcode4 and Xcode5 Source projects 3 | # 4 | # Apple bugs, waiting for Apple to fix/respond: 5 | # 6 | # 15564624 - what does the xccheckout file in Xcode5 do? Where's the documentation? 7 | # 8 | # Version 2.6 9 | # For latest version, see: http://stackoverflow.com/questions/49478/git-ignore-file-for-xcode-projects 10 | # 11 | # 2015 updates: 12 | # - Fixed typo in "xccheckout" line - thanks to @lyck for pointing it out! 13 | # - Fixed the .idea optional ignore. Thanks to @hashier for pointing this out 14 | # - Finally added "xccheckout" to the ignore. Apple still refuses to answer support requests about this, but in practice it seems you should ignore it. 15 | # - minor tweaks from Jona and Coeur (slightly more precise xc* filtering/names) 16 | # 2014 updates: 17 | # - appended non-standard items DISABLED by default (uncomment if you use those tools) 18 | # - removed the edit that an SO.com moderator made without bothering to ask me 19 | # - researched CocoaPods .lock more carefully, thanks to Gokhan Celiker 20 | # 2013 updates: 21 | # - fixed the broken "save personal Schemes" 22 | # - added line-by-line explanations for EVERYTHING (some were missing) 23 | # 24 | # NB: if you are storing "built" products, this WILL NOT WORK, 25 | # and you should use a different .gitignore (or none at all) 26 | # This file is for SOURCE projects, where there are many extra 27 | # files that we want to exclude 28 | # 29 | ######################### 30 | 31 | ##### 32 | # OS X temporary files that should never be committed 33 | # 34 | # c.f. http://www.westwind.com/reference/os-x/invisibles.html 35 | 36 | .DS_Store 37 | 38 | # c.f. http://www.westwind.com/reference/os-x/invisibles.html 39 | 40 | .Trashes 41 | 42 | # c.f. http://www.westwind.com/reference/os-x/invisibles.html 43 | 44 | *.swp 45 | 46 | # 47 | # *.lock - this is used and abused by many editors for many different things. 48 | # For the main ones I use (e.g. Eclipse), it should be excluded 49 | # from source-control, but YMMV. 50 | # (lock files are usually local-only file-synchronization on the local FS that should NOT go in git) 51 | # c.f. the "OPTIONAL" section at bottom though, for tool-specific variations! 52 | # 53 | # In particular, if you're using CocoaPods, you'll want to comment-out this line: 54 | *.lock 55 | 56 | 57 | # 58 | # profile - REMOVED temporarily (on double-checking, I can't find it in OS X docs?) 59 | #profile 60 | 61 | 62 | #### 63 | # Xcode temporary files that should never be committed 64 | # 65 | # NB: NIB/XIB files still exist even on Storyboard projects, so we want this... 66 | 67 | *~.nib 68 | 69 | 70 | #### 71 | # Xcode build files - 72 | # 73 | # NB: slash on the end, so we only remove the FOLDER, not any files that were badly named "DerivedData" 74 | 75 | DerivedData/ 76 | 77 | # NB: slash on the end, so we only remove the FOLDER, not any files that were badly named "build" 78 | 79 | build/ 80 | 81 | 82 | ##### 83 | # Xcode private settings (window sizes, bookmarks, breakpoints, custom executables, smart groups) 84 | # 85 | # This is complicated: 86 | # 87 | # SOMETIMES you need to put this file in version control. 88 | # Apple designed it poorly - if you use "custom executables", they are 89 | # saved in this file. 90 | # 99% of projects do NOT use those, so they do NOT want to version control this file. 91 | # ..but if you're in the 1%, comment out the line "*.pbxuser" 92 | 93 | # .pbxuser: http://lists.apple.com/archives/xcode-users/2004/Jan/msg00193.html 94 | 95 | *.pbxuser 96 | 97 | # .mode1v3: http://lists.apple.com/archives/xcode-users/2007/Oct/msg00465.html 98 | 99 | *.mode1v3 100 | 101 | # .mode2v3: http://lists.apple.com/archives/xcode-users/2007/Oct/msg00465.html 102 | 103 | *.mode2v3 104 | 105 | # .perspectivev3: http://stackoverflow.com/questions/5223297/xcode-projects-what-is-a-perspectivev3-file 106 | 107 | *.perspectivev3 108 | 109 | # NB: also, whitelist the default ones, some projects need to use these 110 | !default.pbxuser 111 | !default.mode1v3 112 | !default.mode2v3 113 | !default.perspectivev3 114 | 115 | 116 | #### 117 | # Xcode 4 - semi-personal settings 118 | # 119 | # Apple Shared data that Apple put in the wrong folder 120 | # c.f. http://stackoverflow.com/a/19260712/153422 121 | # FROM ANSWER: Apple says "don't ignore it" 122 | # FROM COMMENTS: Apple is wrong; Apple code is too buggy to trust; there are no known negative side-effects to ignoring Apple's unofficial advice and instead doing the thing that actively fixes bugs in Xcode 123 | # Up to you, but ... current advice: ignore it. 124 | *.xccheckout 125 | 126 | # 127 | # 128 | # OPTION 1: --------------------------------- 129 | # throw away ALL personal settings (including custom schemes! 130 | # - unless they are "shared") 131 | # As per build/ and DerivedData/, this ought to have a trailing slash 132 | # 133 | # NB: this is exclusive with OPTION 2 below 134 | xcuserdata/ 135 | 136 | # OPTION 2: --------------------------------- 137 | # get rid of ALL personal settings, but KEEP SOME OF THEM 138 | # - NB: you must manually uncomment the bits you want to keep 139 | # 140 | # NB: this *requires* git v1.8.2 or above; you may need to upgrade to latest OS X, 141 | # or manually install git over the top of the OS X version 142 | # NB: this is exclusive with OPTION 1 above 143 | # 144 | #xcuserdata/**/* 145 | 146 | # (requires option 2 above): Personal Schemes 147 | # 148 | #!xcuserdata/**/xcschemes/* 149 | 150 | #### 151 | # XCode 4 workspaces - more detailed 152 | # 153 | # Workspaces are important! They are a core feature of Xcode - don't exclude them :) 154 | # 155 | # Workspace layout is quite spammy. For reference: 156 | # 157 | # /(root)/ 158 | # /(project-name).xcodeproj/ 159 | # project.pbxproj 160 | # /project.xcworkspace/ 161 | # contents.xcworkspacedata 162 | # /xcuserdata/ 163 | # /(your name)/xcuserdatad/ 164 | # UserInterfaceState.xcuserstate 165 | # /xcshareddata/ 166 | # /xcschemes/ 167 | # (shared scheme name).xcscheme 168 | # /xcuserdata/ 169 | # /(your name)/xcuserdatad/ 170 | # (private scheme).xcscheme 171 | # xcschememanagement.plist 172 | # 173 | # 174 | 175 | #### 176 | # Xcode 4 - Deprecated classes 177 | # 178 | # Allegedly, if you manually "deprecate" your classes, they get moved here. 179 | # 180 | # We're using source-control, so this is a "feature" that we do not want! 181 | 182 | *.moved-aside 183 | 184 | #### 185 | # OPTIONAL: Some well-known tools that people use side-by-side with Xcode / iOS development 186 | # 187 | # NB: I'd rather not include these here, but gitignore's design is weak and doesn't allow 188 | # modular gitignore: you have to put EVERYTHING in one file. 189 | # 190 | # COCOAPODS: 191 | # 192 | # c.f. http://guides.cocoapods.org/using/using-cocoapods.html#what-is-a-podfilelock 193 | # c.f. http://guides.cocoapods.org/using/using-cocoapods.html#should-i-ignore-the-pods-directory-in-source-control 194 | # 195 | #!Podfile.lock 196 | # 197 | # RUBY: 198 | # 199 | # c.f. http://yehudakatz.com/2010/12/16/clarifying-the-roles-of-the-gemspec-and-gemfile/ 200 | # 201 | #!Gemfile.lock 202 | # 203 | # IDEA: 204 | # 205 | # c.f. https://www.jetbrains.com/objc/help/managing-projects-under-version-control.html?search=workspace.xml 206 | # 207 | #.idea/workspace.xml 208 | # 209 | # TEXTMATE: 210 | # 211 | # -- UNVERIFIED: c.f. http://stackoverflow.com/a/50283/153422 212 | # 213 | #tm_build_errors 214 | 215 | #### 216 | # UNKNOWN: recommended by others, but I can't discover what these files are 217 | # -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **NOTICE**: Unfortunately, I don’t have much free time to actively maintain this project. If you like the project but are not satisfied with its current state, you can explore the many forks or create your own. Even if you’re unfamiliar with **Swift**, tools like **Claude Code** and **Codex** can effectively help implement projects like this. This is a great opportunity to tailor **barik** to your needs and make it exactly the way you’d like. 2 | 3 | ---- 4 | 5 |

6 | Barik 7 |

8 | 9 | License Badge 10 | 11 | 12 | Issues Badge 13 | 14 | 15 | Changelog Badge 16 | 17 | 18 | GitHub Downloads (all assets, all releases) 19 | 20 |

21 |

22 | 23 | **barik** is a lightweight macOS menu bar replacement. If you use [**yabai**](https://github.com/koekeishiya/yabai) or [**AeroSpace**](https://github.com/nikitabobko/AeroSpace) for tiling WM, you can display the current space in a sleek macOS-style panel with smooth animations. This makes it easy to see which number to press to switch spaces. 24 | 25 |
26 | 27 |
28 |

Screenshots

29 | Barik Light Theme 30 | Barik Dark Theme 31 |
32 |
33 |
34 |

Video

35 |
37 | 38 | https://github.com/user-attachments/assets/d3799e24-c077-4c6a-a7da-a1f2eee1a07f 39 | 40 |
41 | 42 | ## Requirements 43 | 44 | - macOS 14.6+ 45 | 46 | ## Quick Start 47 | 48 | 1. Install **barik** via [Homebrew](https://brew.sh/) 49 | 50 | ```sh 51 | brew install --cask mocki-toki/formulae/barik 52 | ``` 53 | 54 | Or you can download from [Releases](https://github.com/mocki-toki/barik/releases), unzip it, and move it to your Applications folder. 55 | 56 | 2. _(Optional)_ To display open applications and spaces, install [**yabai**](https://github.com/koekeishiya/yabai) or [**AeroSpace**](https://github.com/nikitabobko/AeroSpace) and set up hotkeys. For **yabai**, you'll need **skhd** or **Raycast scripts**. Don't forget to configure **top padding** — [here's an example for **yabai**](https://github.com/mocki-toki/barik/blob/main/example/.yabairc). 57 | 58 | 3. Hide the system menu bar in **System Settings** and uncheck **Desktop & Dock → Show items → On Desktop**. 59 | 60 | 4. Launch **barik** from the Applications folder. 61 | 62 | 5. Add **barik** to your login items for automatic startup. 63 | 64 | **That's it!** Try switching spaces and see the panel in action. 65 | 66 | ## Configuration 67 | 68 | When you launch **barik** for the first time, it will create a `~/.barik-config.toml` file with an example customization for your new menu bar. 69 | 70 | ```toml 71 | # If you installed yabai or aerospace without using Homebrew, 72 | # manually set the path to the binary. For example: 73 | # 74 | # yabai.path = "/run/current-system/sw/bin/yabai" 75 | # aerospace.path = ... 76 | 77 | theme = "system" # system, light, dark 78 | 79 | [widgets] 80 | displayed = [ # widgets on menu bar 81 | "default.spaces", 82 | "spacer", 83 | "default.nowplaying", 84 | "default.network", 85 | "default.battery", 86 | "divider", 87 | # { "default.time" = { time-zone = "America/Los_Angeles", format = "E d, hh:mm" } }, 88 | "default.time", 89 | ] 90 | 91 | [widgets.default.spaces] 92 | space.show-key = true # show space number (or character, if you use AeroSpace) 93 | window.show-title = true 94 | window.title.max-length = 50 95 | 96 | # A list of applications that will always be displayed by application name. 97 | # Other applications will show the window title if there is more than one window. 98 | window.title.always-display-app-name-for = ["Mail", "Chrome", "Arc"] 99 | 100 | [widgets.default.nowplaying.popup] 101 | view-variant = "horizontal" 102 | 103 | [widgets.default.battery] 104 | show-percentage = true 105 | warning-level = 30 106 | critical-level = 10 107 | 108 | [widgets.default.time] 109 | format = "E d, J:mm" 110 | calendar.format = "J:mm" 111 | 112 | calendar.show-events = true 113 | # calendar.allow-list = ["Home", "Personal"] # show only these calendars 114 | # calendar.deny-list = ["Work", "Boss"] # show all calendars except these 115 | 116 | [widgets.default.time.popup] 117 | view-variant = "box" 118 | 119 | 120 | 121 | ### EXPERIMENTAL, WILL BE REPLACED BY STYLE API IN THE FUTURE 122 | [experimental.background] # settings for blurred background 123 | displayed = true # display blurred background 124 | height = "default" # available values: default (stretch to full screen), menu-bar (height like system menu bar), (e.g., 40, 33.5) 125 | blur = 3 # background type: from 1 to 6 for blur intensity, 7 for black color 126 | 127 | [experimental.foreground] # settings for menu bar 128 | height = "default" # available values: default (55.0), menu-bar (height like system menu bar), (e.g., 40, 33.5) 129 | horizontal-padding = 25 # padding on the left and right corners 130 | spacing = 15 # spacing between widgets 131 | 132 | [experimental.foreground.widgets-background] # settings for widgets background 133 | displayed = false # wrap widgets in their own background 134 | blur = 3 # background type: from 1 to 6 for blur intensity 135 | ``` 136 | 137 | Currently, you can customize the order of widgets (time, indicators, etc.) and adjust some of their settings. Soon, you’ll also be able to add custom widgets and completely change **barik**'s appearance—making it almost unrecognizable (hello, r/unixporn!). 138 | 139 | ## Future Plans 140 | 141 | I'm not planning to stick to minimal functionality—exciting new features are coming soon! The roadmap includes full style customization, the ability to create custom widgets or extend existing ones, and a public **Store** where you can share your styles and widgets. 142 | 143 | Soon, you'll also be able to place widgets not just at the top, but at the bottom, left, and right as well. This means you can replace not only the menu bar but also the Dock! 🚀 144 | 145 | ## What to do if the currently playing song is not displayed in the Now Playing widget? 146 | 147 | Unfortunately, macOS does not support access to its API that allows music control. Fortunately, there is a workaround using Apple Script or a service API, but this requires additional work to integrate each service. Currently, the Now Playing widget supports the following services: 148 | 149 | 1. Spotify (requires the desktop application) 150 | 2. Apple Music (requires the desktop application) 151 | 152 | Create an issue so we can add your favorite music service: https://github.com/mocki-toki/barik/issues/new 153 | 154 | ## Where Are the Menu Items? 155 | 156 | [#5](https://github.com/mocki-toki/barik/issues/5), [#1](https://github.com/mocki-toki/barik/issues/1) 157 | 158 | Menu items (such as File, Edit, View, etc.) are not currently supported, but they are planned for future releases. However, you can use [Raycast](https://www.raycast.com/), which supports menu items through an interface similar to Spotlight. I personally use it with the `option + tab` shortcut, and it works very well. 159 | 160 | If you’re accustomed to using menu items from the system menu bar, simply move your mouse to the top of the screen to reveal the system menu bar, where they will be available. 161 | 162 | Raycast Menu Items 163 | 164 | ## Contributing 165 | 166 | Contributions are welcome! Please feel free to submit a PR. 167 | 168 | ## License 169 | 170 | [MIT](LICENSE) 171 | 172 | ## Trademarks 173 | 174 | Apple and macOS are trademarks of Apple Inc. This project is not connected to Apple Inc. and does not have their approval or support. 175 | 176 | ## Stars 177 | 178 | [![Stargazers over time](https://starchart.cc/mocki-toki/barik.svg?variant=adaptive)](https://starchart.cc/mocki-toki/barik) 179 | -------------------------------------------------------------------------------- /Barik/Widgets/NowPlaying/NowPlayingPopup.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | import SwiftUI 3 | 4 | struct NowPlayingPopup: View { 5 | @ObservedObject var configProvider: ConfigProvider 6 | @State private var selectedVariant: MenuBarPopupVariant = .horizontal 7 | 8 | var body: some View { 9 | MenuBarPopupVariantView( 10 | selectedVariant: selectedVariant, 11 | onVariantSelected: { variant in 12 | selectedVariant = variant 13 | ConfigManager.shared.updateConfigValue( 14 | key: "widgets.default.nowplaying.popup.view-variant", 15 | newValue: variant.rawValue 16 | ) 17 | }, 18 | vertical: { NowPlayingVerticalPopup() }, 19 | horizontal: { NowPlayingHorizontalPopup() } 20 | ) 21 | .onAppear(perform: loadVariant) 22 | .onReceive(configProvider.$config, perform: updateVariant) 23 | } 24 | 25 | /// Loads the initial view variant from configuration. 26 | private func loadVariant() { 27 | if let variantString = configProvider.config["popup"]? 28 | .dictionaryValue?["view-variant"]?.stringValue, 29 | let variant = MenuBarPopupVariant(rawValue: variantString) { 30 | selectedVariant = variant 31 | } else { 32 | selectedVariant = .box 33 | } 34 | } 35 | 36 | /// Updates the view variant when configuration changes. 37 | private func updateVariant(newConfig: ConfigData) { 38 | if let variantString = newConfig["popup"]?.dictionaryValue?["view-variant"]?.stringValue, 39 | let variant = MenuBarPopupVariant(rawValue: variantString) { 40 | selectedVariant = variant 41 | } 42 | } 43 | } 44 | 45 | /// A vertical layout for the now playing popup. 46 | private struct NowPlayingVerticalPopup: View { 47 | @ObservedObject private var playingManager = NowPlayingManager.shared 48 | 49 | var body: some View { 50 | if let song = playingManager.nowPlaying, 51 | let duration = song.duration, 52 | let position = song.position { 53 | VStack(spacing: 15) { 54 | RotateAnimatedCachedImage( 55 | url: song.albumArtURL, 56 | targetSize: CGSize(width: 200, height: 200) 57 | ) { image in 58 | image.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) 59 | } 60 | .frame(width: 200, height: 200) 61 | .scaleEffect(song.state == .paused ? 0.9 : 1) 62 | .overlay( 63 | song.state == .paused ? 64 | Color.black.opacity(0.3) 65 | .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) 66 | : nil 67 | ) 68 | .animation(.smooth(duration: 0.5, extraBounce: 0.4), value: song.state == .paused) 69 | 70 | VStack(alignment: .center) { 71 | Text(song.title) 72 | .multilineTextAlignment(.center) 73 | .font(.system(size: 15)) 74 | .fontWeight(.medium) 75 | Text(song.artist) 76 | .opacity(0.6) 77 | .font(.system(size: 15)) 78 | .fontWeight(.light) 79 | } 80 | 81 | HStack { 82 | Text(timeString(from: position)) 83 | .font(.caption) 84 | ProgressView(value: position, total: duration) 85 | .progressViewStyle(LinearProgressViewStyle()) 86 | .tint(.white) 87 | Text("-" + timeString(from: duration - position)) 88 | .font(.caption) 89 | } 90 | .foregroundColor(.gray) 91 | .monospacedDigit() 92 | 93 | HStack(spacing: 40) { 94 | Image(systemName: "backward.fill") 95 | .font(.system(size: 20)) 96 | .onTapGesture { playingManager.previousTrack() } 97 | Image(systemName: song.state == .paused ? "play.fill" : "pause.fill") 98 | .font(.system(size: 30)) 99 | .onTapGesture { playingManager.togglePlayPause() } 100 | Image(systemName: "forward.fill") 101 | .font(.system(size: 20)) 102 | .onTapGesture { playingManager.nextTrack() } 103 | } 104 | } 105 | .padding(.horizontal, 25) 106 | .padding(.vertical, 30) 107 | .frame(width: 300) 108 | .animation(.easeInOut, value: song.albumArtURL) 109 | } 110 | } 111 | } 112 | 113 | /// A horizontal layout for the now playing popup. 114 | struct NowPlayingHorizontalPopup: View { 115 | @ObservedObject private var playingManager = NowPlayingManager.shared 116 | 117 | var body: some View { 118 | if let song = playingManager.nowPlaying, 119 | let duration = song.duration, 120 | let position = song.position { 121 | VStack(spacing: 15) { 122 | HStack(spacing: 15) { 123 | RotateAnimatedCachedImage( 124 | url: song.albumArtURL, 125 | targetSize: CGSize(width: 200, height: 200) 126 | ) { image in 127 | image.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) 128 | } 129 | .frame(width: 60, height: 60) 130 | .scaleEffect(song.state == .paused ? 0.9 : 1) 131 | .overlay( 132 | song.state == .paused ? 133 | Color.black.opacity(0.3) 134 | .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) 135 | : nil 136 | ) 137 | .animation(.smooth(duration: 0.5, extraBounce: 0.4), value: song.state == .paused) 138 | 139 | VStack(alignment: .leading, spacing: 0) { 140 | Text(song.title) 141 | .font(.headline) 142 | .fontWeight(.medium) 143 | Text(song.artist) 144 | .opacity(0.6) 145 | .font(.headline) 146 | .fontWeight(.light) 147 | } 148 | .padding(.trailing, 8) 149 | .frame(maxWidth: .infinity, alignment: .leading) 150 | } 151 | 152 | HStack { 153 | Text(timeString(from: position)) 154 | .font(.caption) 155 | ProgressView(value: position, total: duration) 156 | .progressViewStyle(LinearProgressViewStyle()) 157 | .tint(.white) 158 | Text("-" + timeString(from: duration - position)) 159 | .font(.caption) 160 | } 161 | .foregroundColor(.gray) 162 | .monospacedDigit() 163 | 164 | HStack(spacing: 40) { 165 | Image(systemName: "backward.fill") 166 | .font(.system(size: 20)) 167 | .onTapGesture { playingManager.previousTrack() } 168 | Image(systemName: song.state == .paused ? "play.fill" : "pause.fill") 169 | .font(.system(size: 30)) 170 | .onTapGesture { playingManager.togglePlayPause() } 171 | Image(systemName: "forward.fill") 172 | .font(.system(size: 20)) 173 | .onTapGesture { playingManager.nextTrack() } 174 | } 175 | } 176 | .padding(.horizontal, 25) 177 | .padding(.vertical, 20) 178 | .frame(width: 300, height: 180) 179 | .animation(.easeInOut, value: song.albumArtURL) 180 | } 181 | } 182 | } 183 | 184 | /// Converts a time interval in seconds to a formatted string (minutes:seconds). 185 | private func timeString(from seconds: Double) -> String { 186 | let intSeconds = Int(seconds) 187 | let minutes = intSeconds / 60 188 | let remainingSeconds = intSeconds % 60 189 | return String(format: "%d:%02d", minutes, remainingSeconds) 190 | } 191 | 192 | // MARK: - Previews 193 | 194 | struct NowPlayingPopup_Previews: PreviewProvider { 195 | static var previews: some View { 196 | Group { 197 | NowPlayingVerticalPopup() 198 | .background(Color.black) 199 | .frame(height: 600) 200 | .previewDisplayName("Vertical") 201 | 202 | NowPlayingHorizontalPopup() 203 | .background(Color.black) 204 | .previewLayout(.sizeThatFits) 205 | .previewDisplayName("Horizontal") 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /Barik/Widgets/NowPlaying/NowPlayingManager.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Combine 3 | import Foundation 4 | 5 | // MARK: - Playback State 6 | 7 | /// Represents the current playback state. 8 | enum PlaybackState: String { 9 | case playing, paused, stopped 10 | } 11 | 12 | // MARK: - Now Playing Song Model 13 | 14 | /// A model representing the currently playing song. 15 | struct NowPlayingSong: Equatable, Identifiable { 16 | var id: String { title + artist } 17 | let appName: String 18 | let state: PlaybackState 19 | let title: String 20 | let artist: String 21 | let albumArtURL: URL? 22 | let position: Double? 23 | let duration: Double? // Duration in seconds 24 | 25 | /// Initializes a song model from a given output string. 26 | /// - Parameters: 27 | /// - application: The name of the music application. 28 | /// - output: The output string returned by AppleScript. 29 | init?(application: String, from output: String) { 30 | let components = output.components(separatedBy: "|") 31 | guard components.count == 6, 32 | let state = PlaybackState(rawValue: components[0]) 33 | else { 34 | return nil 35 | } 36 | // Replace commas with dots for correct decimal conversion. 37 | let positionString = components[4].replacingOccurrences( 38 | of: ",", with: ".") 39 | let durationString = components[5].replacingOccurrences( 40 | of: ",", with: ".") 41 | guard let position = Double(positionString), 42 | let duration = Double(durationString) 43 | else { 44 | return nil 45 | } 46 | 47 | self.appName = application 48 | self.state = state 49 | self.title = components[1] 50 | self.artist = components[2] 51 | self.albumArtURL = URL(string: components[3]) 52 | self.position = position 53 | if application == MusicApp.spotify.rawValue { 54 | self.duration = duration / 1000 55 | } else { 56 | self.duration = duration 57 | } 58 | } 59 | } 60 | 61 | // MARK: - Supported Music Applications 62 | 63 | /// Supported music applications with corresponding AppleScript commands. 64 | enum MusicApp: String, CaseIterable { 65 | case spotify = "Spotify" 66 | case music = "Music" 67 | 68 | /// AppleScript to fetch the now playing song. 69 | var nowPlayingScript: String { 70 | if self == .music { 71 | return """ 72 | if application "Music" is running then 73 | tell application "Music" 74 | if player state is playing or player state is paused then 75 | set currentTrack to current track 76 | try 77 | set artworkURL to (get URL of artwork 1 of currentTrack) as text 78 | on error 79 | set artworkURL to "" 80 | end try 81 | set stateText to "" 82 | if player state is playing then 83 | set stateText to "playing" 84 | else if player state is paused then 85 | set stateText to "paused" 86 | end if 87 | return stateText & "|" & (name of currentTrack) & "|" & (artist of currentTrack) & "|" & artworkURL & "|" & (player position as text) & "|" & ((duration of currentTrack) as text) 88 | else 89 | return "stopped" 90 | end if 91 | end tell 92 | else 93 | return "stopped" 94 | end if 95 | """ 96 | } else { 97 | return """ 98 | if application "\(rawValue)" is running then 99 | tell application "\(rawValue)" 100 | if player state is playing then 101 | set currentTrack to current track 102 | return "playing|" & (name of currentTrack) & "|" & (artist of currentTrack) & "|" & (artwork url of currentTrack) & "|" & player position & "|" & (duration of currentTrack) 103 | else if player state is paused then 104 | set currentTrack to current track 105 | return "paused|" & (name of currentTrack) & "|" & (artist of currentTrack) & "|" & (artwork url of currentTrack) & "|" & player position & "|" & (duration of currentTrack) 106 | else 107 | return "stopped" 108 | end if 109 | end tell 110 | else 111 | return "stopped" 112 | end if 113 | """ 114 | } 115 | } 116 | 117 | var previousTrackCommand: String { 118 | "tell application \"\(rawValue)\" to previous track" 119 | } 120 | 121 | var togglePlayPauseCommand: String { 122 | "tell application \"\(rawValue)\" to playpause" 123 | } 124 | 125 | var nextTrackCommand: String { 126 | "tell application \"\(rawValue)\" to next track" 127 | } 128 | } 129 | 130 | // MARK: - Now Playing Provider 131 | 132 | /// Provides functionality to fetch the now playing song and execute playback commands. 133 | final class NowPlayingProvider { 134 | 135 | /// Returns the current playing song from any supported music application. 136 | static func fetchNowPlaying() -> NowPlayingSong? { 137 | for app in MusicApp.allCases { 138 | if let song = fetchNowPlaying(from: app) { 139 | return song 140 | } 141 | } 142 | return nil 143 | } 144 | 145 | /// Returns the now playing song for a specific music application. 146 | private static func fetchNowPlaying(from app: MusicApp) -> NowPlayingSong? { 147 | guard let output = runAppleScript(app.nowPlayingScript), 148 | output != "stopped" 149 | else { 150 | return nil 151 | } 152 | return NowPlayingSong(application: app.rawValue, from: output) 153 | } 154 | 155 | /// Checks if the specified music application is currently running. 156 | static func isAppRunning(_ app: MusicApp) -> Bool { 157 | NSWorkspace.shared.runningApplications.contains { 158 | $0.localizedName == app.rawValue 159 | } 160 | } 161 | 162 | /// Executes the provided AppleScript and returns the trimmed result. 163 | @discardableResult 164 | static func runAppleScript(_ script: String) -> String? { 165 | guard let appleScript = NSAppleScript(source: script) else { 166 | return nil 167 | } 168 | var error: NSDictionary? 169 | let outputDescriptor = appleScript.executeAndReturnError(&error) 170 | if let error = error { 171 | print("AppleScript Error: \(error)") 172 | return nil 173 | } 174 | return outputDescriptor.stringValue?.trimmingCharacters( 175 | in: .whitespacesAndNewlines) 176 | } 177 | 178 | /// Returns the first running music application. 179 | static func activeMusicApp() -> MusicApp? { 180 | MusicApp.allCases.first { isAppRunning($0) } 181 | } 182 | 183 | /// Executes a playback command for the active music application. 184 | static func executeCommand(_ command: (MusicApp) -> String) { 185 | guard let activeApp = activeMusicApp() else { return } 186 | _ = runAppleScript(command(activeApp)) 187 | } 188 | } 189 | 190 | // MARK: - Now Playing Manager 191 | 192 | /// An observable manager that periodically updates the now playing song. 193 | final class NowPlayingManager: ObservableObject { 194 | static let shared = NowPlayingManager() 195 | 196 | @Published private(set) var nowPlaying: NowPlayingSong? 197 | private var cancellable: AnyCancellable? 198 | 199 | private init() { 200 | cancellable = Timer.publish(every: 0.3, on: .main, in: .common) 201 | .autoconnect() 202 | .sink { [weak self] _ in 203 | self?.updateNowPlaying() 204 | } 205 | } 206 | 207 | /// Updates the now playing song asynchronously. 208 | private func updateNowPlaying() { 209 | DispatchQueue.global(qos: .background).async { 210 | let song = NowPlayingProvider.fetchNowPlaying() 211 | DispatchQueue.main.async { [weak self] in 212 | self?.nowPlaying = song 213 | } 214 | } 215 | } 216 | 217 | /// Skips to the previous track. 218 | func previousTrack() { 219 | NowPlayingProvider.executeCommand { $0.previousTrackCommand } 220 | } 221 | 222 | /// Toggles between play and pause. 223 | func togglePlayPause() { 224 | NowPlayingProvider.executeCommand { $0.togglePlayPauseCommand } 225 | } 226 | 227 | /// Skips to the next track. 228 | func nextTrack() { 229 | NowPlayingProvider.executeCommand { $0.nextTrackCommand } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /Barik/Views/AppUpdater.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class AppUpdater: ObservableObject { 4 | // Published properties to notify the UI 5 | @Published var latestVersion: String? 6 | @Published var updateAvailable = false 7 | 8 | // Local path for the downloaded update directory 9 | private(set) var downloadedUpdatePath: String? 10 | // URL for the update asset obtained from GitHub release JSON 11 | private var updateAssetURL: URL? 12 | 13 | // Timer to schedule periodic update checks 14 | private var updateTimer: Timer? 15 | 16 | init() { 17 | fetchLatestRelease() 18 | // Check for updates every 30 minutes 19 | updateTimer = Timer.scheduledTimer( 20 | withTimeInterval: 1800, repeats: true 21 | ) { [weak self] _ in 22 | self?.fetchLatestRelease() 23 | } 24 | } 25 | 26 | deinit { 27 | updateTimer?.invalidate() 28 | } 29 | 30 | /// Returns a fallback download URL based on the version string. 31 | private func fallbackDownloadURL(for version: String) -> URL? { 32 | let versionWithoutPrefix = 33 | version.hasPrefix("v") ? String(version.dropFirst()) : version 34 | let urlString = 35 | "https://github.com/mocki-toki/barik/releases/download/\(version)/barik-v\(versionWithoutPrefix).zip" 36 | return URL(string: urlString) 37 | } 38 | 39 | /// Fetches the latest release information from GitHub and updates the state. 40 | func fetchLatestRelease() { 41 | guard 42 | let url = URL( 43 | string: 44 | "https://api.github.com/repos/mocki-toki/barik/releases/latest" 45 | ) 46 | else { return } 47 | URLSession.shared.dataTask(with: url) { [weak self] data, _, error in 48 | if let error = error { 49 | print("Error fetching release info: \(error)") 50 | return 51 | } 52 | guard let data = data, 53 | let json = try? JSONSerialization.jsonObject(with: data) 54 | as? [String: Any], 55 | let tag = json["tag_name"] as? String 56 | else { 57 | return 58 | } 59 | 60 | // Attempt to extract the asset download URL if available 61 | if let assets = json["assets"] as? [[String: Any]] { 62 | for asset in assets { 63 | if let name = asset["name"] as? String, 64 | name.hasSuffix(".zip"), 65 | let downloadURLString = asset["browser_download_url"] 66 | as? String, 67 | let assetURL = URL(string: downloadURLString) 68 | { 69 | self?.updateAssetURL = assetURL 70 | break 71 | } 72 | } 73 | } 74 | 75 | let currentVersion = VersionChecker.currentVersion ?? "0.0.0" 76 | let comparisonResult = 77 | self?.compareVersion(tag, currentVersion) ?? 0 78 | DispatchQueue.main.async { 79 | self?.latestVersion = tag 80 | self?.updateAvailable = comparisonResult > 0 81 | } 82 | }.resume() 83 | } 84 | 85 | /// Compares two version strings. 86 | /// - Returns: 1 if v1 > v2, -1 if v1 < v2, and 0 if equal. 87 | func compareVersion(_ v1: String, _ v2: String) -> Int { 88 | let version1 = v1.replacingOccurrences(of: "v", with: "") 89 | let version2 = v2.replacingOccurrences(of: "v", with: "") 90 | let parts1 = version1.split(separator: ".").compactMap { Int($0) } 91 | let parts2 = version2.split(separator: ".").compactMap { Int($0) } 92 | let maxCount = max(parts1.count, parts2.count) 93 | for i in 0.. num2 { return 1 } 97 | if num1 < num2 { return -1 } 98 | } 99 | return 0 100 | } 101 | 102 | /// Downloads and unzips the update archive. 103 | /// - Parameters: 104 | /// - version: The latest version string. 105 | /// - completion: Returns the temporary directory URL containing the unzipped app. 106 | private func downloadAndUnzip( 107 | latest version: String, completion: @escaping (URL?) -> Void 108 | ) { 109 | let assetURL: URL 110 | if let url = updateAssetURL { 111 | assetURL = url 112 | } else if let fallbackURL = fallbackDownloadURL(for: version) { 113 | assetURL = fallbackURL 114 | } else { 115 | print("Invalid update URL") 116 | completion(nil) 117 | return 118 | } 119 | 120 | print("Downloading update from: \(assetURL.absoluteString)") 121 | let downloadTask = URLSession.shared.downloadTask(with: assetURL) { 122 | localURL, response, error in 123 | if let error = error { 124 | print("Update download error: \(error)") 125 | completion(nil) 126 | return 127 | } 128 | guard let httpResponse = response as? HTTPURLResponse, 129 | httpResponse.statusCode == 200 130 | else { 131 | print("Download failed with HTTP error") 132 | completion(nil) 133 | return 134 | } 135 | guard let localURL = localURL else { 136 | print("No update file found") 137 | completion(nil) 138 | return 139 | } 140 | 141 | let fileManager = FileManager.default 142 | let tempDir = fileManager.temporaryDirectory.appendingPathComponent( 143 | UUID().uuidString) 144 | do { 145 | try fileManager.createDirectory( 146 | at: tempDir, withIntermediateDirectories: true, 147 | attributes: nil) 148 | let unzipProcess = Process() 149 | unzipProcess.executableURL = URL( 150 | fileURLWithPath: "/usr/bin/unzip") 151 | unzipProcess.arguments = [ 152 | "-o", localURL.path, "-d", tempDir.path, 153 | ] 154 | try unzipProcess.run() 155 | unzipProcess.waitUntilExit() 156 | 157 | let newAppURL = tempDir.appendingPathComponent("Barik.app") 158 | if fileManager.fileExists(atPath: newAppURL.path) { 159 | DispatchQueue.main.async { 160 | completion(tempDir) 161 | } 162 | } else { 163 | print("Unzipping failed: Barik.app not found in archive") 164 | DispatchQueue.main.async { 165 | completion(nil) 166 | } 167 | } 168 | } catch { 169 | print("Error unzipping update: \(error)") 170 | DispatchQueue.main.async { 171 | completion(nil) 172 | } 173 | } 174 | } 175 | downloadTask.resume() 176 | } 177 | 178 | /// Downloads and installs the update immediately. 179 | /// - Parameters: 180 | /// - version: The latest version string. 181 | /// - completion: Called when the installation process has been triggered. 182 | func downloadAndInstall( 183 | latest version: String, completion: @escaping () -> Void 184 | ) { 185 | downloadAndUnzip(latest: version) { [weak self] tempDir in 186 | guard let tempDir = tempDir else { 187 | completion() 188 | return 189 | } 190 | self?.downloadedUpdatePath = tempDir.path 191 | self?.installUpdate(latest: version) 192 | DispatchQueue.main.async { 193 | completion() 194 | } 195 | } 196 | } 197 | 198 | /// Installs the update by replacing the current application. 199 | /// - Parameter version: The latest version string. 200 | func installUpdate(latest version: String) { 201 | guard let downloadedPath = downloadedUpdatePath else { 202 | print("No downloaded update to install") 203 | return 204 | } 205 | let newAppURL = URL(fileURLWithPath: downloadedPath) 206 | .appendingPathComponent("Barik.app") 207 | let destinationURL = URL(fileURLWithPath: "/Applications/Barik.app") 208 | let script = """ 209 | #!/bin/bash 210 | sleep 2 211 | rm -rf "\(destinationURL.path)" 212 | mv "\(newAppURL.path)" "\(destinationURL.path)" 213 | open "\(destinationURL.path)" 214 | rm -- "$0" 215 | """ 216 | 217 | let fileManager = FileManager.default 218 | let updateTempDir = fileManager.temporaryDirectory 219 | .appendingPathComponent(UUID().uuidString) 220 | do { 221 | try fileManager.createDirectory( 222 | at: updateTempDir, withIntermediateDirectories: true, 223 | attributes: nil) 224 | let scriptURL = updateTempDir.appendingPathComponent("update.sh") 225 | try script.write(to: scriptURL, atomically: true, encoding: .utf8) 226 | try fileManager.setAttributes( 227 | [.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) 228 | 229 | let process = Process() 230 | process.executableURL = scriptURL 231 | try process.run() 232 | } catch { 233 | print("Error installing update: \(error)") 234 | } 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /Barik/Config/ConfigManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import TOMLDecoder 4 | 5 | final class ConfigManager: ObservableObject { 6 | static let shared = ConfigManager() 7 | 8 | @Published private(set) var config = Config() 9 | @Published private(set) var initError: String? 10 | 11 | private var fileWatchSource: DispatchSourceFileSystemObject? 12 | private var fileDescriptor: CInt = -1 13 | private var configFilePath: String? 14 | 15 | private init() { 16 | loadOrCreateConfigIfNeeded() 17 | } 18 | 19 | private func loadOrCreateConfigIfNeeded() { 20 | let homePath = FileManager.default.homeDirectoryForCurrentUser.path 21 | let path1 = "\(homePath)/.barik-config.toml" 22 | let path2 = "\(homePath)/.config/barik/config.toml" 23 | var chosenPath: String? 24 | 25 | if FileManager.default.fileExists(atPath: path1) { 26 | chosenPath = path1 27 | } else if FileManager.default.fileExists(atPath: path2) { 28 | chosenPath = path2 29 | } else { 30 | do { 31 | try createDefaultConfig(at: path1) 32 | chosenPath = path1 33 | } catch { 34 | initError = "Error creating default config: \(error.localizedDescription)" 35 | print("Error when creating default config:", error) 36 | return 37 | } 38 | } 39 | 40 | if let path = chosenPath { 41 | configFilePath = path 42 | parseConfigFile(at: path) 43 | startWatchingFile(at: path) 44 | } 45 | } 46 | 47 | private func parseConfigFile(at path: String) { 48 | do { 49 | let content = try String(contentsOfFile: path, encoding: .utf8) 50 | let decoder = TOMLDecoder() 51 | let rootToml = try decoder.decode(RootToml.self, from: content) 52 | DispatchQueue.main.async { 53 | self.config = Config(rootToml: rootToml) 54 | } 55 | } catch { 56 | initError = "Error parsing TOML file: \(error.localizedDescription)" 57 | print("Error when parsing TOML file:", error) 58 | } 59 | } 60 | 61 | private func createDefaultConfig(at path: String) throws { 62 | let defaultTOML = """ 63 | # If you installed yabai or aerospace without using Homebrew, 64 | # manually set the path to the binary. For example: 65 | # 66 | # yabai.path = "/run/current-system/sw/bin/yabai" 67 | # aerospace.path = ... 68 | 69 | theme = "system" # system, light, dark 70 | 71 | [widgets] 72 | displayed = [ # widgets on menu bar 73 | "default.spaces", 74 | "spacer", 75 | "default.network", 76 | "default.battery", 77 | "divider", 78 | # { "default.time" = { time-zone = "America/Los_Angeles", format = "E d, hh:mm" } }, 79 | "default.time" 80 | ] 81 | 82 | [widgets.default.spaces] 83 | space.show-key = true # show space number (or character, if you use AeroSpace) 84 | window.show-title = true 85 | window.title.max-length = 50 86 | 87 | [widgets.default.battery] 88 | show-percentage = true 89 | warning-level = 30 90 | critical-level = 10 91 | 92 | [widgets.default.time] 93 | format = "E d, J:mm" 94 | calendar.format = "J:mm" 95 | 96 | calendar.show-events = true 97 | # calendar.allow-list = ["Home", "Personal"] # show only these calendars 98 | # calendar.deny-list = ["Work", "Boss"] # show all calendars except these 99 | 100 | [popup.default.time] 101 | view-variant = "box" 102 | 103 | [background] 104 | enabled = true 105 | """ 106 | try defaultTOML.write(toFile: path, atomically: true, encoding: .utf8) 107 | } 108 | 109 | private func startWatchingFile(at path: String) { 110 | fileDescriptor = open(path, O_EVTONLY) 111 | if fileDescriptor == -1 { return } 112 | fileWatchSource = DispatchSource.makeFileSystemObjectSource( 113 | fileDescriptor: fileDescriptor, eventMask: .write, 114 | queue: DispatchQueue.global()) 115 | fileWatchSource?.setEventHandler { [weak self] in 116 | guard let self = self, let path = self.configFilePath else { 117 | return 118 | } 119 | self.parseConfigFile(at: path) 120 | } 121 | fileWatchSource?.setCancelHandler { [weak self] in 122 | if let fd = self?.fileDescriptor, fd != -1 { 123 | close(fd) 124 | } 125 | } 126 | fileWatchSource?.resume() 127 | } 128 | 129 | func updateConfigValue(key: String, newValue: String) { 130 | guard let path = configFilePath else { 131 | print("Config file path is not set") 132 | return 133 | } 134 | do { 135 | let currentText = try String(contentsOfFile: path, encoding: .utf8) 136 | let updatedText = updatedTOMLString( 137 | original: currentText, key: key, newValue: newValue) 138 | try updatedText.write( 139 | toFile: path, atomically: false, encoding: .utf8) 140 | DispatchQueue.main.async { 141 | self.parseConfigFile(at: path) 142 | } 143 | } catch { 144 | print("Error updating config:", error) 145 | } 146 | } 147 | 148 | private func updatedTOMLString( 149 | original: String, key: String, newValue: String 150 | ) -> String { 151 | if key.contains(".") { 152 | let components = key.split(separator: ".").map(String.init) 153 | guard components.count >= 2 else { 154 | return original 155 | } 156 | 157 | let tablePath = components.dropLast().joined(separator: ".") 158 | let actualKey = components.last! 159 | 160 | let tableHeader = "[\(tablePath)]" 161 | let lines = original.components(separatedBy: "\n") 162 | var newLines: [String] = [] 163 | var insideTargetTable = false 164 | var updatedKey = false 165 | var foundTable = false 166 | 167 | for line in lines { 168 | let trimmed = line.trimmingCharacters(in: .whitespaces) 169 | if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { 170 | if insideTargetTable && !updatedKey { 171 | newLines.append("\(actualKey) = \"\(newValue)\"") 172 | updatedKey = true 173 | } 174 | if trimmed == tableHeader { 175 | foundTable = true 176 | insideTargetTable = true 177 | } else { 178 | insideTargetTable = false 179 | } 180 | newLines.append(line) 181 | } else { 182 | if insideTargetTable && !updatedKey { 183 | let pattern = 184 | "^\(NSRegularExpression.escapedPattern(for: actualKey))\\s*=" 185 | if line.range(of: pattern, options: .regularExpression) 186 | != nil 187 | { 188 | newLines.append("\(actualKey) = \"\(newValue)\"") 189 | updatedKey = true 190 | continue 191 | } 192 | } 193 | newLines.append(line) 194 | } 195 | } 196 | 197 | if foundTable && insideTargetTable && !updatedKey { 198 | newLines.append("\(actualKey) = \"\(newValue)\"") 199 | } 200 | 201 | if !foundTable { 202 | newLines.append("") 203 | newLines.append("[\(tablePath)]") 204 | newLines.append("\(actualKey) = \"\(newValue)\"") 205 | } 206 | return newLines.joined(separator: "\n") 207 | } else { 208 | let lines = original.components(separatedBy: "\n") 209 | var newLines: [String] = [] 210 | var updatedAtLeastOnce = false 211 | 212 | for line in lines { 213 | let trimmed = line.trimmingCharacters(in: .whitespaces) 214 | if !trimmed.hasPrefix("#") { 215 | let pattern = 216 | "^\(NSRegularExpression.escapedPattern(for: key))\\s*=" 217 | if line.range(of: pattern, options: .regularExpression) 218 | != nil 219 | { 220 | newLines.append("\(key) = \"\(newValue)\"") 221 | updatedAtLeastOnce = true 222 | continue 223 | } 224 | } 225 | newLines.append(line) 226 | } 227 | if !updatedAtLeastOnce { 228 | newLines.append("\(key) = \"\(newValue)\"") 229 | } 230 | return newLines.joined(separator: "\n") 231 | } 232 | } 233 | 234 | func globalWidgetConfig(for widgetId: String) -> ConfigData { 235 | config.rootToml.widgets.config(for: widgetId) ?? [:] 236 | } 237 | 238 | func resolvedWidgetConfig(for item: TomlWidgetItem) -> ConfigData { 239 | let global = globalWidgetConfig(for: item.id) 240 | if item.inlineParams.isEmpty { 241 | return global 242 | } 243 | var merged = global 244 | for (key, value) in item.inlineParams { 245 | merged[key] = value 246 | } 247 | return merged 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /Barik/Utils/ImageCache.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Combine 3 | import SwiftUI 4 | 5 | // MARK: - Image Cache 6 | 7 | /// A singleton cache for storing downloaded NSImage objects. 8 | final class ImageCache { 9 | static let shared = NSCache() 10 | } 11 | 12 | // MARK: - Image Loader 13 | 14 | /// An observable object that asynchronously downloads and caches images. 15 | final class ImageLoader: ObservableObject { 16 | @Published var image: NSImage? 17 | 18 | private var cancellable: AnyCancellable? 19 | 20 | /// The URL of the image to load. 21 | var url: URL? 22 | 23 | /// Optional target size to which the image should be resized. 24 | var targetSize: CGSize? 25 | 26 | /// Initializes the loader with an optional URL and target size. 27 | /// - Parameters: 28 | /// - url: The URL of the image. 29 | /// - targetSize: The desired size for the image. 30 | init(url: URL?, targetSize: CGSize? = nil) { 31 | self.url = url 32 | self.targetSize = targetSize 33 | } 34 | 35 | /// Generates a cache key based on the URL and target size. 36 | private var cacheKey: NSString? { 37 | guard let url = url else { return nil } 38 | if let targetSize = targetSize { 39 | return "\(url.absoluteString)-\(Int(targetSize.width))x\(Int(targetSize.height))" as NSString 40 | } else { 41 | return url.absoluteString as NSString 42 | } 43 | } 44 | 45 | /// Loads the image from the URL, resizing if needed, and caches it. 46 | func load() { 47 | // Cancel any ongoing request before starting a new one. 48 | cancellable?.cancel() 49 | 50 | guard let url = url, let key = cacheKey else { return } 51 | 52 | // Check for cached image. 53 | if let cachedImage = ImageCache.shared.object(forKey: key) { 54 | self.image = cachedImage 55 | return 56 | } 57 | 58 | // Download image asynchronously. 59 | cancellable = URLSession.shared.dataTaskPublisher(for: url) 60 | .tryMap { [weak self] data, _ -> NSImage? in 61 | guard let downloadedImage = NSImage(data: data) else { return nil } 62 | if let targetSize = self?.targetSize { 63 | return downloadedImage.resized(to: targetSize) ?? downloadedImage 64 | } 65 | return downloadedImage 66 | } 67 | .replaceError(with: nil) 68 | .receive(on: DispatchQueue.main) 69 | .sink { [weak self] downloadedImage in 70 | if let downloadedImage = downloadedImage { 71 | ImageCache.shared.setObject(downloadedImage, forKey: key) 72 | } 73 | self?.image = downloadedImage 74 | } 75 | } 76 | 77 | deinit { 78 | cancellable?.cancel() 79 | } 80 | } 81 | 82 | // MARK: - NSImage Extension 83 | 84 | extension NSImage { 85 | /// Returns a resized version of the image. 86 | /// - Parameter newSize: The target size. 87 | /// - Returns: A new NSImage resized to the given dimensions, or nil if resizing fails. 88 | func resized(to newSize: NSSize) -> NSImage? { 89 | let newImage = NSImage(size: newSize) 90 | newImage.lockFocus() 91 | let rect = NSRect(origin: .zero, size: newSize) 92 | self.draw(in: rect, from: NSRect(origin: .zero, size: self.size), operation: .copy, fraction: 1.0) 93 | newImage.unlockFocus() 94 | newImage.size = newSize 95 | return newImage 96 | } 97 | } 98 | 99 | // MARK: - Rotate Animated Cached Image View 100 | 101 | /// A view that displays a cached image with a rotation and blur animation when the image changes. 102 | struct RotateAnimatedCachedImage: View { 103 | let url: URL? 104 | let targetSize: CGSize? 105 | 106 | @StateObject private var loader: ImageLoader 107 | @State private var displayedImage: NSImage? 108 | @State private var rotation: Double = 1 109 | let rotatingModifier: (Image) -> RotatingContent 110 | 111 | /// Initializes the view with a URL, optional target size, and a custom rotating modifier. 112 | init( 113 | url: URL?, 114 | targetSize: CGSize? = nil, 115 | @ViewBuilder rotatingModifier: @escaping (Image) -> RotatingContent 116 | ) { 117 | self.url = url 118 | self.targetSize = targetSize 119 | _loader = StateObject(wrappedValue: ImageLoader(url: url, targetSize: targetSize)) 120 | self.rotatingModifier = rotatingModifier 121 | } 122 | 123 | /// Convenience initializer when no custom modifier is needed. 124 | init(url: URL?, targetSize: CGSize? = nil) where RotatingContent == Image { 125 | self.init(url: url, targetSize: targetSize) { image in image } 126 | } 127 | 128 | var body: some View { 129 | Group { 130 | if let image = displayedImage { 131 | rotatingModifier(Image(nsImage: image).resizable()) 132 | .blur(radius: abs(1 - rotation) * 5) 133 | .scaleEffect(x: rotation) 134 | } else { 135 | Color.clear 136 | } 137 | } 138 | .onAppear { loader.load() } 139 | .onReceive(loader.$image) { newImage in 140 | guard let newImage = newImage else { return } 141 | // If image is loading for the first time. 142 | if displayedImage == nil { 143 | displayedImage = newImage 144 | } else if displayedImage != newImage { 145 | // Animate the transition. 146 | withAnimation(.easeInOut(duration: 0.2)) { rotation = 0 } 147 | withAnimation(.easeOut(duration: 0.3).delay(0.2)) { rotation = 1 } 148 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { 149 | displayedImage = newImage 150 | } 151 | } 152 | } 153 | .onChange(of: url) { _, newURL in 154 | loader.url = newURL 155 | loader.load() 156 | } 157 | } 158 | } 159 | 160 | // MARK: - Fade Animated Cached Image View 161 | 162 | /// A view that displays a cached image with a fade transition when the image changes. 163 | struct FadeAnimatedCachedImage: View { 164 | let url: URL? 165 | let targetSize: CGSize? 166 | 167 | @StateObject private var loader: ImageLoader 168 | @State private var currentImage: NSImage? 169 | @State private var nextImage: NSImage? 170 | @State private var showNextImage: Bool = false 171 | let content: (Image) -> Content 172 | 173 | /// Initializes the view with a URL, optional target size, and a custom content modifier. 174 | init( 175 | url: URL?, 176 | targetSize: CGSize? = nil, 177 | @ViewBuilder content: @escaping (Image) -> Content 178 | ) { 179 | self.url = url 180 | self.targetSize = targetSize 181 | _loader = StateObject(wrappedValue: ImageLoader(url: url, targetSize: targetSize)) 182 | self.content = content 183 | } 184 | 185 | /// Convenience initializer when no custom modifier is needed. 186 | init(url: URL?, targetSize: CGSize? = nil) where Content == Image { 187 | self.init(url: url, targetSize: targetSize) { image in image } 188 | } 189 | 190 | var body: some View { 191 | ZStack { 192 | if let currentImage = currentImage { 193 | content(Image(nsImage: currentImage)) 194 | } 195 | 196 | if let nextImage = nextImage { 197 | content(Image(nsImage: nextImage)) 198 | .opacity(showNextImage ? 1 : 0) 199 | } 200 | } 201 | .onAppear { loader.load() } 202 | .onReceive(loader.$image) { newImage in 203 | guard let newImage = newImage else { return } 204 | // Set the image for the first time. 205 | if currentImage == nil { 206 | currentImage = newImage 207 | } else if currentImage != newImage { 208 | // Animate the fade transition. 209 | nextImage = newImage 210 | withAnimation(.easeInOut(duration: 0.5)) { 211 | showNextImage = true 212 | } 213 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 214 | currentImage = newImage 215 | nextImage = nil 216 | showNextImage = false 217 | } 218 | } 219 | } 220 | .onChange(of: url) { _, newURL in 221 | loader.url = newURL 222 | loader.load() 223 | } 224 | } 225 | } 226 | 227 | // MARK: - Cached Image View 228 | 229 | /// A view that displays a cached image without animation. 230 | struct CachedImage: View { 231 | let url: URL? 232 | let targetSize: CGSize? 233 | 234 | @StateObject private var loader: ImageLoader 235 | @State private var displayedImage: NSImage? 236 | let content: (Image) -> Content 237 | 238 | /// Initializes the view with a URL and optional target size. 239 | init( 240 | url: URL?, 241 | targetSize: CGSize? = nil, 242 | @ViewBuilder content: @escaping (Image) -> Content 243 | ) { 244 | self.url = url 245 | self.targetSize = targetSize 246 | _loader = StateObject(wrappedValue: ImageLoader(url: url, targetSize: targetSize)) 247 | self.content = content 248 | } 249 | 250 | var body: some View { 251 | Group { 252 | if let image = displayedImage { 253 | Image(nsImage: image).resizable() 254 | } else { 255 | Color.clear 256 | } 257 | } 258 | .onAppear { loader.load() } 259 | .onReceive(loader.$image) { newImage in 260 | displayedImage = newImage 261 | } 262 | .onChange(of: url) { _, newURL in 263 | loader.url = newURL 264 | loader.load() 265 | } 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /Barik/Widgets/Time+Calendar/CalendarPopup.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | import SwiftUI 3 | 4 | struct CalendarPopup: View { 5 | let calendarManager: CalendarManager 6 | 7 | @ObservedObject var configProvider: ConfigProvider 8 | @State private var selectedVariant: MenuBarPopupVariant = .box 9 | 10 | var body: some View { 11 | MenuBarPopupVariantView( 12 | selectedVariant: selectedVariant, 13 | onVariantSelected: { variant in 14 | selectedVariant = variant 15 | ConfigManager.shared.updateConfigValue( 16 | key: "widgets.default.time.popup.view-variant", 17 | newValue: variant.rawValue 18 | ) 19 | }, 20 | box: { CalendarBoxPopup() }, 21 | vertical: { CalendarVerticalPopup(calendarManager) }, 22 | horizontal: { CalendarHorizontalPopup(calendarManager) } 23 | ) 24 | .onAppear { 25 | if let variantString = configProvider.config["popup"]? 26 | .dictionaryValue?["view-variant"]?.stringValue, 27 | let variant = MenuBarPopupVariant(rawValue: variantString) 28 | { 29 | selectedVariant = variant 30 | } else { 31 | selectedVariant = .box 32 | } 33 | } 34 | .onReceive(configProvider.$config) { newConfig in 35 | if let variantString = newConfig["popup"]?.dictionaryValue?[ 36 | "view-variant"]?.stringValue, 37 | let variant = MenuBarPopupVariant(rawValue: variantString) 38 | { 39 | selectedVariant = variant 40 | } 41 | } 42 | } 43 | } 44 | 45 | struct CalendarBoxPopup: View { 46 | var body: some View { 47 | VStack(spacing: 0) { 48 | Text(currentMonthYear) 49 | .font(.title2) 50 | .padding(.bottom, 25) 51 | WeekdayHeaderView() 52 | CalendarDaysView( 53 | weeks: weeks, 54 | currentYear: currentYear, 55 | currentMonth: currentMonth 56 | ) 57 | } 58 | .padding(30) 59 | .fontWeight(.semibold) 60 | .foregroundStyle(.white) 61 | } 62 | } 63 | 64 | struct CalendarVerticalPopup: View { 65 | let calendarManager: CalendarManager 66 | 67 | init(_ calendarManager: CalendarManager) { 68 | self.calendarManager = calendarManager 69 | } 70 | 71 | var body: some View { 72 | VStack(spacing: 0) { 73 | Text(currentMonthYear) 74 | .font(.title2) 75 | .padding(.bottom, 25) 76 | WeekdayHeaderView() 77 | CalendarDaysView( 78 | weeks: weeks, 79 | currentYear: currentYear, 80 | currentMonth: currentMonth 81 | ) 82 | 83 | Group { 84 | if calendarManager.todaysEvents.isEmpty && calendarManager.tomorrowsEvents.isEmpty { 85 | Text(NSLocalizedString("EMPTY_EVENTS", comment: "")) 86 | .frame(maxWidth: .infinity, alignment: .center) 87 | .font(.callout) 88 | .padding(.top, 3) 89 | } 90 | EventListView( 91 | todaysEvents: calendarManager.todaysEvents, 92 | tomorrowsEvents: calendarManager.tomorrowsEvents 93 | ) 94 | } 95 | .frame(width: 255) 96 | .padding(.top, 20) 97 | } 98 | .padding(.horizontal, 20) 99 | .padding(.vertical, 30) 100 | .fontWeight(.semibold) 101 | .foregroundStyle(.white) 102 | } 103 | } 104 | 105 | struct CalendarHorizontalPopup: View { 106 | let calendarManager: CalendarManager 107 | 108 | init(_ calendarManager: CalendarManager) { 109 | self.calendarManager = calendarManager 110 | } 111 | 112 | var body: some View { 113 | HStack(alignment: .top, spacing: 0) { 114 | VStack(alignment: .leading, spacing: 0) { 115 | Text(currentMonthYear) 116 | .font(.title2) 117 | .padding(.bottom, 25) 118 | .fixedSize(horizontal: true, vertical: false) 119 | WeekdayHeaderView() 120 | CalendarDaysView( 121 | weeks: weeks, 122 | currentYear: currentYear, 123 | currentMonth: currentMonth 124 | ) 125 | } 126 | 127 | Group { 128 | if calendarManager.todaysEvents.isEmpty && calendarManager.tomorrowsEvents.isEmpty { 129 | Text(NSLocalizedString("EMPTY_EVENTS", comment: "")) 130 | .frame(maxWidth: .infinity, alignment: .leading) 131 | .font(.callout) 132 | } 133 | EventListView( 134 | todaysEvents: calendarManager.todaysEvents, 135 | tomorrowsEvents: calendarManager.tomorrowsEvents 136 | ) 137 | } 138 | .frame(width: 255) 139 | .padding(.leading, 30) 140 | } 141 | .padding(.horizontal, 30) 142 | .padding(.vertical, 30) 143 | .fontWeight(.semibold) 144 | .foregroundStyle(.white) 145 | } 146 | } 147 | 148 | private var currentMonthYear: String { 149 | let formatter = DateFormatter() 150 | formatter.dateFormat = "LLLL yyyy" 151 | return formatter.string(from: Date()).capitalized 152 | } 153 | 154 | private var currentMonth: Int { 155 | Calendar.current.component(.month, from: Date()) 156 | } 157 | 158 | private var currentYear: Int { 159 | Calendar.current.component(.year, from: Date()) 160 | } 161 | 162 | private var calendarDays: [Int?] { 163 | let calendar = Calendar.current 164 | let date = Date() 165 | guard 166 | let range = calendar.range(of: .day, in: .month, for: date), 167 | let firstOfMonth = calendar.date( 168 | from: calendar.dateComponents([.year, .month], from: date) 169 | ) 170 | else { 171 | return [] 172 | } 173 | let startOfMonthWeekday = calendar.component(.weekday, from: firstOfMonth) 174 | let blanks = (startOfMonthWeekday - calendar.firstWeekday + 7) % 7 175 | var days: [Int?] = Array(repeating: nil, count: blanks) 176 | days.append(contentsOf: range.map { $0 }) 177 | return days 178 | } 179 | 180 | private var weeks: [[Int?]] { 181 | var days = calendarDays 182 | let remainder = days.count % 7 183 | if remainder != 0 { 184 | days.append(contentsOf: Array(repeating: nil, count: 7 - remainder)) 185 | } 186 | return stride(from: 0, to: days.count, by: 7).map { 187 | Array(days[$0.. Bool { 269 | let calendar = Calendar.current 270 | let components = calendar.dateComponents([.year, .month], from: Date()) 271 | if let dateFromDay = calendar.date( 272 | from: DateComponents( 273 | year: components.year, 274 | month: components.month, 275 | day: day 276 | ) 277 | ) { 278 | return calendar.isDateInToday(dateFromDay) 279 | } 280 | return false 281 | } 282 | } 283 | 284 | private struct EventListView: View { 285 | let todaysEvents: [EKEvent] 286 | let tomorrowsEvents: [EKEvent] 287 | 288 | var body: some View { 289 | if !todaysEvents.isEmpty || !tomorrowsEvents.isEmpty { 290 | VStack(spacing: 10) { 291 | eventSection( 292 | title: NSLocalizedString("TODAY", comment: "").uppercased(), 293 | events: todaysEvents) 294 | eventSection( 295 | title: NSLocalizedString("TOMORROW", comment: "") 296 | .uppercased(), events: tomorrowsEvents) 297 | } 298 | } 299 | } 300 | 301 | @ViewBuilder 302 | func eventSection(title: String, events: [EKEvent]) -> some View { 303 | if !events.isEmpty { 304 | VStack(alignment: .leading, spacing: 8) { 305 | Text(title) 306 | .font(.subheadline) 307 | .foregroundStyle(.gray) 308 | ForEach(events, id: \.eventIdentifier) { event in 309 | EventRow(event: event) 310 | } 311 | } 312 | } 313 | } 314 | } 315 | 316 | private struct EventRow: View { 317 | let event: EKEvent 318 | 319 | var body: some View { 320 | let eventTime = getEventTime(event) 321 | HStack(spacing: 4) { 322 | Rectangle() 323 | .fill(Color(event.calendar.cgColor)) 324 | .frame(width: 3, height: 30) 325 | .clipShape(Capsule()) 326 | VStack(alignment: .leading) { 327 | Text(event.title) 328 | .font(.headline) 329 | .lineLimit(1) 330 | Text(eventTime) 331 | .font(.caption) 332 | .fontWeight(.regular) 333 | .lineLimit(1) 334 | } 335 | Spacer() 336 | } 337 | .padding(5) 338 | .padding(.trailing, 5) 339 | .foregroundStyle(Color(event.calendar.cgColor)) 340 | .background(Color(event.calendar.cgColor).opacity(0.2)) 341 | .cornerRadius(6) 342 | .frame(maxWidth: .infinity) 343 | } 344 | 345 | func getEventTime(_ event: EKEvent) -> String { 346 | var text = "" 347 | if !event.isAllDay { 348 | let formatter = DateFormatter() 349 | formatter.setLocalizedDateFormatFromTemplate("j:mm") 350 | text += formatter.string(from: event.startDate).replacing(":00", with: "") 351 | text += " — " 352 | text += formatter.string(from: event.endDate).replacing(":00", with: "") 353 | } else { 354 | return NSLocalizedString("ALL_DAY", comment: "") 355 | } 356 | return text 357 | } 358 | } 359 | 360 | struct CalendarPopup_Previews: PreviewProvider { 361 | var configProvider: ConfigProvider = ConfigProvider(config: ConfigData()) 362 | var calendarManager: CalendarManager 363 | 364 | init() { 365 | self.calendarManager = CalendarManager(configProvider: configProvider) 366 | } 367 | 368 | static var previews: some View { 369 | let configProvider = ConfigProvider(config: ConfigData()) 370 | let calendarManager = CalendarManager(configProvider: configProvider) 371 | 372 | CalendarBoxPopup() 373 | .background(Color.black) 374 | .previewLayout(.sizeThatFits) 375 | .previewDisplayName("Box") 376 | CalendarVerticalPopup(calendarManager) 377 | .background(Color.black) 378 | .frame(height: 600) 379 | .previewDisplayName("Vertical") 380 | CalendarHorizontalPopup(calendarManager) 381 | .background(Color.black) 382 | .previewLayout(.sizeThatFits) 383 | .previewDisplayName("Horizontal") 384 | } 385 | } 386 | --------------------------------------------------------------------------------