├── .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 | 
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 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
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 |

30 |

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 |
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 | [](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 |
--------------------------------------------------------------------------------