diff --git a/Barik/Config/ConfigManager.swift b/Barik/Config/ConfigManager.swift index 70af4d6..fb6c838 100644 --- a/Barik/Config/ConfigManager.swift +++ b/Barik/Config/ConfigManager.swift @@ -51,6 +51,9 @@ final class ConfigManager: ObservableObject { let rootToml = try decoder.decode(RootToml.self, from: content) DispatchQueue.main.async { self.config = Config(rootToml: rootToml) + + // Notify about config change for widget activation + NotificationCenter.default.post(name: NSNotification.Name("ConfigChanged"), object: nil) } } catch { initError = "Error parsing TOML file: \(error.localizedDescription)" @@ -74,6 +77,9 @@ final class ConfigManager: ObservableObject { "spacer", "default.network", "default.battery", + "default.cpuram", + "default.networkactivity", + "default.performance", "divider", # { "default.time" = { time-zone = "America/Los_Angeles", format = "E d, hh:mm" } }, "default.time" @@ -93,10 +99,25 @@ final class ConfigManager: ObservableObject { format = "E d, J:mm" calendar.format = "J:mm" - calendar.show-events = true + calendar.show-events = false # calendar.allow-list = ["Home", "Personal"] # show only these calendars # calendar.deny-list = ["Work", "Boss"] # show all calendars except these + [widgets.default.cpuram] + show-icon = false + cpu-warning-level = 70 + cpu-critical-level = 90 + ram-warning-level = 70 + ram-critical-level = 90 + + [widgets.default.networkactivity] + # No specific configuration options yet + + [widgets.default.performance] + # Performance mode widget - replaces volume widget + # Controls energy consumption by adjusting update intervals + # Modes: battery-saver (default), balanced, max-performance + [popup.default.time] view-variant = "box" diff --git a/Barik/Config/ConfigModels.swift b/Barik/Config/ConfigModels.swift index d5f16a1..7a71acd 100644 --- a/Barik/Config/ConfigModels.swift +++ b/Barik/Config/ConfigModels.swift @@ -246,23 +246,31 @@ struct AerospaceConfig: Decodable { } } +enum MenuBarPosition: String, Decodable, CaseIterable { + case top = "top" + case bottom = "bottom" +} + struct ExperimentalConfig: Decodable { let foreground: ForegroundConfig let background: BackgroundConfig + let position: MenuBarPosition enum CodingKeys: String, CodingKey { - case foreground, background + case foreground, background, position } init() { self.foreground = ForegroundConfig() self.background = BackgroundConfig() + self.position = .top } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) foreground = try container.decodeIfPresent(ForegroundConfig.self, forKey: .foreground) ?? ForegroundConfig() background = try container.decodeIfPresent(BackgroundConfig.self, forKey: .background) ?? BackgroundConfig() + position = try container.decodeIfPresent(MenuBarPosition.self, forKey: .position) ?? .top } } diff --git a/Barik/MenuBarPopup/MenuBarPopup.swift b/Barik/MenuBarPopup/MenuBarPopup.swift index ef7a111..c5fc1be 100644 --- a/Barik/MenuBarPopup/MenuBarPopup.swift +++ b/Barik/MenuBarPopup/MenuBarPopup.swift @@ -77,7 +77,7 @@ class MenuBarPopup { MenuBarPopupView { content() } - .position(x: rect.midX) + .position(x: rect.midX, y: rect.midY) } .frame(maxWidth: .infinity, maxHeight: .infinity) .id(UUID()) @@ -95,7 +95,7 @@ class MenuBarPopup { MenuBarPopupView { content() } - .position(x: rect.midX) + .position(x: rect.midX, y: rect.midY) } .frame(maxWidth: .infinity, maxHeight: .infinity) ) diff --git a/Barik/MenuBarPopup/MenuBarPopupView.swift b/Barik/MenuBarPopup/MenuBarPopupView.swift index 8cd889b..b7ba14e 100644 --- a/Barik/MenuBarPopup/MenuBarPopupView.swift +++ b/Barik/MenuBarPopup/MenuBarPopupView.swift @@ -30,12 +30,14 @@ struct MenuBarPopupView: View { } var body: some View { - ZStack(alignment: .topTrailing) { + let isBottom = configManager.config.experimental.position == .bottom + + ZStack(alignment: isBottom ? .bottomTrailing : .topTrailing) { content .background(Color.black) .cornerRadius(((1.0 - animationValue) * 1) + 40) - .padding(.top, foregroundHeight + 5) - .offset(x: computedOffset, y: computedYOffset) + .padding(isBottom ? .bottom : .top, foregroundHeight + 5) + .offset(x: computedOffset, y: isBottom ? -computedYOffset : computedYOffset) .shadow(radius: 30) .blur(radius: (1.0 - (0.1 + 0.9 * animationValue)) * 20) .scaleEffect(x: 0.2 + 0.8 * animationValue, y: animationValue) @@ -151,7 +153,15 @@ struct MenuBarPopupView: View { } var computedYOffset: CGFloat { - return viewFrame.height / 2 + let isBottom = configManager.config.experimental.position == .bottom + + if isBottom { + // For bottom positioning, use the original logic + return viewFrame.height / 2 + } else { + // For top positioning, reduce the offset to bring popups closer + return viewFrame.height / 2 - 65 // Bring closer to top menu bar + } } } diff --git a/Barik/Resources/Localizable.xcstrings b/Barik/Resources/Localizable.xcstrings index ca19551..1aaddf1 100644 --- a/Barik/Resources/Localizable.xcstrings +++ b/Barik/Resources/Localizable.xcstrings @@ -3,9 +3,15 @@ "strings" : { "?%@?" : { "shouldTranslate" : false + }, + "%@ GB" : { + }, "%lld" : { "shouldTranslate" : false + }, + "%lld%%" : { + }, "ALL_DAY" : { "extractionState" : "manual", @@ -131,6 +137,9 @@ } } } + }, + "Changes update intervals for widgets to optimize energy consumption" : { + }, "Channel: %@" : { "localizations" : { @@ -255,6 +264,21 @@ } } } + }, + "CPU" : { + + }, + "CPU Usage" : { + + }, + "Current keyboard layout: %@" : { + + }, + "Current: %@" : { + + }, + "Download" : { + }, "EMPTY_EVENTS" : { "extractionState" : "manual", @@ -504,6 +528,24 @@ } } } + }, + "Idle" : { + + }, + "Keyboard Layout" : { + + }, + "Memory Usage" : { + + }, + "Mute" : { + + }, + "Network Activity" : { + + }, + "No additional layouts available" : { + }, "Noise: %lld" : { "localizations" : { @@ -628,6 +670,15 @@ } } } + }, + "Performance Mode" : { + + }, + "Performance Mode: %@" : { + + }, + "RAM" : { + }, "RSSI: %lld" : { "localizations" : { @@ -876,6 +927,15 @@ } } } + }, + "Switch to Next Layout" : { + + }, + "System" : { + + }, + "System Monitor" : { + }, "TODAY" : { "extractionState" : "manual", @@ -1126,6 +1186,9 @@ } } } + }, + "Unmute" : { + }, "Update" : { "localizations" : { @@ -1374,6 +1437,12 @@ } } } + }, + "Upload" : { + + }, + "User" : { + }, "v%@ Changelog" : { "localizations" : { @@ -1498,6 +1567,9 @@ } } } + }, + "Volume" : { + }, "What's new" : { "localizations" : { diff --git a/Barik/Utils/WidgetActivationManager.swift b/Barik/Utils/WidgetActivationManager.swift new file mode 100644 index 0000000..2e3f5f1 --- /dev/null +++ b/Barik/Utils/WidgetActivationManager.swift @@ -0,0 +1,93 @@ +import Foundation +import SwiftUI + +/// Manages which widgets are actively displayed and controls their lifecycle +class WidgetActivationManager: ObservableObject { + static let shared = WidgetActivationManager() + + @Published private(set) var activeWidgets: Set = [] + private let configManager = ConfigManager.shared + + private init() { + // Force immediate update on initialization + updateActiveWidgets() + + // Listen for config changes + NotificationCenter.default.addObserver( + self, + selector: #selector(configDidChange), + name: NSNotification.Name("ConfigChanged"), + object: nil + ) + + // Trigger initial activation after a brief delay to ensure everything is ready + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.updateActiveWidgets() + } + } + + @objc private func configDidChange() { + updateActiveWidgets() + } + + private func updateActiveWidgets() { + let displayedWidgets = configManager.config.rootToml.widgets.displayed + let newActiveWidgets = Set(displayedWidgets.map { $0.id }) + + print("WidgetActivationManager: Active widgets updated to: \(newActiveWidgets)") + + DispatchQueue.main.async { + self.activeWidgets = newActiveWidgets + } + + // Notify about widget activation changes + NotificationCenter.default.post( + name: NSNotification.Name("WidgetActivationChanged"), + object: newActiveWidgets + ) + } + + /// Checks if a widget with the given ID is currently active (displayed) + func isWidgetActive(_ widgetId: String) -> Bool { + let isActive = activeWidgets.contains(widgetId) + print("WidgetActivationManager: Checking if \(widgetId) is active: \(isActive)") + return isActive + } + + /// Get all active widget IDs + func getActiveWidgets() -> Set { + return activeWidgets + } +} + +/// A protocol for widgets that can be conditionally activated +protocol ConditionallyActivatableWidget { + func activate() + func deactivate() + var widgetId: String { get } +} + +/// Extension to help with conditional widget activation +extension ObservableObject { + func activateIfNeeded(widgetId: String) { + let activationManager = WidgetActivationManager.shared + + // Only activate if the widget is being displayed + if activationManager.isWidgetActive(widgetId) { + if let activatable = self as? ConditionallyActivatableWidget { + activatable.activate() + } + } + } + + func deactivateIfNeeded(widgetId: String) { + let activationManager = WidgetActivationManager.shared + + // Deactivate if the widget is not being displayed + if !activationManager.isWidgetActive(widgetId) { + if let activatable = self as? ConditionallyActivatableWidget { + activatable.deactivate() + } + } + } +} \ No newline at end of file diff --git a/Barik/Views/BackgroundView.swift b/Barik/Views/BackgroundView.swift index 3a3fc42..eceb6d5 100644 --- a/Barik/Views/BackgroundView.swift +++ b/Barik/Views/BackgroundView.swift @@ -3,7 +3,7 @@ import SwiftUI struct BackgroundView: View { @ObservedObject var configManager = ConfigManager.shared - private func spacer(_ geometry: GeometryProxy) -> some View { + private func menuBarBlurView(_ geometry: GeometryProxy) -> some View { let theme: ColorScheme? = { switch configManager.config.rootToml.theme { case "dark": return .dark @@ -12,26 +12,74 @@ struct BackgroundView: View { } }() - let height = configManager.config.experimental.background.resolveHeight() + // menu bar height // change the last number to change the height of the menu bar + let menuBarHeight = (configManager.config.experimental.foreground.resolveHeight() ?? 32) - 6 - return Color.clear - .frame(height: height ?? geometry.size.height) - .preferredColorScheme(theme) + let isBottom = configManager.config.experimental.position == .bottom + return VStack(spacing: 0) { + if !isBottom { + // Top positioning (original) + // Smooth menu bar blur with rounded edges + if configManager.config.experimental.background.black { + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.6)) + .frame(height: menuBarHeight) + .padding(.horizontal, 6) + .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 1) + } else { + RoundedRectangle(cornerRadius: 12) + .fill(Color.clear) + .background( + configManager.config.experimental.background.blur, + in: RoundedRectangle(cornerRadius: 12) + ) + .frame(height: menuBarHeight) + .padding(.horizontal, 6) + .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 2) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.1), lineWidth: 0.5) + .padding(.horizontal, 6) + ) + } + Spacer() + } else { + // Bottom positioning + Spacer() + if configManager.config.experimental.background.black { + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.6)) + .frame(height: menuBarHeight) + .padding(.horizontal, 6) + .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: -1) + } else { + RoundedRectangle(cornerRadius: 12) + .fill(Color.clear) + .background( + configManager.config.experimental.background.blur, + in: RoundedRectangle(cornerRadius: 12) + ) + .frame(height: menuBarHeight) + .padding(.horizontal, 6) + .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: -2) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.1), lineWidth: 0.5) + .padding(.horizontal, 6) + ) + } + } + } + .preferredColorScheme(theme) + .animation(.easeInOut(duration: 0.3), value: configManager.config.experimental.background.black) } var body: some View { if configManager.config.experimental.background.displayed { GeometryReader { geometry in - if configManager.config.experimental.background.black { - spacer(geometry) - .background(.black) - .id("black") - } else { - spacer(geometry) - .background(configManager.config.experimental.background.blur) - .id("blur") - } + menuBarBlurView(geometry) + .id("refined-blur") } } } diff --git a/Barik/Views/MenuBarView.swift b/Barik/Views/MenuBarView.swift index 31081ab..f4ec697 100644 --- a/Barik/Views/MenuBarView.swift +++ b/Barik/Views/MenuBarView.swift @@ -30,7 +30,7 @@ struct MenuBarView: View { } .foregroundStyle(Color.foregroundOutside) .frame(height: max(configManager.config.experimental.foreground.resolveHeight(), 1.0)) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: configManager.config.experimental.position == .bottom ? .bottom : .top) .padding(.horizontal, configManager.config.experimental.foreground.horizontalPadding) .background(.black.opacity(0.001)) .preferredColorScheme(theme) @@ -59,6 +59,22 @@ struct MenuBarView: View { NowPlayingWidget() .environmentObject(config) + case "default.cpuram": + CPURAMWidget() + .environmentObject(config) + + case "default.networkactivity": + NetworkActivityWidget() + .environmentObject(config) + + case "default.performance": + PerformanceModeWidget() + .environmentObject(config) + + case "default.keyboardlayout": + KeyboardLayoutWidget() + .environmentObject(config) + case "spacer": Spacer().frame(minWidth: 50, maxWidth: .infinity) diff --git a/Barik/Widgets/AudioVisual/AudioVisualManager.swift b/Barik/Widgets/AudioVisual/AudioVisualManager.swift new file mode 100644 index 0000000..9cc8acb --- /dev/null +++ b/Barik/Widgets/AudioVisual/AudioVisualManager.swift @@ -0,0 +1,221 @@ +import Combine +import Foundation +import CoreAudio +import IOKit +import IOKit.graphics + +/// Central manager for audio and visual system controls. +class AudioVisualManager: ObservableObject { + @Published var volumeLevel: Float = 0.0 + @Published var isMuted: Bool = false + + private var timer: Timer? + private var audioObjectPropertyAddress: AudioObjectPropertyAddress + + init() { + audioObjectPropertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyVolumeScalar, + mScope: kAudioObjectPropertyScopeOutput, + mElement: kAudioObjectPropertyElementMain + ) + startMonitoring() + } + + deinit { + stopMonitoring() + } + + private func startMonitoring() { + // Update every 10 seconds for optimal energy efficiency + timer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { [weak self] _ in + self?.updateStatus() + } + updateStatus() + } + + private func stopMonitoring() { + timer?.invalidate() + timer = nil + } + + /// Updates all audio and visual status properties + private func updateStatus() { + self.updateVolumeStatus() + } + + /// Updates volume level and mute status + private func updateVolumeStatus() { + // Run audio API calls on background queue to avoid blocking + DispatchQueue.global(qos: .utility).async { [weak self] in + guard let self = self else { return } + + var outputDeviceID: AudioDeviceID = kAudioObjectUnknown + var propertySize = UInt32(MemoryLayout.size) + + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + let result = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + 0, + nil, + &propertySize, + &outputDeviceID + ) + + guard result == noErr && outputDeviceID != kAudioObjectUnknown else { + DispatchQueue.main.async { + self.volumeLevel = 0.0 + self.isMuted = false + } + return + } + + // Get volume level - using master volume property + var volume: Float32 = 0.0 + propertySize = UInt32(MemoryLayout.size) + propertyAddress.mSelector = kAudioDevicePropertyVolumeScalar + propertyAddress.mScope = kAudioObjectPropertyScopeOutput + propertyAddress.mElement = kAudioObjectPropertyElementMain + + let volumeResult = AudioObjectGetPropertyData( + outputDeviceID, + &propertyAddress, + 0, + nil, + &propertySize, + &volume + ) + + // Get mute status + var muteValue: UInt32 = 0 + propertySize = UInt32(MemoryLayout.size) + propertyAddress.mSelector = kAudioDevicePropertyMute + propertyAddress.mScope = kAudioObjectPropertyScopeOutput + propertyAddress.mElement = kAudioObjectPropertyElementMain + + let muteResult = AudioObjectGetPropertyData( + outputDeviceID, + &propertyAddress, + 0, + nil, + &propertySize, + &muteValue + ) + + // Update UI on main queue + DispatchQueue.main.async { + if volumeResult == noErr { + self.volumeLevel = max(0.0, min(1.0, volume)) + } else { + // Fallback: keep current volume or set to reasonable default + if self.volumeLevel == 0.0 { + self.volumeLevel = 0.5 + } + } + + if muteResult == noErr { + self.isMuted = muteValue != 0 + } else { + // If we can't determine mute status, assume not muted + self.isMuted = false + } + } + } + } + + + + // MARK: - Control Methods + + /// Sets the system volume level + func setVolume(level: Float) { + let clampedLevel = max(0.0, min(1.0, level)) + + var outputDeviceID: AudioDeviceID = kAudioObjectUnknown + var propertySize = UInt32(MemoryLayout.size) + + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + let result = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + 0, + nil, + &propertySize, + &outputDeviceID + ) + + guard result == noErr else { return } + + var volume = Float32(clampedLevel) + propertySize = UInt32(MemoryLayout.size) + propertyAddress.mSelector = kAudioDevicePropertyVolumeScalar + propertyAddress.mScope = kAudioObjectPropertyScopeOutput + propertyAddress.mElement = kAudioObjectPropertyElementMain + + AudioObjectSetPropertyData( + outputDeviceID, + &propertyAddress, + 0, + nil, + propertySize, + &volume + ) + + DispatchQueue.main.async { + self.volumeLevel = clampedLevel + } + } + + /// Toggles the mute status + func toggleMute() { + var outputDeviceID: AudioDeviceID = kAudioObjectUnknown + var propertySize = UInt32(MemoryLayout.size) + + var propertyAddress = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + + let result = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &propertyAddress, + 0, + nil, + &propertySize, + &outputDeviceID + ) + + guard result == noErr else { return } + + let newMuteValue: UInt32 = isMuted ? 0 : 1 + var muteValue = newMuteValue + propertySize = UInt32(MemoryLayout.size) + propertyAddress.mSelector = kAudioDevicePropertyMute + + AudioObjectSetPropertyData( + outputDeviceID, + &propertyAddress, + 0, + nil, + propertySize, + &muteValue + ) + + DispatchQueue.main.async { + self.isMuted = newMuteValue != 0 + } + } + + +} \ No newline at end of file diff --git a/Barik/Widgets/AudioVisual/VolumePopup.swift b/Barik/Widgets/AudioVisual/VolumePopup.swift new file mode 100644 index 0000000..2ea6e41 --- /dev/null +++ b/Barik/Widgets/AudioVisual/VolumePopup.swift @@ -0,0 +1,111 @@ +import SwiftUI + +struct VolumePopup: View { + @StateObject private var audioVisualManager = AudioVisualManager() + + var body: some View { + VStack(spacing: 20) { + // Header with icon and title + HStack(spacing: 12) { + Image(systemName: volumeIcon) + .font(.system(size: 24, weight: .medium)) + .foregroundStyle(volumeColor) + .frame(width: 30, height: 30) + .animation(.easeInOut(duration: 0.3), value: audioVisualManager.isMuted) + + VStack(alignment: .leading, spacing: 2) { + Text("Volume") + .font(.headline) + .fontWeight(.semibold) + + Text(volumeStatusText) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.7)) + } + + Spacer() + } + + // Volume control section + VStack(spacing: 12) { + // Volume slider + HStack(spacing: 12) { + Image(systemName: "speaker.wave.1") + .font(.system(size: 14)) + .foregroundStyle(.white.opacity(0.6)) + + Slider( + value: Binding( + get: { audioVisualManager.volumeLevel }, + set: { audioVisualManager.setVolume(level: $0) } + ), + in: 0...1 + ) + .accentColor(.white) + .disabled(audioVisualManager.isMuted) + + Image(systemName: "speaker.wave.3") + .font(.system(size: 14)) + .foregroundStyle(.white.opacity(0.6)) + } + + // Mute toggle button + Button(action: { + audioVisualManager.toggleMute() + }) { + HStack(spacing: 8) { + Image(systemName: audioVisualManager.isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") + .font(.system(size: 16, weight: .medium)) + + Text(audioVisualManager.isMuted ? "Unmute" : "Mute") + .font(.system(size: 14, weight: .medium)) + } + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(audioVisualManager.isMuted ? .red.opacity(0.3) : .white.opacity(0.2)) + ) + } + .buttonStyle(PlainButtonStyle()) + .animation(.easeInOut(duration: 0.2), value: audioVisualManager.isMuted) + } + } + .padding(25) + .frame(width: 280) + .foregroundStyle(.white) + } + + private var volumeIcon: String { + if audioVisualManager.isMuted { + return "speaker.slash.fill" + } else if audioVisualManager.volumeLevel < 0.33 { + return "speaker.wave.1.fill" + } else if audioVisualManager.volumeLevel < 0.66 { + return "speaker.wave.2.fill" + } else { + return "speaker.wave.3.fill" + } + } + + private var volumeColor: Color { + return audioVisualManager.isMuted ? .red.opacity(0.8) : .white + } + + private var volumeStatusText: String { + if audioVisualManager.isMuted { + return "Muted" + } else { + return "\(Int(audioVisualManager.volumeLevel * 100))%" + } + } +} + +struct VolumePopup_Previews: PreviewProvider { + static var previews: some View { + VolumePopup() + .background(Color.black) + .previewLayout(.sizeThatFits) + } +} \ No newline at end of file diff --git a/Barik/Widgets/AudioVisual/VolumeWidget.swift b/Barik/Widgets/AudioVisual/VolumeWidget.swift new file mode 100644 index 0000000..900dc3a --- /dev/null +++ b/Barik/Widgets/AudioVisual/VolumeWidget.swift @@ -0,0 +1,68 @@ +import SwiftUI + +struct VolumeWidget: View { + @EnvironmentObject var configProvider: ConfigProvider + var config: ConfigData { configProvider.config } + var showPercentage: Bool { config["show-percentage"]?.boolValue ?? false } + + @StateObject private var audioVisualManager = AudioVisualManager() + @State private var rect: CGRect = .zero + + var body: some View { + HStack(spacing: 4) { + Image(systemName: volumeIcon) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(volumeColor) + .animation(.easeInOut(duration: 0.3), value: audioVisualManager.isMuted) + + if showPercentage { + Text("\(Int(audioVisualManager.volumeLevel * 100))%") + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(.foregroundOutside) + .transition(.blurReplace) + } + } + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + rect = geometry.frame(in: .global) + } + .onChange(of: geometry.frame(in: .global)) { oldState, newState in + rect = newState + } + } + ) + .experimentalConfiguration(cornerRadius: 15) + .frame(maxHeight: .infinity) + .background(.black.opacity(0.001)) + .onTapGesture { + MenuBarPopup.show(rect: rect, id: "volume") { VolumePopup() } + } + } + + private var volumeIcon: String { + if audioVisualManager.isMuted { + return "speaker.slash.fill" + } else if audioVisualManager.volumeLevel < 0.33 { + return "speaker.wave.1.fill" + } else if audioVisualManager.volumeLevel < 0.66 { + return "speaker.wave.2.fill" + } else { + return "speaker.wave.3.fill" + } + } + + private var volumeColor: Color { + return audioVisualManager.isMuted ? .red.opacity(0.8) : .icon + } +} + +struct VolumeWidget_Previews: PreviewProvider { + static var previews: some View { + VolumeWidget() + .background(.black) + .environmentObject(ConfigProvider(config: [:])) + .previewLayout(.sizeThatFits) + } +} \ No newline at end of file diff --git a/Barik/Widgets/Battery/BatteryManager.swift b/Barik/Widgets/Battery/BatteryManager.swift index 7d4e700..df0033a 100644 --- a/Barik/Widgets/Battery/BatteryManager.swift +++ b/Barik/Widgets/Battery/BatteryManager.swift @@ -3,23 +3,90 @@ import Foundation import IOKit.ps /// This class monitors the battery status. -class BatteryManager: ObservableObject { +class BatteryManager: ObservableObject, ConditionallyActivatableWidget { @Published var batteryLevel: Int = 0 @Published var isCharging: Bool = false @Published var isPluggedIn: Bool = false private var timer: Timer? + + private var currentInterval: TimeInterval = 30.0 + let widgetId = "default.battery" + + private var isActive = false init() { - startMonitoring() + setupNotifications() + // For now, always activate to ensure widgets work + activate() } deinit { stopMonitoring() + NotificationCenter.default.removeObserver(self) + } + + private func setupNotifications() { + // Listen for performance mode changes + NotificationCenter.default.addObserver( + forName: NSNotification.Name("PerformanceModeChanged"), + object: nil, + queue: .main + ) { [weak self] notification in + if let intervals = notification.object as? [String: TimeInterval], + let newInterval = intervals["battery"] { + self?.updateTimerInterval(newInterval) + } + } + + // Listen for widget activation changes + NotificationCenter.default.addObserver( + forName: NSNotification.Name("WidgetActivationChanged"), + object: nil, + queue: .main + ) { [weak self] notification in + if let activeWidgets = notification.object as? Set { + if activeWidgets.contains(self?.widgetId ?? "") { + self?.activate() + } else { + self?.deactivate() + } + } + } + } + + func activate() { + guard !isActive else { + return + } + + isActive = true + + // Get current performance mode interval + let performanceManager = PerformanceModeManager.shared + let intervals = performanceManager.getTimerIntervals(for: performanceManager.currentMode) + currentInterval = intervals["battery"] ?? 30.0 + + startMonitoring() + } + + func deactivate() { + guard isActive else { return } + isActive = false + stopMonitoring() + } + + private func updateTimerInterval(_ newInterval: TimeInterval) { + guard isActive else { return } + currentInterval = newInterval + + // Restart timer with new interval + stopMonitoring() + startMonitoring() } private func startMonitoring() { - // Update every 1 second. - timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { + // Update every X seconds based on performance mode + timer = Timer.scheduledTimer(withTimeInterval: currentInterval, repeats: true) { [weak self] _ in self?.updateBatteryStatus() } diff --git a/Barik/Widgets/Information/KeyboardLayoutManager.swift b/Barik/Widgets/Information/KeyboardLayoutManager.swift new file mode 100644 index 0000000..8ca4303 --- /dev/null +++ b/Barik/Widgets/Information/KeyboardLayoutManager.swift @@ -0,0 +1,165 @@ +import Combine +import Foundation +import Carbon +import InputMethodKit + +/// Manages keyboard input source monitoring and switching. +class KeyboardLayoutManager: ObservableObject { + @Published var currentInputSource: String = "EN" + @Published var availableInputSources: [String] = [] + + private var timer: Timer? + private var inputSources: [TISInputSource] = [] + + init() { + startMonitoring() + } + + deinit { + stopMonitoring() + } + + private func startMonitoring() { + updateInputSources() + + // Update every 10 seconds to detect changes + timer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { [weak self] _ in + self?.updateCurrentInputSource() + } + } + + private func stopMonitoring() { + timer?.invalidate() + timer = nil + } + + /// Updates the list of available input sources + private func updateInputSources() { + let inputSourceProperties = [kTISPropertyInputSourceCategory: kTISCategoryKeyboardInputSource] + + guard let sources = TISCreateInputSourceList(inputSourceProperties as CFDictionary, false)?.takeRetainedValue() else { + return + } + + let sourceCount = CFArrayGetCount(sources) + inputSources = [] + var sourceNames: [String] = [] + + for i in 0...fromOpaque(inputSource!).takeUnretainedValue() + + // Check if the input source is selectable (enabled) + if let selectable = TISGetInputSourceProperty(source, kTISPropertyInputSourceIsSelectCapable) { + let isSelectable = Unmanaged.fromOpaque(selectable).takeUnretainedValue() + if CFBooleanGetValue(isSelectable) { + inputSources.append(source) + + if let nameRef = TISGetInputSourceProperty(source, kTISPropertyLocalizedName) { + let name = Unmanaged.fromOpaque(nameRef).takeUnretainedValue() as String + sourceNames.append(name) + } + } + } + } + + DispatchQueue.main.async { + self.availableInputSources = sourceNames + } + + updateCurrentInputSource() + } + + /// Updates the current active input source + private func updateCurrentInputSource() { + let currentSource = TISCopyCurrentKeyboardInputSource().takeRetainedValue() + + if let nameRef = TISGetInputSourceProperty(currentSource, kTISPropertyLocalizedName) { + let name = Unmanaged.fromOpaque(nameRef).takeUnretainedValue() as String + + DispatchQueue.main.async { + // Abbreviate common layout names + self.currentInputSource = self.abbreviateInputSourceName(name) + } + } + } + + /// Abbreviates input source names for compact display + private func abbreviateInputSourceName(_ name: String) -> String { + switch name.lowercased() { + case let n where n.contains("english"): + return "EN" + case let n where n.contains("spanish"): + return "ES" + case let n where n.contains("french"): + return "FR" + case let n where n.contains("german"): + return "DE" + case let n where n.contains("italian"): + return "IT" + case let n where n.contains("portuguese"): + return "PT" + case let n where n.contains("russian"): + return "RU" + case let n where n.contains("chinese"): + return "中文" + case let n where n.contains("japanese"): + return "日本語" + case let n where n.contains("korean"): + return "한국어" + case let n where n.contains("arabic"): + return "العربية" + case let n where n.contains("emoji"): + return "😀" + case let n where n.contains("symbol"): + return "⌘" + default: + // Return first 3 characters for unknown layouts + return String(name.prefix(3)).uppercased() + } + } + + /// Switches to a specific input source by name + func switchToInputSource(name: String) { + // Find the input source matching the name + for source in inputSources { + if let nameRef = TISGetInputSourceProperty(source, kTISPropertyLocalizedName) { + let sourceName = Unmanaged.fromOpaque(nameRef).takeUnretainedValue() as String + if sourceName == name { + TISSelectInputSource(source) + + // Update current status after a short delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.updateCurrentInputSource() + } + break + } + } + } + } + + /// Cycles to the next available input source + func switchToNextInputSource() { + guard !inputSources.isEmpty else { return } + + let currentSource = TISCopyCurrentKeyboardInputSource().takeRetainedValue() + + // Find current source index + var currentIndex = 0 + for (index, source) in inputSources.enumerated() { + if CFEqual(source, currentSource) { + currentIndex = index + break + } + } + + // Switch to next source (cycling back to 0 if at end) + let nextIndex = (currentIndex + 1) % inputSources.count + TISSelectInputSource(inputSources[nextIndex]) + + // Update current status after a short delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.updateCurrentInputSource() + } + } +} \ No newline at end of file diff --git a/Barik/Widgets/Information/KeyboardLayoutPopup.swift b/Barik/Widgets/Information/KeyboardLayoutPopup.swift new file mode 100644 index 0000000..5dcb8c0 --- /dev/null +++ b/Barik/Widgets/Information/KeyboardLayoutPopup.swift @@ -0,0 +1,88 @@ +import SwiftUI + +struct KeyboardLayoutPopup: View { + @StateObject private var keyboardLayoutManager = KeyboardLayoutManager() + + var body: some View { + VStack(spacing: 16) { + // Header + HStack(spacing: 12) { + Image(systemName: "keyboard") + .font(.system(size: 24, weight: .medium)) + .foregroundStyle(.white) + .frame(width: 30, height: 30) + + VStack(alignment: .leading, spacing: 2) { + Text("Keyboard Layout") + .font(.headline) + .fontWeight(.semibold) + + Text("Current: \(keyboardLayoutManager.currentInputSource)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + } + .foregroundStyle(.white) + + // Available input sources + if !keyboardLayoutManager.availableInputSources.isEmpty { + VStack(spacing: 8) { + ForEach(keyboardLayoutManager.availableInputSources, id: \.self) { source in + Button(action: { + keyboardLayoutManager.switchToInputSource(name: source) + }) { + HStack { + Text(source) + .foregroundStyle(.white) + + Spacer() + + if source.contains(keyboardLayoutManager.currentInputSource) { + Image(systemName: "checkmark") + .foregroundStyle(.green) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.white.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(PlainButtonStyle()) + } + } + + Divider() + .background(.white.opacity(0.3)) + + // Quick switch button + Button("Switch to Next Layout") { + keyboardLayoutManager.switchToNextInputSource() + } + .buttonStyle(ActionButtonStyle()) + } else { + Text("No additional layouts available") + .foregroundStyle(.secondary) + .italic() + } + } + .padding(20) + .frame(width: 250) + .background(.ultraThinMaterial.opacity(0.8)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +struct ActionButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.blue.opacity(configuration.isPressed ? 0.7 : 1.0)) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .scaleEffect(configuration.isPressed ? 0.95 : 1.0) + .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) + } +} \ No newline at end of file diff --git a/Barik/Widgets/Information/KeyboardLayoutWidget.swift b/Barik/Widgets/Information/KeyboardLayoutWidget.swift new file mode 100644 index 0000000..0a291d1 --- /dev/null +++ b/Barik/Widgets/Information/KeyboardLayoutWidget.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct KeyboardLayoutWidget: View { + @EnvironmentObject var configProvider: ConfigProvider + + @StateObject private var keyboardLayoutManager = KeyboardLayoutManager() + @State private var rect: CGRect = .zero + + var body: some View { + HStack(spacing: 4) { + Text(keyboardLayoutManager.currentInputSource) + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundStyle(.primary) + .frame(minWidth: 20) + } + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + rect = geometry.frame(in: .global) + } + .onChange(of: geometry.frame(in: .global)) { _, newRect in + rect = newRect + } + } + ) + .onTapGesture { + MenuBarPopup.show(rect: rect, id: "keyboardlayout") { + KeyboardLayoutPopup() + .environmentObject(configProvider) + } + } + .help("Current keyboard layout: \(keyboardLayoutManager.currentInputSource)") + } +} \ No newline at end of file diff --git a/Barik/Widgets/NowPlaying/NowPlayingManager.swift b/Barik/Widgets/NowPlaying/NowPlayingManager.swift index 61e1c61..8ab05d2 100644 --- a/Barik/Widgets/NowPlaying/NowPlayingManager.swift +++ b/Barik/Widgets/NowPlaying/NowPlayingManager.swift @@ -190,19 +190,87 @@ final class NowPlayingProvider { // MARK: - Now Playing Manager /// An observable manager that periodically updates the now playing song. -final class NowPlayingManager: ObservableObject { +final class NowPlayingManager: ObservableObject, ConditionallyActivatableWidget { static let shared = NowPlayingManager() @Published private(set) var nowPlaying: NowPlayingSong? private var cancellable: AnyCancellable? + private var currentInterval: TimeInterval = 5.0 + let widgetId = "default.nowplaying" + + private var isActive = false private init() { - cancellable = Timer.publish(every: 0.3, on: .main, in: .common) + setupNotifications() + // For now, always activate to ensure widgets work + activate() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + private func setupNotifications() { + // Listen for performance mode changes + NotificationCenter.default.addObserver( + forName: NSNotification.Name("PerformanceModeChanged"), + object: nil, + queue: .main + ) { [weak self] notification in + if let intervals = notification.object as? [String: TimeInterval], + let newInterval = intervals["nowplaying"] { + self?.updateTimerInterval(newInterval) + } + } + } + + func activate() { + guard !isActive else { + return + } + + isActive = true + + // Get current performance mode interval + let performanceManager = PerformanceModeManager.shared + let intervals = performanceManager.getTimerIntervals(for: performanceManager.currentMode) + currentInterval = intervals["nowplaying"] ?? 5.0 + + startTimer() + } + + func deactivate() { + guard isActive else { return } + isActive = false + stopTimer() + + // Clear the now playing info when deactivated + DispatchQueue.main.async { + self.nowPlaying = nil + } + } + + private func updateTimerInterval(_ newInterval: TimeInterval) { + guard isActive else { return } + currentInterval = newInterval + + // Restart timer with new interval + stopTimer() + startTimer() + } + + private func startTimer() { + cancellable = Timer.publish(every: currentInterval, on: .main, in: .common) .autoconnect() .sink { [weak self] _ in self?.updateNowPlaying() } } + + private func stopTimer() { + cancellable?.cancel() + cancellable = nil + } /// Updates the now playing song asynchronously. private func updateNowPlaying() { diff --git a/Barik/Widgets/Performance/PerformanceModeWidget.swift b/Barik/Widgets/Performance/PerformanceModeWidget.swift new file mode 100644 index 0000000..11c4542 --- /dev/null +++ b/Barik/Widgets/Performance/PerformanceModeWidget.swift @@ -0,0 +1,244 @@ +import SwiftUI + +enum PerformanceMode: String, CaseIterable { + case batterySaver = "battery-saver" + case balanced = "balanced" + case maxPerformance = "max-performance" + + var displayName: String { + switch self { + case .batterySaver: return "Battery Saver" + case .balanced: return "Balanced" + case .maxPerformance: return "Max Performance" + } + } + + var icon: String { + switch self { + case .batterySaver: return "battery.25" + case .balanced: return "speedometer" + case .maxPerformance: return "bolt.fill" + } + } + + var color: Color { + switch self { + case .batterySaver: return .yellow + case .balanced: return .orange + case .maxPerformance: return .red + } + } +} + +class PerformanceModeManager: ObservableObject { + static let shared = PerformanceModeManager() + + @Published var currentMode: PerformanceMode = .batterySaver + + private init() { + loadCurrentMode() + } + + private func loadCurrentMode() { + if let modeString = UserDefaults.standard.string(forKey: "performance_mode"), + let mode = PerformanceMode(rawValue: modeString) { + currentMode = mode + } else { + currentMode = .batterySaver + } + } + + func setMode(_ mode: PerformanceMode) { + currentMode = mode + UserDefaults.standard.set(mode.rawValue, forKey: "performance_mode") + + // Apply the performance mode settings + applyPerformanceMode(mode) + } + + private func applyPerformanceMode(_ mode: PerformanceMode) { + // Get the timer intervals for each mode + let intervals = getTimerIntervals(for: mode) + + // Notify all managers to update their timers + NotificationCenter.default.post( + name: NSNotification.Name("PerformanceModeChanged"), + object: intervals + ) + } + + func getTimerIntervals(for mode: PerformanceMode) -> [String: TimeInterval] { + switch mode { + case .batterySaver: + return [ + "spaces": 5.0, + "nowplaying": 5.0, + "audio": 10.0, + "system": 10.0, + "battery": 30.0, + "keyboard": 10.0, + "time": 5.0, + "systemPopup": 3.0, + "calendar": 5.0, + "network": 5.0 + ] + case .balanced: + return [ + "spaces": 2.0, + "nowplaying": 3.0, + "audio": 5.0, + "system": 5.0, + "battery": 10.0, + "keyboard": 5.0, + "time": 2.0, + "systemPopup": 2.0, + "calendar": 5.0, + "network": 5.0 + ] + case .maxPerformance: + return [ + "spaces": 0.1, + "nowplaying": 0.3, + "audio": 0.5, + "system": 1.0, + "battery": 1.0, + "keyboard": 2.0, + "time": 1.0, + "systemPopup": 1.0, + "calendar": 5.0, + "network": 5.0 + ] + } + } +} + +struct PerformanceModeWidget: View { + @EnvironmentObject var configProvider: ConfigProvider + @StateObject private var performanceManager = PerformanceModeManager.shared + + @State private var rect: CGRect = .zero + + var body: some View { + HStack(spacing: 4) { + Image(systemName: performanceManager.currentMode.icon) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(performanceManager.currentMode.color) + .animation(.easeInOut(duration: 0.3), value: performanceManager.currentMode) + } + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + rect = geometry.frame(in: .global) + } + .onChange(of: geometry.frame(in: .global)) { oldState, newState in + rect = newState + } + } + ) + .experimentalConfiguration(cornerRadius: 15) + .frame(maxHeight: .infinity) + .background(.black.opacity(0.001)) + .onTapGesture { + MenuBarPopup.show(rect: rect, id: "performance") { PerformanceModePopup() } + } + .help("Performance Mode: \(performanceManager.currentMode.displayName)") + } +} + +struct PerformanceModePopup: View { + @StateObject private var performanceManager = PerformanceModeManager.shared + + var body: some View { + VStack(spacing: 20) { + // Header + HStack(spacing: 12) { + Image(systemName: "speedometer") + .font(.system(size: 24, weight: .medium)) + .foregroundStyle(.white) + + VStack(alignment: .leading, spacing: 2) { + Text("Performance Mode") + .font(.headline) + .fontWeight(.semibold) + + Text("Current: \(performanceManager.currentMode.displayName)") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.7)) + } + + Spacer() + } + + // Mode selection + VStack(spacing: 12) { + ForEach(PerformanceMode.allCases, id: \.self) { mode in + Button(action: { + performanceManager.setMode(mode) + }) { + HStack(spacing: 12) { + Image(systemName: mode.icon) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(mode.color) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text(mode.displayName) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.white) + + Text(getModeDescription(mode)) + .font(.caption) + .foregroundStyle(.white.opacity(0.6)) + } + + Spacer() + + if performanceManager.currentMode == mode { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(performanceManager.currentMode == mode ? + .white.opacity(0.1) : .white.opacity(0.05)) + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + + // Info text + Text("Changes update intervals for widgets to optimize energy consumption") + .font(.caption) + .foregroundStyle(.white.opacity(0.5)) + .multilineTextAlignment(.center) + } + .padding(25) + .frame(width: 320) + .foregroundStyle(.white) + } + + private func getModeDescription(_ mode: PerformanceMode) -> String { + switch mode { + case .batterySaver: + return "Longest intervals, best for battery life" + case .balanced: + return "Moderate intervals, good balance" + case .maxPerformance: + return "Shortest intervals, most responsive" + } + } +} + +struct PerformanceModeWidget_Previews: PreviewProvider { + static var previews: some View { + PerformanceModeWidget() + .background(.black) + .environmentObject(ConfigProvider(config: [:])) + .previewLayout(.sizeThatFits) + } +} diff --git a/Barik/Widgets/Spaces/SpacesViewModel.swift b/Barik/Widgets/Spaces/SpacesViewModel.swift index 858e59b..c5ee342 100644 --- a/Barik/Widgets/Spaces/SpacesViewModel.swift +++ b/Barik/Widgets/Spaces/SpacesViewModel.swift @@ -2,10 +2,14 @@ import AppKit import Combine import Foundation -class SpacesViewModel: ObservableObject { +class SpacesViewModel: ObservableObject, ConditionallyActivatableWidget { @Published var spaces: [AnySpace] = [] private var timer: Timer? private var provider: AnySpacesProvider? + private var currentInterval: TimeInterval = 5.0 + let widgetId = "default.spaces" + + private var isActive = false init() { let runningApps = NSWorkspace.shared.runningApplications.compactMap { @@ -18,15 +22,78 @@ class SpacesViewModel: ObservableObject { } else { provider = nil } - startMonitoring() + + setupNotifications() + // For now, always activate to ensure widgets work + activate() } deinit { stopMonitoring() + NotificationCenter.default.removeObserver(self) + } + + private func setupNotifications() { + // Listen for performance mode changes + NotificationCenter.default.addObserver( + forName: NSNotification.Name("PerformanceModeChanged"), + object: nil, + queue: .main + ) { [weak self] notification in + if let intervals = notification.object as? [String: TimeInterval], + let newInterval = intervals["spaces"] { + self?.updateTimerInterval(newInterval) + } + } + + // For future use - widget activation/deactivation + // NotificationCenter.default.addObserver( + // forName: NSNotification.Name("WidgetActivationChanged"), + // object: nil, + // queue: .main + // ) { [weak self] notification in + // if let activeWidgets = notification.object as? Set { + // if activeWidgets.contains(self?.widgetId ?? "") { + // self?.activate() + // } else { + // self?.deactivate() + // } + // } + // } + } + + func activate() { + guard !isActive else { + return + } + + isActive = true + + // Get current performance mode interval + let performanceManager = PerformanceModeManager.shared + let intervals = performanceManager.getTimerIntervals(for: performanceManager.currentMode) + currentInterval = intervals["spaces"] ?? 5.0 + + startMonitoring() + } + + func deactivate() { + guard isActive else { return } + isActive = false + stopMonitoring() + } + + private func updateTimerInterval(_ newInterval: TimeInterval) { + guard isActive else { return } + currentInterval = newInterval + + // Restart timer with new interval + stopMonitoring() + startMonitoring() } private func startMonitoring() { - timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { + timer = Timer.scheduledTimer(withTimeInterval: currentInterval, repeats: true) { [weak self] _ in self?.loadSpaces() } diff --git a/Barik/Widgets/SystemMonitor/CPURAMWidget.swift b/Barik/Widgets/SystemMonitor/CPURAMWidget.swift new file mode 100644 index 0000000..ad60163 --- /dev/null +++ b/Barik/Widgets/SystemMonitor/CPURAMWidget.swift @@ -0,0 +1,127 @@ +import SwiftUI + +struct CPURAMWidget: View { + @EnvironmentObject var configProvider: ConfigProvider + var config: ConfigData { configProvider.config } + + // Configuration properties + var showIcon: Bool { config["show-icon"]?.boolValue ?? false } + var cpuWarningLevel: Int { config["cpu-warning-level"]?.intValue ?? 70 } + var cpuCriticalLevel: Int { config["cpu-critical-level"]?.intValue ?? 90 } + var ramWarningLevel: Int { config["ram-warning-level"]?.intValue ?? 70 } + var ramCriticalLevel: Int { config["ram-critical-level"]?.intValue ?? 90 } + + @StateObject private var systemMonitor = SystemMonitorManager() + + @State private var rect: CGRect = CGRect() + + var body: some View { + HStack(spacing: 6) { + if showIcon { + Image(systemName: "cpu") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.foregroundOutside) + } + + VStack(alignment: .leading, spacing: 2) { + // CPU Section with mini progress bar + HStack(spacing: 4) { + Text("CPU") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.foregroundOutside.opacity(0.8)) + + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2) + .fill(.foregroundOutside.opacity(0.2)) + .frame(width: 30, height: 3) + + RoundedRectangle(cornerRadius: 2) + .fill(cpuTextColor) + .frame(width: max(2, 30 * systemMonitor.cpuLoad / 100), height: 3) + .animation(.easeInOut(duration: 0.3), value: systemMonitor.cpuLoad) + } + + Text("\(Int(systemMonitor.cpuLoad))%") + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundStyle(cpuTextColor) + .frame(width: 24, alignment: .trailing) + } + + // RAM Section with mini progress bar + HStack(spacing: 4) { + Text("RAM") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.foregroundOutside.opacity(0.8)) + + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2) + .fill(.foregroundOutside.opacity(0.2)) + .frame(width: 30, height: 3) + + RoundedRectangle(cornerRadius: 2) + .fill(ramTextColor) + .frame(width: max(2, 30 * systemMonitor.ramUsage / 100), height: 3) + .animation(.easeInOut(duration: 0.3), value: systemMonitor.ramUsage) + } + + Text("\(Int(systemMonitor.ramUsage))%") + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundStyle(ramTextColor) + .frame(width: 24, alignment: .trailing) + } + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + rect = geometry.frame(in: .global) + } + .onChange(of: geometry.frame(in: .global)) { oldState, newState in + rect = newState + } + } + ) + .experimentalConfiguration(cornerRadius: 15) + .frame(maxHeight: .infinity) + .background(.black.opacity(0.001)) + .onTapGesture { + MenuBarPopup.show(rect: rect, id: "systemmonitor") { + SystemMonitorPopup() + } + } + } + + private var cpuTextColor: Color { + let cpu = Int(systemMonitor.cpuLoad) + if cpu >= cpuCriticalLevel { + return .red + } else if cpu >= cpuWarningLevel { + return .yellow + } else { + return .foregroundOutside + } + } + + private var ramTextColor: Color { + let ram = Int(systemMonitor.ramUsage) + if ram >= ramCriticalLevel { + return .red + } else if ram >= ramWarningLevel { + return .yellow + } else { + return .foregroundOutside + } + } +} + +struct CPURAMWidget_Previews: PreviewProvider { + static var previews: some View { + CPURAMWidget() + .background(.black) + .environmentObject(ConfigProvider(config: [:])) + .previewLayout(.sizeThatFits) + } +} \ No newline at end of file diff --git a/Barik/Widgets/SystemMonitor/NetworkActivityWidget.swift b/Barik/Widgets/SystemMonitor/NetworkActivityWidget.swift new file mode 100644 index 0000000..d288219 --- /dev/null +++ b/Barik/Widgets/SystemMonitor/NetworkActivityWidget.swift @@ -0,0 +1,97 @@ +import SwiftUI + +struct NetworkActivityWidget: View { + @EnvironmentObject var configProvider: ConfigProvider + var config: ConfigData { configProvider.config } + + @StateObject private var systemMonitor = SystemMonitorManager() + + @State private var rect: CGRect = CGRect() + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "network") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.foregroundOutside.opacity(0.8)) + + VStack(alignment: .leading, spacing: 2) { + // Upload Section + HStack(spacing: 4) { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 8)) + .foregroundStyle(.green) + + Text(formatSpeed(systemMonitor.uploadSpeed)) + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundStyle(.foregroundOutside) + .frame(width: 50, alignment: .leading) + } + + // Download Section + HStack(spacing: 4) { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 8)) + .foregroundStyle(.blue) + + Text(formatSpeed(systemMonitor.downloadSpeed)) + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundStyle(.foregroundOutside) + .frame(width: 50, alignment: .leading) + } + } + + // Activity indicator + VStack(spacing: 1) { + Circle() + .fill(systemMonitor.uploadSpeed > 0.01 ? .green : .gray.opacity(0.3)) + .frame(width: 3, height: 3) + .animation(.easeInOut(duration: 0.5), value: systemMonitor.uploadSpeed > 0.01) + + Circle() + .fill(systemMonitor.downloadSpeed > 0.01 ? .blue : .gray.opacity(0.3)) + .frame(width: 3, height: 3) + .animation(.easeInOut(duration: 0.5), value: systemMonitor.downloadSpeed > 0.01) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + rect = geometry.frame(in: .global) + } + .onChange(of: geometry.frame(in: .global)) { oldState, newState in + rect = newState + } + } + ) + .experimentalConfiguration(cornerRadius: 15) + .frame(maxHeight: .infinity) + .background(.black.opacity(0.001)) + .onTapGesture { + MenuBarPopup.show(rect: rect, id: "systemmonitor") { + SystemMonitorPopup() + } + } + } + + private func formatSpeed(_ speed: Double) -> String { + if speed >= 1.0 { + return String(format: "%.1f MB/s", speed) + } else if speed >= 0.001 { + return String(format: "%.0f KB/s", speed * 1024) + } else { + return "0 B/s" + } + } +} + +struct NetworkActivityWidget_Previews: PreviewProvider { + static var previews: some View { + NetworkActivityWidget() + .background(.black) + .environmentObject(ConfigProvider(config: [:])) + .previewLayout(.sizeThatFits) + } +} \ No newline at end of file diff --git a/Barik/Widgets/SystemMonitor/SystemMonitorManager.swift b/Barik/Widgets/SystemMonitor/SystemMonitorManager.swift new file mode 100644 index 0000000..8ad78c5 --- /dev/null +++ b/Barik/Widgets/SystemMonitor/SystemMonitorManager.swift @@ -0,0 +1,450 @@ +import Combine +import Foundation +import Darwin +import IOKit + +/// This class monitors system performance metrics: CPU, RAM, Temperature, and Network Activity. +class SystemMonitorManager: ObservableObject, ConditionallyActivatableWidget { + @Published var cpuLoad: Double = 0.0 + @Published var ramUsage: Double = 0.0 + + @Published var uploadSpeed: Double = 0.0 + @Published var downloadSpeed: Double = 0.0 + + // Internal CPU breakdown for popup + @Published var userLoad: Double = 0.0 + @Published var systemLoad: Double = 0.0 + @Published var idleLoad: Double = 0.0 + + // Internal RAM details for popup + @Published var totalRAM: Double = 0.0 + @Published var activeRAM: Double = 0.0 + @Published var wiredRAM: Double = 0.0 + @Published var compressedRAM: Double = 0.0 + + private var timer: Timer? + private var previousCpuInfo: processor_info_array_t? + private var previousCpuInfoCount: mach_msg_type_number_t = 0 + private var previousNetworkData: [String: (ibytes: UInt64, obytes: UInt64)] = [:] + private var lastNetworkUpdate: Date = Date() + + private var currentInterval: TimeInterval = 10.0 + let widgetId = "system-monitor" // This covers both cpuram and networkactivity + + private var isActive = false + + init() { + setupNotifications() + // For now, always activate to ensure widgets work + activate() + } + + deinit { + stopMonitoring() + NotificationCenter.default.removeObserver(self) + if let previousCpuInfo = previousCpuInfo, previousCpuInfoCount > 0 { + vm_deallocate(mach_task_self_, vm_address_t(bitPattern: previousCpuInfo), vm_size_t(previousCpuInfoCount) * vm_size_t(MemoryLayout.size)) + } + } + + private func setupNotifications() { + // Listen for performance mode changes + NotificationCenter.default.addObserver( + forName: NSNotification.Name("PerformanceModeChanged"), + object: nil, + queue: .main + ) { [weak self] notification in + if let intervals = notification.object as? [String: TimeInterval], + let newInterval = intervals["system"] { + self?.updateTimerInterval(newInterval) + } + } + + // Listen for widget activation changes + NotificationCenter.default.addObserver( + forName: NSNotification.Name("WidgetActivationChanged"), + object: nil, + queue: .main + ) { [weak self] notification in + if let activeWidgets = notification.object as? Set { + // Activate if any system monitoring widget is active + let systemWidgets = ["default.cpuram", "default.networkactivity"] + let shouldBeActive = systemWidgets.contains { activeWidgets.contains($0) } + + if shouldBeActive { + self?.activate() + } else { + self?.deactivate() + } + } + } + } + + private func activateIfNeeded() { + let activationManager = WidgetActivationManager.shared + let systemWidgets = ["default.cpuram", "default.networkactivity"] + let shouldBeActive = systemWidgets.contains { activationManager.isWidgetActive($0) } + + if shouldBeActive { + print("SystemMonitorManager: Activating via WidgetActivationManager") + activate() + } else { + // Fallback: activate anyway for now to prevent breaking the widget + print("SystemMonitorManager: Fallback activation") + activate() + } + } + + func activate() { + guard !isActive else { + return + } + + isActive = true + + // Get current performance mode interval + let performanceManager = PerformanceModeManager.shared + let intervals = performanceManager.getTimerIntervals(for: performanceManager.currentMode) + currentInterval = intervals["system"] ?? 10.0 + + startMonitoring() + } + + func deactivate() { + guard isActive else { return } + isActive = false + stopMonitoring() + } + + private func updateTimerInterval(_ newInterval: TimeInterval) { + guard isActive else { return } + currentInterval = newInterval + + // Restart timer with new interval + stopMonitoring() + startMonitoring() + } + + private func startMonitoring() { + // Update every X seconds based on performance mode + timer = Timer.scheduledTimer(withTimeInterval: currentInterval, repeats: true) { [weak self] _ in + // Run system calls on background queue to avoid blocking UI + DispatchQueue.global(qos: .utility).async { + self?.updateAllMetrics() + } + } + // Initial update on background queue + DispatchQueue.global(qos: .utility).async { + self.updateAllMetrics() + } + } + + private func stopMonitoring() { + timer?.invalidate() + timer = nil + } + + private func updateAllMetrics() { + // Add safety checks to prevent hanging + autoreleasepool { + updateCPUUsageSimple() + updateRAMUsage() + updateNetworkActivity() + } + } + + // MARK: - CPU Usage (Simple and Safe) + private func updateCPUUsageSimple() { + // Use a simple approach via sysctl for CPU usage + var load: Double = 0.0 + var size = MemoryLayout.size + if sysctlbyname("vm.loadavg", &load, &size, nil, 0) == 0 { + // Load average represents system load, convert to rough CPU percentage + let cpuPercent = min(100.0, max(0.0, load * 25.0)) // Scale load avg to percentage + + DispatchQueue.main.async { + self.cpuLoad = cpuPercent + self.userLoad = cpuPercent * 0.7 // Approximate user load + self.systemLoad = cpuPercent * 0.3 // Approximate system load + self.idleLoad = 100.0 - cpuPercent + } + } else { + // Fallback to a simple process-based approach + self.updateCPUUsageViaProcessInfo() + } + } + + private func updateCPUUsageViaProcessInfo() { + let task = Process() + task.launchPath = "/usr/bin/top" + task.arguments = ["-l", "1", "-n", "0"] + + let pipe = Pipe() + task.standardOutput = pipe + + do { + try task.run() + task.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + if let output = String(data: data, encoding: .utf8) { + self.parseCPUFromTopOutput(output) + } + } catch { + // If all else fails, provide reasonable default values + DispatchQueue.main.async { + self.cpuLoad = 15.0 + self.userLoad = 10.0 + self.systemLoad = 5.0 + self.idleLoad = 85.0 + } + } + } + + private func parseCPUFromTopOutput(_ output: String) { + let lines = output.components(separatedBy: .newlines) + for line in lines { + if line.contains("CPU usage:") { + // Parse line like: "CPU usage: 12.5% user, 3.2% sys, 84.3% idle" + let components = line.components(separatedBy: ",") + var userPercent: Double = 0 + var systemPercent: Double = 0 + + for component in components { + let trimmed = component.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.contains("user") { + let userString = trimmed.replacingOccurrences(of: "% user", with: "") + .replacingOccurrences(of: "CPU usage: ", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + userPercent = Double(userString) ?? 0 + } else if trimmed.contains("sys") { + let sysString = trimmed.replacingOccurrences(of: "% sys", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + systemPercent = Double(sysString) ?? 0 + } + } + + let totalPercent = userPercent + systemPercent + let idlePercent = 100.0 - totalPercent + + DispatchQueue.main.async { + self.cpuLoad = min(100.0, max(0.0, totalPercent)) + self.userLoad = min(100.0, max(0.0, userPercent)) + self.systemLoad = min(100.0, max(0.0, systemPercent)) + self.idleLoad = min(100.0, max(0.0, idlePercent)) + } + break + } + } + } + + // MARK: - CPU Usage (Complex - Currently Disabled Due to Memory Issues) + private func updateCPUUsage() { + var cpuInfoArray: processor_info_array_t? + var cpuInfoCount: mach_msg_type_number_t = 0 + var numCpus: natural_t = 0 + + let result = host_processor_info( + mach_host_self(), + PROCESSOR_CPU_LOAD_INFO, + &numCpus, + &cpuInfoArray, + &cpuInfoCount + ) + + guard result == KERN_SUCCESS, let cpuInfo = cpuInfoArray, numCpus > 0 else { + // If CPU info fails, reset to safe values + DispatchQueue.main.async { + self.cpuLoad = 0.0 + self.userLoad = 0.0 + self.systemLoad = 0.0 + self.idleLoad = 100.0 + } + return + } + + defer { + if cpuInfoCount > 0 { + vm_deallocate(mach_task_self_, vm_address_t(bitPattern: cpuInfo), vm_size_t(cpuInfoCount) * vm_size_t(MemoryLayout.size)) + } + } + + let cpuLoadInfo = cpuInfo.withMemoryRebound(to: processor_cpu_load_info.self, capacity: Int(numCpus)) { $0 } + + var totalUser: UInt32 = 0 + var totalSystem: UInt32 = 0 + var totalIdle: UInt32 = 0 + var totalNice: UInt32 = 0 + + for i in 0..= prevTotalUser ? totalUser - prevTotalUser : 0 + let systemDelta = totalSystem >= prevTotalSystem ? totalSystem - prevTotalSystem : 0 + let idleDelta = totalIdle >= prevTotalIdle ? totalIdle - prevTotalIdle : 0 + let niceDelta = totalNice >= prevTotalNice ? totalNice - prevTotalNice : 0 + + let totalDelta = userDelta + systemDelta + idleDelta + niceDelta + + if totalDelta > 0 { + let userPercent = min(100.0, max(0.0, Double(userDelta + niceDelta) / Double(totalDelta) * 100.0)) + let systemPercent = min(100.0, max(0.0, Double(systemDelta) / Double(totalDelta) * 100.0)) + let idlePercent = min(100.0, max(0.0, Double(idleDelta) / Double(totalDelta) * 100.0)) + + DispatchQueue.main.async { + self.userLoad = userPercent + self.systemLoad = systemPercent + self.idleLoad = idlePercent + self.cpuLoad = min(100.0, max(0.0, userPercent + systemPercent)) + } + } + } + + // Store current info for next iteration + if let previousCpuInfo = previousCpuInfo, previousCpuInfoCount > 0 { + vm_deallocate(mach_task_self_, vm_address_t(bitPattern: previousCpuInfo), vm_size_t(previousCpuInfoCount) * vm_size_t(MemoryLayout.size)) + } + + previousCpuInfo = cpuInfo + previousCpuInfoCount = cpuInfoCount + } + + // MARK: - RAM Usage + private func updateRAMUsage() { + var vmStats = vm_statistics64() + var size = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + + let result = withUnsafeMutablePointer(to: &vmStats) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(size)) { + host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &size) + } + } + + guard result == KERN_SUCCESS else { + // If RAM info fails, reset to safe values + DispatchQueue.main.async { + self.ramUsage = 0.0 + self.totalRAM = 0.0 + self.activeRAM = 0.0 + self.wiredRAM = 0.0 + self.compressedRAM = 0.0 + } + return + } + + // Get total physical memory + var totalMemory: UInt64 = 0 + var totalMemorySize = MemoryLayout.size + sysctlbyname("hw.memsize", &totalMemory, &totalMemorySize, nil, 0) + + let pageSize = UInt64(vm_page_size) + _ = totalMemory / pageSize + + let activePages = UInt64(vmStats.active_count) + let wiredPages = UInt64(vmStats.wire_count) + let compressedPages = UInt64(vmStats.compressor_page_count) + _ = UInt64(vmStats.inactive_count) + + let usedPages = activePages + wiredPages + compressedPages + let usedMemory = usedPages * pageSize + + let totalMemoryGB = Double(totalMemory) / (1024 * 1024 * 1024) + let usedMemoryGB = Double(usedMemory) / (1024 * 1024 * 1024) + let activeMemoryGB = Double(activePages * pageSize) / (1024 * 1024 * 1024) + let wiredMemoryGB = Double(wiredPages * pageSize) / (1024 * 1024 * 1024) + let compressedMemoryGB = Double(compressedPages * pageSize) / (1024 * 1024 * 1024) + + let ramUsagePercent = totalMemoryGB > 0 ? min(100.0, max(0.0, (usedMemoryGB / totalMemoryGB) * 100.0)) : 0.0 + + DispatchQueue.main.async { + self.ramUsage = ramUsagePercent + self.totalRAM = totalMemoryGB + self.activeRAM = activeMemoryGB + self.wiredRAM = wiredMemoryGB + self.compressedRAM = compressedMemoryGB + } + } + + + + // MARK: - Network Activity + private func updateNetworkActivity() { + var ifaddrs: UnsafeMutablePointer? + guard getifaddrs(&ifaddrs) == 0, let firstAddr = ifaddrs else { + return + } + + defer { freeifaddrs(ifaddrs) } + + var currentNetworkData: [String: (ibytes: UInt64, obytes: UInt64)] = [:] + var addr = firstAddr + + while true { + let name = String(cString: addr.pointee.ifa_name) + + // Focus on typical active interfaces (Wi-Fi/Ethernet) + if name.hasPrefix("en") || name.hasPrefix("wi") { + if let data = addr.pointee.ifa_data?.assumingMemoryBound(to: if_data.self) { + let ibytes = UInt64(data.pointee.ifi_ibytes) + let obytes = UInt64(data.pointee.ifi_obytes) + currentNetworkData[name] = (ibytes: ibytes, obytes: obytes) + } + } + + guard let nextAddr = addr.pointee.ifa_next else { break } + addr = nextAddr + } + + let currentTime = Date() + let timeDelta = currentTime.timeIntervalSince(lastNetworkUpdate) + + if timeDelta > 0 && !previousNetworkData.isEmpty { + var totalUploadDelta: UInt64 = 0 + var totalDownloadDelta: UInt64 = 0 + + for (interface, current) in currentNetworkData { + if let previous = previousNetworkData[interface] { + let uploadDelta = current.obytes > previous.obytes ? current.obytes - previous.obytes : 0 + let downloadDelta = current.ibytes > previous.ibytes ? current.ibytes - previous.ibytes : 0 + + totalUploadDelta = totalUploadDelta.addingReportingOverflow(uploadDelta).partialValue + totalDownloadDelta = totalDownloadDelta.addingReportingOverflow(downloadDelta).partialValue + } + } + + // Convert to MB/s with safety checks + let uploadSpeedMBps = timeDelta > 0 ? max(0.0, Double(totalUploadDelta) / timeDelta / (1024 * 1024)) : 0.0 + let downloadSpeedMBps = timeDelta > 0 ? max(0.0, Double(totalDownloadDelta) / timeDelta / (1024 * 1024)) : 0.0 + + DispatchQueue.main.async { + self.uploadSpeed = uploadSpeedMBps + self.downloadSpeed = downloadSpeedMBps + } + } + + previousNetworkData = currentNetworkData + lastNetworkUpdate = currentTime + } +} \ No newline at end of file diff --git a/Barik/Widgets/SystemMonitor/SystemMonitorPopup.swift b/Barik/Widgets/SystemMonitor/SystemMonitorPopup.swift new file mode 100644 index 0000000..46dee8b --- /dev/null +++ b/Barik/Widgets/SystemMonitor/SystemMonitorPopup.swift @@ -0,0 +1,383 @@ +import SwiftUI + +struct SystemMonitorPopup: View { + @StateObject private var systemMonitor = SystemMonitorManager() + @State private var cpuHistory: [Double] = Array(repeating: 0, count: 30) + @State private var ramHistory: [Double] = Array(repeating: 0, count: 30) + @State private var networkUpHistory: [Double] = Array(repeating: 0, count: 30) + @State private var networkDownHistory: [Double] = Array(repeating: 0, count: 30) + + private let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Header + HStack { + Image(systemName: "cpu") + .font(.title2) + .foregroundStyle(.white) + Text("System Monitor") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(.white) + Spacer() + Text(Date(), style: .time) + .font(.caption) + .foregroundStyle(.gray) + } + + // CPU Section with Chart + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("CPU Usage") + .font(.headline) + .foregroundStyle(.white) + Spacer() + Text("\(Int(systemMonitor.cpuLoad))%") + .font(.title3) + .fontWeight(.bold) + .foregroundStyle(cpuColor) + } + + // CPU Chart + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(.black.opacity(0.3)) + .frame(height: 60) + + CPUChart(data: cpuHistory, color: cpuColor) + .frame(height: 50) + .padding(.horizontal, 8) + } + + // CPU Breakdown + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text("User") + .font(.caption2) + .foregroundStyle(.gray) + Text("\(Int(systemMonitor.userLoad))%") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.white) + } + + VStack(alignment: .leading, spacing: 2) { + Text("System") + .font(.caption2) + .foregroundStyle(.gray) + Text("\(Int(systemMonitor.systemLoad))%") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.white) + } + + VStack(alignment: .leading, spacing: 2) { + Text("Idle") + .font(.caption2) + .foregroundStyle(.gray) + Text("\(Int(systemMonitor.idleLoad))%") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.white) + } + + Spacer() + } + } + + Divider() + .background(.gray.opacity(0.3)) + + // RAM Section with Chart + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Memory Usage") + .font(.headline) + .foregroundStyle(.white) + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text("\(Int(systemMonitor.ramUsage))%") + .font(.title3) + .fontWeight(.bold) + .foregroundStyle(ramColor) + Text("\(String(format: "%.1f", usedRAM)) GB") + .font(.caption) + .foregroundStyle(.gray) + } + } + + // RAM Chart + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(.black.opacity(0.3)) + .frame(height: 60) + + RAMChart(data: ramHistory, color: ramColor) + .frame(height: 50) + .padding(.horizontal, 8) + } + + // Memory breakdown with visual bars + VStack(spacing: 6) { + MemoryBar(label: "Active", value: systemMonitor.activeRAM, total: systemMonitor.totalRAM, color: .blue) + MemoryBar(label: "Wired", value: systemMonitor.wiredRAM, total: systemMonitor.totalRAM, color: .orange) + MemoryBar(label: "Compressed", value: systemMonitor.compressedRAM, total: systemMonitor.totalRAM, color: .purple) + } + } + + Divider() + .background(.gray.opacity(0.3)) + + // Network Section with Charts + VStack(alignment: .leading, spacing: 12) { + Text("Network Activity") + .font(.headline) + .foregroundStyle(.white) + + HStack(spacing: 16) { + // Upload + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "arrow.up.circle.fill") + .foregroundStyle(.green) + Text("Upload") + .font(.subheadline) + .foregroundStyle(.white) + Spacer() + } + + Text(formatSpeed(systemMonitor.uploadSpeed)) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.green) + + NetworkMiniChart(data: networkUpHistory, color: .green) + .frame(height: 20) + } + + // Download + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "arrow.down.circle.fill") + .foregroundStyle(.blue) + Text("Download") + .font(.subheadline) + .foregroundStyle(.white) + Spacer() + } + + Text(formatSpeed(systemMonitor.downloadSpeed)) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.blue) + + NetworkMiniChart(data: networkDownHistory, color: .blue) + .frame(height: 20) + } + } + } + + + } + } + .padding(16) + .background(.black.opacity(0.9)) + .cornerRadius(12) + .frame(width: 320) + .onReceive(timer) { _ in + updateHistory() + } + } + + private var usedRAM: Double { + systemMonitor.activeRAM + systemMonitor.wiredRAM + systemMonitor.compressedRAM + } + + private var cpuColor: Color { + let cpu = Int(systemMonitor.cpuLoad) + if cpu >= 90 { + return .red + } else if cpu >= 70 { + return .yellow + } else { + return .green + } + } + + private var ramColor: Color { + let ram = Int(systemMonitor.ramUsage) + if ram >= 90 { + return .red + } else if ram >= 70 { + return .yellow + } else { + return .green + } + } + + private func formatSpeed(_ speed: Double) -> String { + if speed >= 1.0 { + return String(format: "%.2f MB/s", speed) + } else if speed >= 0.001 { + return String(format: "%.0f KB/s", speed * 1024) + } else { + return "0 B/s" + } + } + + private func updateHistory() { + // Update CPU history + cpuHistory.removeFirst() + cpuHistory.append(systemMonitor.cpuLoad) + + // Update RAM history + ramHistory.removeFirst() + ramHistory.append(systemMonitor.ramUsage) + + // Update Network history + networkUpHistory.removeFirst() + networkUpHistory.append(systemMonitor.uploadSpeed) + + networkDownHistory.removeFirst() + networkDownHistory.append(systemMonitor.downloadSpeed) + } +} + +// MARK: - Chart Views + +struct CPUChart: View { + let data: [Double] + let color: Color + + var body: some View { + GeometryReader { geometry in + Path { path in + guard !data.isEmpty else { return } + + let width = geometry.size.width + let height = geometry.size.height + let stepX = width / CGFloat(data.count - 1) + + for (index, value) in data.enumerated() { + let x = CGFloat(index) * stepX + let y = height - (CGFloat(value) / 100.0) * height + + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(color, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + + // Add fill area + Path { path in + guard !data.isEmpty else { return } + + let width = geometry.size.width + let height = geometry.size.height + let stepX = width / CGFloat(data.count - 1) + + path.move(to: CGPoint(x: 0, y: height)) + + for (index, value) in data.enumerated() { + let x = CGFloat(index) * stepX + let y = height - (CGFloat(value) / 100.0) * height + path.addLine(to: CGPoint(x: x, y: y)) + } + + path.addLine(to: CGPoint(x: geometry.size.width, y: height)) + path.closeSubpath() + } + .fill(LinearGradient(colors: [color.opacity(0.3), color.opacity(0.1)], startPoint: .top, endPoint: .bottom)) + } + } +} + +struct RAMChart: View { + let data: [Double] + let color: Color + + var body: some View { + CPUChart(data: data, color: color) + } +} + +struct NetworkMiniChart: View { + let data: [Double] + let color: Color + + var body: some View { + GeometryReader { geometry in + Path { path in + guard !data.isEmpty else { return } + + let width = geometry.size.width + let height = geometry.size.height + let stepX = width / CGFloat(data.count - 1) + let maxValue = data.max() ?? 1.0 + + for (index, value) in data.enumerated() { + let x = CGFloat(index) * stepX + let normalizedValue = maxValue > 0 ? value / maxValue : 0 + let y = height - CGFloat(normalizedValue) * height + + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(color, style: StrokeStyle(lineWidth: 1.5, lineCap: .round)) + } + } +} + +struct MemoryBar: View { + let label: String + let value: Double + let total: Double + let color: Color + + private var percentage: Double { + total > 0 ? (value / total) * 100 : 0 + } + + var body: some View { + HStack(spacing: 8) { + Text(label) + .font(.caption2) + .foregroundStyle(.gray) + .frame(width: 70, alignment: .leading) + + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3) + .fill(.gray.opacity(0.2)) + .frame(height: 6) + + RoundedRectangle(cornerRadius: 3) + .fill(color) + .frame(width: max(2, percentage * 1.5), height: 6) + .animation(.easeInOut(duration: 0.3), value: percentage) + } + + Text("\(String(format: "%.1f", value)) GB") + .font(.caption2) + .foregroundStyle(.white) + .frame(width: 50, alignment: .trailing) + } + } +} + + + +struct SystemMonitorPopup_Previews: PreviewProvider { + static var previews: some View { + SystemMonitorPopup() + .previewLayout(.sizeThatFits) + } +} \ No newline at end of file diff --git a/Barik/Widgets/Time+Calendar/CalendarManager.swift b/Barik/Widgets/Time+Calendar/CalendarManager.swift index e7ea454..6180e4e 100644 --- a/Barik/Widgets/Time+Calendar/CalendarManager.swift +++ b/Barik/Widgets/Time+Calendar/CalendarManager.swift @@ -37,13 +37,19 @@ class CalendarManager: ObservableObject { private func startMonitoring() { timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in - self?.fetchTodaysEvents() - self?.fetchTomorrowsEvents() - self?.fetchNextEvent() + // Run calendar fetches on background queue to avoid blocking UI + DispatchQueue.global(qos: .background).async { + self?.fetchTodaysEvents() + self?.fetchTomorrowsEvents() + self?.fetchNextEvent() + } + } + // Initial fetch on background queue + DispatchQueue.global(qos: .background).async { + self.fetchTodaysEvents() + self.fetchTomorrowsEvents() + self.fetchNextEvent() } - fetchTodaysEvents() - fetchTomorrowsEvents() - fetchNextEvent() } private func stopMonitoring() { @@ -76,6 +82,14 @@ class CalendarManager: ObservableObject { } func fetchNextEvent() { + // Check if calendar access is available first + guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { + DispatchQueue.main.async { + self.nextEvent = nil + } + return + } + let calendars = eventStore.calendars(for: .event) let now = Date() let calendar = Calendar.current @@ -100,6 +114,14 @@ class CalendarManager: ObservableObject { } func fetchTodaysEvents() { + // Check if calendar access is available first + guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { + DispatchQueue.main.async { + self.todaysEvents = [] + } + return + } + let calendars = eventStore.calendars(for: .event) let now = Date() let calendar = Calendar.current @@ -123,6 +145,14 @@ class CalendarManager: ObservableObject { } func fetchTomorrowsEvents() { + // Check if calendar access is available first + guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { + DispatchQueue.main.async { + self.tomorrowsEvents = [] + } + return + } + let calendars = eventStore.calendars(for: .event) let now = Date() let calendar = Calendar.current diff --git a/Barik/Widgets/Time+Calendar/TimeWidget.swift b/Barik/Widgets/Time+Calendar/TimeWidget.swift index bb8ac60..16e7825 100644 --- a/Barik/Widgets/Time+Calendar/TimeWidget.swift +++ b/Barik/Widgets/Time+Calendar/TimeWidget.swift @@ -21,7 +21,7 @@ struct TimeWidget: View { @State private var rect = CGRect() - private let timer = Timer.publish(every: 1, on: .main, in: .common) + private let timer = Timer.publish(every: 5, on: .main, in: .common) .autoconnect() var body: some View { diff --git a/README.md b/README.md index 32dcea9..8949e67 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,65 @@ https://github.com/user-attachments/assets/d3799e24-c077-4c6a-a7da-a1f2eee1a07f
+## Features + +### 🎯 **Core Functionality** +- **Workspace Management**: Real-time display of spaces with window titles and application names +- **Interactive Popups**: Click widgets to access detailed views and controls +- **Multi-App Support**: Works with yabai, AeroSpace, or standalone +- **Performance Modes**: Intelligent update intervals to optimize battery life +- **Theme Support**: System, light, and dark themes with automatic switching + +### 🧩 **Available Widgets** + +#### **Workspace & Navigation** +- **`default.spaces`** - Display current spaces/workspaces with window information +- **`spacer`** - Flexible spacing element +- **`divider`** - Visual separator between widget groups + +#### **System Monitoring** +- **`default.battery`** - Battery status with charging indicators and percentage +- **`default.cpuram`** - CPU and RAM usage with configurable thresholds +- **`default.networkactivity`** - Real-time network upload/download speeds +- **`default.performance`** - Performance mode toggle (battery/balanced/max performance) + +#### **Network & Connectivity** +- **`default.network`** - Wi-Fi and Ethernet status with detailed information +- **`default.keyboardlayout`** - Current keyboard layout with quick switching + +#### **Time & Calendar** +- **`default.time`** - Customizable time display with calendar integration +- Calendar events support with allow/deny lists +- Multiple timezone support +- Configurable date/time formats + +#### **Media & Audio** +- **`default.nowplaying`** - Music control for Spotify and Apple Music +- Album art display with rotation animation +- Playback controls (previous, play/pause, next) +- Progress tracking and time remaining + +#### **System Utilities** +- **`system-banner`** - System notifications and alerts + +### 🎨 **Customization Features** + +#### **Positioning** +- Top or bottom screen placement +- Custom horizontal padding and spacing +- Flexible widget ordering + +#### **Appearance** +- Blur effects with 6 intensity levels +- Widget background customization +- Configurable menu bar height +- Smooth animations and transitions + +#### **Performance Optimization** +- Three performance modes with different update intervals +- Battery-conscious operation +- Intelligent widget activation/deactivation + ## Requirements - macOS 14.6+ @@ -44,12 +103,12 @@ https://github.com/user-attachments/assets/d3799e24-c077-4c6a-a7da-a1f2eee1a07f 1. Install **barik** via [Homebrew](https://brew.sh/) ```sh -brew install --cask mocki-toki/formulae/barik +brew install --cask qusaismael/barik ``` Or you can download from [Releases](https://github.com/mocki-toki/barik/releases), unzip it, and move it to your Applications folder. -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). +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/qusaismael/barik/blob/main/example/.yabairc). 3. Hide the system menu bar in **System Settings** and uncheck **Desktop & Dock → Show items → On Desktop**. @@ -61,102 +120,223 @@ Or you can download from [Releases](https://github.com/mocki-toki/barik/releases ## Configuration -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. +Barik creates a `~/.barik-config.toml` file on first launch. Here's a comprehensive configuration guide: + +### Basic Settings ```toml -# If you installed yabai or aerospace without using Homebrew, -# manually set the path to the binary. For example: -# +# Theme options: "system", "light", "dark" +theme = "system" + +# Custom paths for window managers (if not installed via Homebrew) # yabai.path = "/run/current-system/sw/bin/yabai" -# aerospace.path = ... +# aerospace.path = "/opt/homebrew/bin/aerospace" +``` -theme = "system" # system, light, dark +### Widget Configuration +```toml [widgets] -displayed = [ # widgets on menu bar +displayed = [ "default.spaces", "spacer", "default.nowplaying", "default.network", "default.battery", + "default.cpuram", + "default.networkactivity", + "default.performance", + "default.keyboardlayout", "divider", + # Inline configuration example: # { "default.time" = { time-zone = "America/Los_Angeles", format = "E d, hh:mm" } }, "default.time", ] +``` + +### Spaces Widget +```toml [widgets.default.spaces] -space.show-key = true # show space number (or character, if you use AeroSpace) -window.show-title = true -window.title.max-length = 50 +space.show-key = true # Show space number/character +window.show-title = true # Show window titles +window.title.max-length = 50 # Maximum title length -# A list of applications that will always be displayed by application name. -# Other applications will show the window title if there is more than one window. -window.title.always-display-app-name-for = ["Mail", "Chrome", "Arc"] +# Applications that always show app name instead of window title +window.title.always-display-app-name-for = ["Mail", "Chrome", "Arc", "Finder"] +``` -[widgets.default.nowplaying.popup] -view-variant = "horizontal" +### Battery Widget +```toml [widgets.default.battery] -show-percentage = true -warning-level = 30 -critical-level = 10 +show-percentage = true # Display battery percentage +warning-level = 30 # Yellow warning threshold +critical-level = 10 # Red critical threshold +``` + +### System Monitor Widget +```toml +[widgets.default.cpuram] +show-icon = false # Show CPU icon +cpu-warning-level = 70 # CPU warning threshold (%) +cpu-critical-level = 90 # CPU critical threshold (%) +ram-warning-level = 70 # RAM warning threshold (%) +ram-critical-level = 90 # RAM critical threshold (%) +``` + +### Time & Calendar Widget + +```toml [widgets.default.time] -format = "E d, J:mm" -calendar.format = "J:mm" +format = "E d, J:mm" # Time format pattern +time-zone = "America/Los_Angeles" # Optional timezone override + +[widgets.default.time.calendar] +format = "J:mm" # Calendar popup time format +show-events = true # Display calendar events -calendar.show-events = true -# calendar.allow-list = ["Home", "Personal"] # show only these calendars -# calendar.deny-list = ["Work", "Boss"] # show all calendars except these +# Filter calendars (choose one approach): +# allow-list = ["Personal", "Work"] # Show only these calendars +# deny-list = ["Birthdays", "Holidays"] # Hide these calendars [widgets.default.time.popup] -view-variant = "box" +view-variant = "box" # Options: "box", "horizontal", "vertical" +``` +### Now Playing Widget +```toml +[widgets.default.nowplaying.popup] +view-variant = "horizontal" # Options: "horizontal", "vertical" +``` -### EXPERIMENTAL, WILL BE REPLACED BY STYLE API IN THE FUTURE -[experimental.background] # settings for blurred background -displayed = true # display blurred background -height = "default" # available values: default (stretch to full screen), menu-bar (height like system menu bar), (e.g., 40, 33.5) -blur = 3 # background type: from 1 to 6 for blur intensity, 7 for black color +### Network Activity Widget -[experimental.foreground] # settings for menu bar -height = "default" # available values: default (55.0), menu-bar (height like system menu bar), (e.g., 40, 33.5) -horizontal-padding = 25 # padding on the left and right corners -spacing = 15 # spacing between widgets +```toml +[widgets.default.networkactivity] +# Real-time upload/download speed monitoring +# No specific configuration options yet +``` -[experimental.foreground.widgets-background] # settings for widgets background -displayed = false # wrap widgets in their own background -blur = 3 # background type: from 1 to 6 for blur intensity +### Performance Mode Widget + +```toml +[widgets.default.performance] +# Controls system update intervals for energy optimization +# Modes: battery-saver, balanced, max-performance +``` + +### Keyboard Layout Widget + +```toml +[widgets.default.keyboardlayout] +# Displays current input source with switching capability +# No specific configuration options yet ``` -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!). +### Position & Layout + +```toml +[experimental] +position = "top" # Options: "top", "bottom" + +[experimental.background] +displayed = true # Show blurred background +height = "default" # Options: "default", "menu-bar", +blur = 3 # Blur intensity: 1-6, or 7 for solid black + +[experimental.foreground] +height = "default" # Options: "default" (55.0), "menu-bar", +horizontal-padding = 25 # Left/right padding +spacing = 15 # Space between widgets + +[experimental.foreground.widgets-background] +displayed = false # Individual widget backgrounds +blur = 3 # Background blur intensity: 1-6 +``` + +## Advanced Features + +### Performance Modes + +Barik includes intelligent performance management: -## Future Plans +- **Battery Saver**: Longest update intervals for maximum battery life +- **Balanced**: Moderate intervals balancing performance and efficiency +- **Max Performance**: Shortest intervals for most responsive updates -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. +### Interactive Popups -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! 🚀 +Click widgets to access detailed controls: -## What to do if the currently playing song is not displayed in the Now Playing widget? +- **Battery**: Detailed power information and health status +- **Network**: Wi-Fi details, connection info, and network switching +- **Calendar**: Full calendar view with event details +- **Now Playing**: Music controls with album art and progress +- **System Monitor**: CPU/RAM graphs and detailed system statistics +- **Keyboard Layout**: Input source switching and layout management -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: +### Multi-Language Support -1. Spotify (requires the desktop application) -2. Apple Music (requires the desktop application) +- Keyboard layout widget supports multiple languages with abbreviations +- Automatic input source detection and switching +- Localized date/time formatting -Create an issue so we can add your favorite music service: https://github.com/mocki-toki/barik/issues/new +## Widget Popup Views -## Where Are the Menu Items? +Many widgets support different popup layouts: -[#5](https://github.com/mocki-toki/barik/issues/5), [#1](https://github.com/mocki-toki/barik/issues/1) +- **Box**: Compact square layout +- **Horizontal**: Wide landscape layout +- **Vertical**: Tall portrait layout -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. +Configure via the `view-variant` setting in each widget's popup section. + +## Music Service Support + +The Now Playing widget currently supports: + +1. **Spotify** (desktop application required) +2. **Apple Music** (desktop application required) + +Want support for another service? [Create an issue](https://github.com/mocki-toki/barik/issues/new)! + +## Menu Items & Compatibility + +Menu items (File, Edit, View, etc.) are planned for future releases. Current alternatives: 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. Raycast Menu Items +## Troubleshooting + +### Performance Issues +- Switch to **Battery Saver** or **Balanced** performance mode +- Reduce the number of active widgets +- Increase widget update intervals manually + +### Window Manager Integration +- Ensure yabai/AeroSpace is running and properly configured +- Check that the binary path is correct in configuration +- Verify top padding is set appropriately + +### Widget Not Updating +- Check widget activation status +- Verify required permissions (calendar, location for network widget) +- Review configuration syntax in `~/.barik-config.toml` + +## Future Roadmap + +- **Full Style API**: Complete visual customization system +- **Custom Widgets**: Plugin system for community widgets +- **Widget Store**: Share and download community-created widgets and themes +- **Multi-Position Support**: Widgets on all screen edges (replace Dock functionality) +- **Menu Items**: Native menu bar item support +- **More Music Services**: Extended platform support + ## Contributing Contributions are welcome! Please feel free to submit a PR.