From 873c46daa78f79f6c3067f6bbe04dd435c063eda Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Sat, 3 Jan 2026 14:03:41 -0800 Subject: [PATCH 01/30] refactor: use event-driven notifications instead of polling for NowPlaying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace aggressive 0.3s AppleScript polling with DistributedNotificationCenter observers for instant media updates. Changes: - Listen to Spotify (`com.spotify.client.PlaybackStateChanged`) and Apple Music (`com.apple.Music.playerInfo`) notifications - Only poll every 1s for position updates (progress bar) when playing - Fetch track info on-demand when notifications fire - Add initial fetch on startup to populate current state This significantly reduces CPU usage and provides instant UI updates when track changes or playback state changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../NowPlaying/NowPlayingManager.swift | 69 +++++++++++++++++-- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/Barik/Widgets/NowPlaying/NowPlayingManager.swift b/Barik/Widgets/NowPlaying/NowPlayingManager.swift index 61e1c61..c0a2ea1 100644 --- a/Barik/Widgets/NowPlaying/NowPlayingManager.swift +++ b/Barik/Widgets/NowPlaying/NowPlayingManager.swift @@ -65,6 +65,16 @@ enum MusicApp: String, CaseIterable { case spotify = "Spotify" case music = "Music" + /// The notification name for playback state changes. + var notificationName: Notification.Name { + switch self { + case .spotify: + return Notification.Name("com.spotify.client.PlaybackStateChanged") + case .music: + return Notification.Name("com.apple.Music.playerInfo") + } + } + /// AppleScript to fetch the now playing song. var nowPlayingScript: String { if self == .music { @@ -143,7 +153,7 @@ final class NowPlayingProvider { } /// Returns the now playing song for a specific music application. - private static func fetchNowPlaying(from app: MusicApp) -> NowPlayingSong? { + static func fetchNowPlaying(from app: MusicApp) -> NowPlayingSong? { guard let output = runAppleScript(app.nowPlayingScript), output != "stopped" else { @@ -189,26 +199,71 @@ final class NowPlayingProvider { // MARK: - Now Playing Manager -/// An observable manager that periodically updates the now playing song. +/// An observable manager that uses event-driven notifications to update the now playing song. final class NowPlayingManager: ObservableObject { static let shared = NowPlayingManager() @Published private(set) var nowPlaying: NowPlayingSong? - private var cancellable: AnyCancellable? + private var notificationTasks: [Task] = [] + private var positionUpdateCancellable: AnyCancellable? private init() { - cancellable = Timer.publish(every: 0.3, on: .main, in: .common) + setupNotificationObservers() + // Initial fetch to populate current state + updateNowPlaying() + // Timer for position updates only (less frequent, only when playing) + setupPositionUpdates() + } + + deinit { + notificationTasks.forEach { $0.cancel() } + } + + /// Sets up observers for music app notifications using DistributedNotificationCenter. + private func setupNotificationObservers() { + for app in MusicApp.allCases { + let task = Task { @MainActor [weak self] in + let notifications = DistributedNotificationCenter.default().notifications( + named: app.notificationName + ) + for await _ in notifications { + self?.handleNotification(from: app) + } + } + notificationTasks.append(task) + } + } + + /// Handles a notification from a music application. + @MainActor + private func handleNotification(from app: MusicApp) { + // Fetch on background thread to avoid blocking + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let song = NowPlayingProvider.fetchNowPlaying(from: app) + DispatchQueue.main.async { + self?.nowPlaying = song + } + } + } + + /// Sets up a timer for position updates (only needed for progress bar). + private func setupPositionUpdates() { + // Update position every 1 second (only when playing) + positionUpdateCancellable = Timer.publish(every: 1.0, on: .main, in: .common) .autoconnect() .sink { [weak self] _ in - self?.updateNowPlaying() + guard let self = self, + let current = self.nowPlaying, + current.state == .playing else { return } + self.updateNowPlaying() } } /// Updates the now playing song asynchronously. private func updateNowPlaying() { - DispatchQueue.global(qos: .background).async { + DispatchQueue.global(qos: .userInitiated).async { [weak self] in let song = NowPlayingProvider.fetchNowPlaying() - DispatchQueue.main.async { [weak self] in + DispatchQueue.main.async { self?.nowPlaying = song } } From 6666de097498b240826fc8df3ad5994318b6653b Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Sat, 3 Jan 2026 15:04:23 -0800 Subject: [PATCH 02/30] feat: add weather widget using Open-Meteo API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add WeatherWidget displaying current temperature and weather icon - Use Open-Meteo free API (no API key required) - Dynamic SF Symbols based on weather conditions (sun, clouds, rain, snow, etc.) - Location-based weather with CoreLocation - Click to open macOS Weather dropdown - Add SystemUIHelper for triggering system UI elements - Add required entitlements for location and network access 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Barik/Barik.entitlements | 2 + Barik/Helpers/SystemUIHelper.swift | 59 ++++++ Barik/Info.plist | 7 +- Barik/Views/MenuBarView.swift | 4 + Barik/Widgets/Weather/WeatherWidget.swift | 237 ++++++++++++++++++++++ 5 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 Barik/Helpers/SystemUIHelper.swift create mode 100644 Barik/Widgets/Weather/WeatherWidget.swift diff --git a/Barik/Barik.entitlements b/Barik/Barik.entitlements index e45d024..769aec2 100644 --- a/Barik/Barik.entitlements +++ b/Barik/Barik.entitlements @@ -8,5 +8,7 @@ com.apple.security.personal-information.location + com.apple.security.network.client + diff --git a/Barik/Helpers/SystemUIHelper.swift b/Barik/Helpers/SystemUIHelper.swift new file mode 100644 index 0000000..2c9a3df --- /dev/null +++ b/Barik/Helpers/SystemUIHelper.swift @@ -0,0 +1,59 @@ +import AppKit +import Foundation + +/// Helper for triggering macOS system UI elements +final class SystemUIHelper { + + /// Opens the macOS Notification Center + static func openNotificationCenter() { + let script = """ + tell application "System Events" + tell process "ControlCenter" + click menu bar item "Clock" of menu bar 1 + end tell + end tell + """ + runAppleScript(script) + } + + /// Opens the macOS Weather menu bar dropdown + static func openWeatherDropdown() { + let script = """ + tell application "System Events" + tell process "ControlCenter" + try + click menu bar item "Weather" of menu bar 1 + on error + -- Weather might not be in menu bar, try to open Weather app instead + tell application "Weather" to activate + end try + end tell + end tell + """ + runAppleScript(script) + } + + /// Opens the Weather app + static func openWeatherApp() { + NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.Weather")!) + // Fallback to opening Weather app directly + if let weatherURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.weather") { + NSWorkspace.shared.open(weatherURL) + } + } + + /// Runs an AppleScript + @discardableResult + private static func runAppleScript(_ script: String) -> String? { + guard let appleScript = NSAppleScript(source: script) else { + return nil + } + var error: NSDictionary? + let result = appleScript.executeAndReturnError(&error) + if let error = error { + print("AppleScript Error: \(error)") + return nil + } + return result.stringValue + } +} diff --git a/Barik/Info.plist b/Barik/Info.plist index 0c67376..08763ad 100644 --- a/Barik/Info.plist +++ b/Barik/Info.plist @@ -1,5 +1,10 @@ - + + NSLocationUsageDescription + Barik needs your location to show local weather conditions. + NSLocationWhenInUseUsageDescription + Barik needs your location to show local weather conditions. + diff --git a/Barik/Views/MenuBarView.swift b/Barik/Views/MenuBarView.swift index 31081ab..7dbc449 100644 --- a/Barik/Views/MenuBarView.swift +++ b/Barik/Views/MenuBarView.swift @@ -59,6 +59,10 @@ struct MenuBarView: View { NowPlayingWidget() .environmentObject(config) + case "default.weather": + WeatherWidget() + .environmentObject(config) + case "spacer": Spacer().frame(minWidth: 50, maxWidth: .infinity) diff --git a/Barik/Widgets/Weather/WeatherWidget.swift b/Barik/Widgets/Weather/WeatherWidget.swift new file mode 100644 index 0000000..c0cd4ec --- /dev/null +++ b/Barik/Widgets/Weather/WeatherWidget.swift @@ -0,0 +1,237 @@ +import SwiftUI +import CoreLocation + +/// Weather widget that displays current weather using Open-Meteo API +struct WeatherWidget: View { + @StateObject private var weatherManager = WeatherManager.shared + + var body: some View { + HStack(spacing: 4) { + if let weather = weatherManager.currentWeather { + Image(systemName: weather.symbolName) + .symbolRenderingMode(.multicolor) + Text(weather.temperature) + .fontWeight(.semibold) + } else { + Image(systemName: "cloud.sun") + .symbolRenderingMode(.multicolor) + if weatherManager.isLoading { + ProgressView() + .scaleEffect(0.5) + } + } + } + .font(.headline) + .foregroundStyle(.foregroundOutside) + .shadow(color: .foregroundShadowOutside, radius: 3) + .experimentalConfiguration(cornerRadius: 15) + .frame(maxHeight: .infinity) + .background(.black.opacity(0.001)) + .onTapGesture { + SystemUIHelper.openWeatherDropdown() + } + .onAppear { + weatherManager.startUpdating() + } + } +} + +// MARK: - Weather Data Model + +struct CurrentWeather { + let temperature: String + let symbolName: String + let condition: String +} + +// MARK: - Open-Meteo API Response + +struct OpenMeteoResponse: Codable { + let currentWeather: OpenMeteoCurrentWeather + + enum CodingKeys: String, CodingKey { + case currentWeather = "current_weather" + } +} + +struct OpenMeteoCurrentWeather: Codable { + let temperature: Double + let weathercode: Int +} + +// MARK: - Weather Manager + +@MainActor +final class WeatherManager: NSObject, ObservableObject { + static let shared = WeatherManager() + + @Published private(set) var currentWeather: CurrentWeather? + @Published private(set) var isLoading = false + + private let locationManager = CLLocationManager() + private var lastLocation: CLLocation? + private var updateTimer: Timer? + + override private init() { + super.init() + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyKilometer + } + + func startUpdating() { + if locationManager.authorizationStatus == .notDetermined { + locationManager.requestWhenInUseAuthorization() + } + locationManager.startUpdatingLocation() + + // Update every 15 minutes + updateTimer?.invalidate() + updateTimer = Timer.scheduledTimer(withTimeInterval: 900, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.fetchWeather() + } + } + } + + func stopUpdating() { + locationManager.stopUpdatingLocation() + updateTimer?.invalidate() + updateTimer = nil + } + + private func fetchWeather() { + guard let location = lastLocation else { return } + + isLoading = true + + Task { + do { + let lat = location.coordinate.latitude + let lon = location.coordinate.longitude + let urlString = "https://api.open-meteo.com/v1/forecast?latitude=\(lat)&longitude=\(lon)¤t_weather=true&temperature_unit=fahrenheit" + + guard let url = URL(string: urlString) else { + isLoading = false + return + } + + let (data, _) = try await URLSession.shared.data(from: url) + let response = try JSONDecoder().decode(OpenMeteoResponse.self, from: data) + + let temp = Int(response.currentWeather.temperature.rounded()) + let symbol = symbolName(for: response.currentWeather.weathercode) + let condition = conditionName(for: response.currentWeather.weathercode) + + self.currentWeather = CurrentWeather( + temperature: "\(temp)°F", + symbolName: symbol, + condition: condition + ) + } catch { + print("Weather fetch error: \(error)") + } + isLoading = false + } + } + + /// Maps Open-Meteo weather codes to SF Symbols + private func symbolName(for code: Int) -> String { + switch code { + case 0: + return "sun.max.fill" + case 1, 2: + return "cloud.sun.fill" + case 3: + return "cloud.fill" + case 45, 48: + return "cloud.fog.fill" + case 51, 53, 55, 56, 57: + return "cloud.drizzle.fill" + case 61, 63, 65, 66, 67: + return "cloud.rain.fill" + case 71, 73, 75, 77: + return "cloud.snow.fill" + case 80, 81, 82: + return "cloud.heavyrain.fill" + case 85, 86: + return "cloud.snow.fill" + case 95, 96, 99: + return "cloud.bolt.rain.fill" + default: + return "cloud.fill" + } + } + + /// Maps Open-Meteo weather codes to condition names + private func conditionName(for code: Int) -> String { + switch code { + case 0: + return "Clear" + case 1: + return "Mainly Clear" + case 2: + return "Partly Cloudy" + case 3: + return "Overcast" + case 45, 48: + return "Foggy" + case 51, 53, 55: + return "Drizzle" + case 56, 57: + return "Freezing Drizzle" + case 61, 63, 65: + return "Rain" + case 66, 67: + return "Freezing Rain" + case 71, 73, 75: + return "Snow" + case 77: + return "Snow Grains" + case 80, 81, 82: + return "Rain Showers" + case 85, 86: + return "Snow Showers" + case 95: + return "Thunderstorm" + case 96, 99: + return "Thunderstorm with Hail" + default: + return "Unknown" + } + } +} + +// MARK: - CLLocationManagerDelegate + +extension WeatherManager: CLLocationManagerDelegate { + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + + Task { @MainActor in + // Only update if location changed significantly (1km) + if lastLocation == nil || lastLocation!.distance(from: location) > 1000 { + lastLocation = location + fetchWeather() + } + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + print("Location error: \(error)") + } + + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + if manager.authorizationStatus == .authorized || + manager.authorizationStatus == .authorizedAlways { + manager.startUpdatingLocation() + } + } +} + +struct WeatherWidget_Previews: PreviewProvider { + static var previews: some View { + ZStack { + WeatherWidget() + }.frame(width: 100, height: 50) + } +} From fcd0ffffbe129c353842384b6af0762cde490b03 Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Sat, 3 Jan 2026 15:14:05 -0800 Subject: [PATCH 03/30] feat: add event-based yabai provider with Unix socket listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace polling-based space monitoring with event-driven updates: - Add SpaceEvent enum and EventBasedSpacesProvider protocol - Implement Unix socket listener in YabaiProvider - Update SpacesViewModel to handle space events reactively Yabai signals now send events to /tmp/barik-yabai.sock for instant updates. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Barik/Widgets/Spaces/SpacesModels.swift | 53 +++++++ Barik/Widgets/Spaces/SpacesViewModel.swift | 76 +++++++++- .../Widgets/Spaces/Yabai/YabaiProvider.swift | 142 +++++++++++++++++- 3 files changed, 269 insertions(+), 2 deletions(-) diff --git a/Barik/Widgets/Spaces/SpacesModels.swift b/Barik/Widgets/Spaces/SpacesModels.swift index 1d8848d..ce31362 100644 --- a/Barik/Widgets/Spaces/SpacesModels.swift +++ b/Barik/Widgets/Spaces/SpacesModels.swift @@ -1,4 +1,5 @@ import AppKit +import Combine protocol SpaceModel: Identifiable, Equatable, Codable { associatedtype WindowType: WindowModel @@ -19,6 +20,23 @@ protocol SpacesProvider { func getSpacesWithWindows() -> [SpaceType]? } +// MARK: - Event-Based Provider Support + +enum SpaceEvent { + case initialState([AnySpace]) + case focusChanged(String) + case windowsUpdated(String, [AnyWindow]) + case spaceCreated(String) + case spaceDestroyed(String) +} + +protocol EventBasedSpacesProvider { + var spacesPublisher: AnyPublisher { get } + + func startObserving() + func stopObserving() +} + protocol SwitchableSpacesProvider: SpacesProvider { func focusSpace(spaceId: String, needWindowFocus: Bool) func focusWindow(windowId: String) @@ -62,6 +80,12 @@ struct AnySpace: Identifiable, Equatable { self.windows = space.windows.map { AnyWindow($0) } } + init(id: String, isFocused: Bool, windows: [AnyWindow]) { + self.id = id + self.isFocused = isFocused + self.windows = windows + } + static func == (lhs: AnySpace, rhs: AnySpace) -> Bool { return lhs.id == rhs.id && lhs.isFocused == rhs.isFocused && lhs.windows == rhs.windows @@ -73,10 +97,19 @@ class AnySpacesProvider { private let _focusSpace: ((String, Bool) -> Void)? private let _focusWindow: ((String) -> Void)? + private let _isEventBased: Bool + private let _startObserving: (() -> Void)? + private let _stopObserving: (() -> Void)? + private let _spacesPublisher: AnyPublisher? + + var isEventBased: Bool { _isEventBased } + var spacesPublisher: AnyPublisher? { _spacesPublisher } + init(_ provider: P) { _getSpacesWithWindows = { provider.getSpacesWithWindows()?.map { AnySpace($0) } } + if let switchable = provider as? any SwitchableSpacesProvider { _focusSpace = { spaceId, needWindowFocus in switchable.focusSpace( @@ -89,6 +122,18 @@ class AnySpacesProvider { _focusSpace = nil _focusWindow = nil } + + if let eventBased = provider as? any EventBasedSpacesProvider { + _isEventBased = true + _startObserving = eventBased.startObserving + _stopObserving = eventBased.stopObserving + _spacesPublisher = eventBased.spacesPublisher + } else { + _isEventBased = false + _startObserving = nil + _stopObserving = nil + _spacesPublisher = nil + } } func getSpacesWithWindows() -> [AnySpace]? { @@ -102,4 +147,12 @@ class AnySpacesProvider { func focusWindow(windowId: String) { _focusWindow?(windowId) } + + func startObserving() { + _startObserving?() + } + + func stopObserving() { + _stopObserving?() + } } diff --git a/Barik/Widgets/Spaces/SpacesViewModel.swift b/Barik/Widgets/Spaces/SpacesViewModel.swift index 858e59b..e05d744 100644 --- a/Barik/Widgets/Spaces/SpacesViewModel.swift +++ b/Barik/Widgets/Spaces/SpacesViewModel.swift @@ -6,6 +6,8 @@ class SpacesViewModel: ObservableObject { @Published var spaces: [AnySpace] = [] private var timer: Timer? private var provider: AnySpacesProvider? + private var cancellables: Set = [] + private var spacesById: [String: AnySpace] = [:] init() { let runningApps = NSWorkspace.shared.runningApplications.compactMap { @@ -26,6 +28,26 @@ class SpacesViewModel: ObservableObject { } private func startMonitoring() { + if let provider = provider { + if provider.isEventBased { + startMonitoringEventBasedProvider() + } else { + startMonitoringPollingBasedProvider() + } + } + } + + private func stopMonitoring() { + if let provider = provider { + if provider.isEventBased { + stopMonitoringEventBasedProvider() + } else { + stopMonitoringPollingBasedProvider() + } + } + } + + private func startMonitoringPollingBasedProvider() { timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in self?.loadSpaces() @@ -33,11 +55,63 @@ class SpacesViewModel: ObservableObject { loadSpaces() } - private func stopMonitoring() { + private func stopMonitoringPollingBasedProvider() { timer?.invalidate() timer = nil } + private func startMonitoringEventBasedProvider() { + guard let provider = provider else { return } + provider.spacesPublisher? + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + self?.handleSpaceEvent(event) + } + .store(in: &cancellables) + provider.startObserving() + } + + private func stopMonitoringEventBasedProvider() { + provider?.stopObserving() + cancellables.removeAll() + } + + private func handleSpaceEvent(_ event: SpaceEvent) { + switch event { + case .initialState(let spaces): + spacesById = Dictionary(uniqueKeysWithValues: spaces.map { ($0.id, $0) }) + updatePublishedSpaces() + case .focusChanged(let spaceId): + for (id, space) in spacesById { + let newFocused = id == spaceId + if space.isFocused != newFocused { + spacesById[id] = AnySpace( + id: space.id, isFocused: newFocused, windows: space.windows) + } + } + updatePublishedSpaces() + case .windowsUpdated(let spaceId, let windows): + if let space = spacesById[spaceId] { + spacesById[spaceId] = AnySpace( + id: space.id, isFocused: space.isFocused, windows: windows) + } + updatePublishedSpaces() + case .spaceCreated(let spaceId): + spacesById[spaceId] = AnySpace(id: spaceId, isFocused: false, windows: []) + updatePublishedSpaces() + case .spaceDestroyed(let spaceId): + spacesById.removeValue(forKey: spaceId) + updatePublishedSpaces() + } + } + + private func updatePublishedSpaces() { + let sortedSpaces = spacesById.values.sorted { $0.id < $1.id } + if sortedSpaces != spaces { + spaces = sortedSpaces + } + } + private func loadSpaces() { DispatchQueue.global(qos: .background).async { guard let provider = self.provider, diff --git a/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift b/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift index 91d3e0e..e7ec827 100644 --- a/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift +++ b/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift @@ -1,9 +1,149 @@ +import Combine import Foundation -class YabaiSpacesProvider: SpacesProvider, SwitchableSpacesProvider { +class YabaiSpacesProvider: SpacesProvider, SwitchableSpacesProvider, EventBasedSpacesProvider { typealias SpaceType = YabaiSpace let executablePath = ConfigManager.shared.config.yabai.path + // MARK: - Event-Based Provider Support + + private let spacesSubject = PassthroughSubject() + var spacesPublisher: AnyPublisher { + spacesSubject.eraseToAnyPublisher() + } + + private var socketFileDescriptor: Int32 = -1 + private var socketPath = "/tmp/barik-yabai.sock" + private var isObserving = false + private var socketQueue = DispatchQueue(label: "com.barik.yabai.socket", qos: .userInitiated) + + func startObserving() { + guard !isObserving else { return } + isObserving = true + + // Send initial state + if let spaces = getSpacesWithWindows() { + let anySpaces = spaces.map { AnySpace($0) } + spacesSubject.send(.initialState(anySpaces)) + } + + // Start socket listener + socketQueue.async { [weak self] in + self?.startSocketListener() + } + } + + func stopObserving() { + isObserving = false + if socketFileDescriptor >= 0 { + close(socketFileDescriptor) + socketFileDescriptor = -1 + } + unlink(socketPath) + } + + private func startSocketListener() { + // Remove existing socket file + unlink(socketPath) + + // Create Unix domain socket + socketFileDescriptor = socket(AF_UNIX, SOCK_DGRAM, 0) + guard socketFileDescriptor >= 0 else { + print("Failed to create socket") + return + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathSize = MemoryLayout.size(ofValue: addr.sun_path) + socketPath.withCString { ptr in + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let pathBytes = UnsafeMutableRawPointer(pathPtr) + .assumingMemoryBound(to: CChar.self) + strncpy(pathBytes, ptr, pathSize - 1) + } + } + + let bindResult = withUnsafePointer(to: &addr) { addrPtr in + addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + bind(socketFileDescriptor, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + + guard bindResult >= 0 else { + print("Failed to bind socket: \(String(cString: strerror(errno)))") + close(socketFileDescriptor) + socketFileDescriptor = -1 + return + } + + // Listen for messages + var buffer = [CChar](repeating: 0, count: 1024) + while isObserving && socketFileDescriptor >= 0 { + let bytesRead = recv(socketFileDescriptor, &buffer, buffer.count - 1, 0) + if bytesRead > 0 { + buffer[bytesRead] = 0 + let message = String(cString: buffer) + handleSocketMessage(message.trimmingCharacters(in: .whitespacesAndNewlines)) + } + } + } + + private func handleSocketMessage(_ message: String) { + // Parse message format: "event_type:data" or just "event_type" + let parts = message.split(separator: ":", maxSplits: 1) + let eventType = String(parts[0]) + let data = parts.count > 1 ? String(parts[1]) : nil + + switch eventType { + case "space_changed": + if let spaceId = data { + spacesSubject.send(.focusChanged(spaceId)) + } else { + // Fallback: query current focused space + refreshSpaces() + } + + case "window_focused", "window_created", "window_destroyed", "window_moved": + // For window events, refresh the windows for affected space + if let spaceIdStr = data, let spaces = getSpacesWithWindows() { + if let space = spaces.first(where: { String($0.id) == spaceIdStr }) { + let windows = space.windows.map { AnyWindow($0) } + spacesSubject.send(.windowsUpdated(spaceIdStr, windows)) + } + } else { + refreshSpaces() + } + + case "space_created": + if let spaceId = data { + spacesSubject.send(.spaceCreated(spaceId)) + } else { + refreshSpaces() + } + + case "space_destroyed": + if let spaceId = data { + spacesSubject.send(.spaceDestroyed(spaceId)) + } else { + refreshSpaces() + } + + default: + // Unknown event, refresh all + refreshSpaces() + } + } + + private func refreshSpaces() { + if let spaces = getSpacesWithWindows() { + let anySpaces = spaces.map { AnySpace($0) } + spacesSubject.send(.initialState(anySpaces)) + } + } + + // MARK: - Original Provider Methods + private func runYabaiCommand(arguments: [String]) -> Data? { let process = Process() process.executableURL = URL(fileURLWithPath: executablePath) From d20a3de118add0be404718fc762e94128e534072 Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Sat, 3 Jan 2026 15:16:33 -0800 Subject: [PATCH 04/30] feat: add weather popup with hourly forecast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add WeatherPopup matching Barik's dark theme style - Show location name, current temp, condition, high/low - Display hourly forecast with icons and precipitation % - Add "Open Weather" button to launch macOS Weather app - Extend WeatherManager with hourly data and location name via geocoding 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Barik/Widgets/Weather/WeatherPopup.swift | 140 ++++++++++++++++++++ Barik/Widgets/Weather/WeatherWidget.swift | 148 +++++++++++++++++++++- 2 files changed, 282 insertions(+), 6 deletions(-) create mode 100644 Barik/Widgets/Weather/WeatherPopup.swift diff --git a/Barik/Widgets/Weather/WeatherPopup.swift b/Barik/Widgets/Weather/WeatherPopup.swift new file mode 100644 index 0000000..9cb2ea1 --- /dev/null +++ b/Barik/Widgets/Weather/WeatherPopup.swift @@ -0,0 +1,140 @@ +import SwiftUI + +struct WeatherPopup: View { + @ObservedObject private var weatherManager = WeatherManager.shared + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let weather = weatherManager.currentWeather { + // Header: Location + Current Weather + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(weatherManager.locationName ?? "Current Location") + .font(.system(size: 14, weight: .medium)) + Image(systemName: "location.fill") + .font(.system(size: 8)) + .opacity(0.6) + } + Text(weather.temperature) + .font(.system(size: 48, weight: .regular)) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Image(systemName: weather.symbolName) + .symbolRenderingMode(.multicolor) + .font(.system(size: 28)) + Text(weather.condition) + .font(.system(size: 13)) + .opacity(0.8) + if let high = weatherManager.highTemp, let low = weatherManager.lowTemp { + Text("H:\(high) L:\(low)") + .font(.system(size: 12)) + .opacity(0.6) + } + } + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 15) + + Divider() + .background(Color.white.opacity(0.2)) + + // Precipitation indicator (if raining) + if let precipitation = weatherManager.precipitation, precipitation > 0 { + HStack(spacing: 8) { + Image(systemName: "umbrella.fill") + .font(.system(size: 14)) + Text("\(Int(precipitation * 100))% chance of rain") + .font(.system(size: 13)) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + + Divider() + .background(Color.white.opacity(0.2)) + } + + // Hourly Forecast + if !weatherManager.hourlyForecast.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 20) { + ForEach(weatherManager.hourlyForecast.prefix(6), id: \.time) { hour in + VStack(spacing: 8) { + Text(hour.timeLabel) + .font(.system(size: 12, weight: .medium)) + .opacity(0.8) + Image(systemName: hour.symbolName) + .symbolRenderingMode(.multicolor) + .font(.system(size: 20)) + if let precip = hour.precipitationProbability, precip > 0 { + Text("\(precip)%") + .font(.system(size: 10)) + .foregroundColor(.cyan) + } + Text(hour.temperature) + .font(.system(size: 14, weight: .medium)) + } + .frame(width: 50) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 15) + } + + Divider() + .background(Color.white.opacity(0.2)) + } + + // Open Weather button + Button(action: { + SystemUIHelper.openWeatherApp() + }) { + HStack { + Text("Open Weather") + .font(.system(size: 13)) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12)) + .opacity(0.5) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(Color.white.opacity(0.001)) + .onHover { hovering in + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + + } else { + // Loading state + VStack(spacing: 12) { + ProgressView() + Text("Loading weather...") + .font(.system(size: 13)) + .opacity(0.6) + } + .frame(maxWidth: .infinity) + .padding(40) + } + } + .frame(width: 280) + .background(Color.black) + } +} + +struct WeatherPopup_Previews: PreviewProvider { + static var previews: some View { + WeatherPopup() + .background(Color.black) + } +} diff --git a/Barik/Widgets/Weather/WeatherWidget.swift b/Barik/Widgets/Weather/WeatherWidget.swift index c0cd4ec..f9dfcca 100644 --- a/Barik/Widgets/Weather/WeatherWidget.swift +++ b/Barik/Widgets/Weather/WeatherWidget.swift @@ -3,7 +3,10 @@ import CoreLocation /// Weather widget that displays current weather using Open-Meteo API struct WeatherWidget: View { - @StateObject private var weatherManager = WeatherManager.shared + @EnvironmentObject var configProvider: ConfigProvider + @ObservedObject private var weatherManager = WeatherManager.shared + + @State private var widgetFrame: CGRect = .zero var body: some View { HStack(spacing: 4) { @@ -27,8 +30,21 @@ struct WeatherWidget: View { .experimentalConfiguration(cornerRadius: 15) .frame(maxHeight: .infinity) .background(.black.opacity(0.001)) + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + widgetFrame = geometry.frame(in: .global) + } + .onChange(of: geometry.frame(in: .global)) { _, newFrame in + widgetFrame = newFrame + } + } + ) .onTapGesture { - SystemUIHelper.openWeatherDropdown() + MenuBarPopup.show(rect: widgetFrame, id: "weather") { + WeatherPopup() + } } .onAppear { weatherManager.startUpdating() @@ -36,7 +52,7 @@ struct WeatherWidget: View { } } -// MARK: - Weather Data Model +// MARK: - Weather Data Models struct CurrentWeather { let temperature: String @@ -44,13 +60,25 @@ struct CurrentWeather { let condition: String } +struct HourlyForecast { + let time: Date + let timeLabel: String + let temperature: String + let symbolName: String + let precipitationProbability: Int? +} + // MARK: - Open-Meteo API Response struct OpenMeteoResponse: Codable { let currentWeather: OpenMeteoCurrentWeather + let hourly: OpenMeteoHourly? + let daily: OpenMeteoDaily? enum CodingKeys: String, CodingKey { case currentWeather = "current_weather" + case hourly + case daily } } @@ -59,6 +87,30 @@ struct OpenMeteoCurrentWeather: Codable { let weathercode: Int } +struct OpenMeteoHourly: Codable { + let time: [String] + let temperature2m: [Double] + let weathercode: [Int] + let precipitationProbability: [Int]? + + enum CodingKeys: String, CodingKey { + case time + case temperature2m = "temperature_2m" + case weathercode + case precipitationProbability = "precipitation_probability" + } +} + +struct OpenMeteoDaily: Codable { + let temperature2mMax: [Double] + let temperature2mMin: [Double] + + enum CodingKeys: String, CodingKey { + case temperature2mMax = "temperature_2m_max" + case temperature2mMin = "temperature_2m_min" + } +} + // MARK: - Weather Manager @MainActor @@ -66,9 +118,15 @@ final class WeatherManager: NSObject, ObservableObject { static let shared = WeatherManager() @Published private(set) var currentWeather: CurrentWeather? + @Published private(set) var hourlyForecast: [HourlyForecast] = [] + @Published private(set) var locationName: String? + @Published private(set) var highTemp: String? + @Published private(set) var lowTemp: String? + @Published private(set) var precipitation: Double? @Published private(set) var isLoading = false private let locationManager = CLLocationManager() + private let geocoder = CLGeocoder() private var lastLocation: CLLocation? private var updateTimer: Timer? @@ -104,11 +162,20 @@ final class WeatherManager: NSObject, ObservableObject { isLoading = true + // Reverse geocode for location name + geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, _ in + if let placemark = placemarks?.first { + Task { @MainActor in + self?.locationName = placemark.locality ?? placemark.administrativeArea ?? "Unknown" + } + } + } + Task { do { let lat = location.coordinate.latitude let lon = location.coordinate.longitude - let urlString = "https://api.open-meteo.com/v1/forecast?latitude=\(lat)&longitude=\(lon)¤t_weather=true&temperature_unit=fahrenheit" + let urlString = "https://api.open-meteo.com/v1/forecast?latitude=\(lat)&longitude=\(lon)¤t_weather=true&hourly=temperature_2m,weathercode,precipitation_probability&daily=temperature_2m_max,temperature_2m_min&temperature_unit=fahrenheit&timezone=auto&forecast_days=1" guard let url = URL(string: urlString) else { isLoading = false @@ -118,6 +185,7 @@ final class WeatherManager: NSObject, ObservableObject { let (data, _) = try await URLSession.shared.data(from: url) let response = try JSONDecoder().decode(OpenMeteoResponse.self, from: data) + // Current weather let temp = Int(response.currentWeather.temperature.rounded()) let symbol = symbolName(for: response.currentWeather.weathercode) let condition = conditionName(for: response.currentWeather.weathercode) @@ -127,6 +195,66 @@ final class WeatherManager: NSObject, ObservableObject { symbolName: symbol, condition: condition ) + + // Daily high/low + if let daily = response.daily { + if let high = daily.temperature2mMax.first { + self.highTemp = "\(Int(high.rounded()))°" + } + if let low = daily.temperature2mMin.first { + self.lowTemp = "\(Int(low.rounded()))°" + } + } + + // Hourly forecast + if let hourly = response.hourly { + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withFullDate, .withTime, .withDashSeparatorInDate, .withColonSeparatorInTime] + + let timeFormatter = DateFormatter() + timeFormatter.dateFormat = "ha" + + let now = Date() + var forecasts: [HourlyForecast] = [] + + for i in 0.. now { + let tempF = Int(hourly.temperature2m[i].rounded()) + let sym = symbolName(for: hourly.weathercode[i]) + let precip = hourly.precipitationProbability?[safe: i] + + let label = forecasts.isEmpty ? "Now" : timeFormatter.string(from: date) + + forecasts.append(HourlyForecast( + time: date, + timeLabel: label, + temperature: "\(tempF)°", + symbolName: sym, + precipitationProbability: precip + )) + + if forecasts.count >= 6 { break } + } + } + + // Set precipitation from first hour + if let firstPrecip = hourly.precipitationProbability?.first(where: { $0 > 0 }) { + self.precipitation = Double(firstPrecip) / 100.0 + } else { + self.precipitation = nil + } + + self.hourlyForecast = forecasts + } } catch { print("Weather fetch error: \(error)") } @@ -135,7 +263,7 @@ final class WeatherManager: NSObject, ObservableObject { } /// Maps Open-Meteo weather codes to SF Symbols - private func symbolName(for code: Int) -> String { + func symbolName(for code: Int) -> String { switch code { case 0: return "sun.max.fill" @@ -163,7 +291,7 @@ final class WeatherManager: NSObject, ObservableObject { } /// Maps Open-Meteo weather codes to condition names - private func conditionName(for code: Int) -> String { + func conditionName(for code: Int) -> String { switch code { case 0: return "Clear" @@ -228,6 +356,14 @@ extension WeatherManager: CLLocationManagerDelegate { } } +// MARK: - Array Safe Subscript + +extension Array { + subscript(safe index: Int) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} + struct WeatherWidget_Previews: PreviewProvider { static var previews: some View { ZStack { From 59cc6253119ac47b3d91cffd5f5ac5a778b332fd Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Sat, 3 Jan 2026 15:28:45 -0800 Subject: [PATCH 05/30] fix: move yabai calls off main thread to prevent crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Run initial space fetch asynchronously in startObserving() - Run refreshSpaces() on background queue - Dispatch results back to main thread for UI updates - Fixes SwiftUI AttributeGraph crash from blocking main thread 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../Widgets/Spaces/Yabai/YabaiProvider.swift | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift b/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift index e7ec827..48366b6 100644 --- a/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift +++ b/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift @@ -21,10 +21,15 @@ class YabaiSpacesProvider: SpacesProvider, SwitchableSpacesProvider, EventBasedS guard !isObserving else { return } isObserving = true - // Send initial state - if let spaces = getSpacesWithWindows() { - let anySpaces = spaces.map { AnySpace($0) } - spacesSubject.send(.initialState(anySpaces)) + // Send initial state asynchronously to avoid blocking main thread + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { return } + if let spaces = self.getSpacesWithWindows() { + let anySpaces = spaces.map { AnySpace($0) } + DispatchQueue.main.async { + self.spacesSubject.send(.initialState(anySpaces)) + } + } } // Start socket listener @@ -136,9 +141,14 @@ class YabaiSpacesProvider: SpacesProvider, SwitchableSpacesProvider, EventBasedS } private func refreshSpaces() { - if let spaces = getSpacesWithWindows() { - let anySpaces = spaces.map { AnySpace($0) } - spacesSubject.send(.initialState(anySpaces)) + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { return } + if let spaces = self.getSpacesWithWindows() { + let anySpaces = spaces.map { AnySpace($0) } + DispatchQueue.main.async { + self.spacesSubject.send(.initialState(anySpaces)) + } + } } } From b3776d4b2aaa72e5a2a90deda14490b079f8deeb Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Sat, 3 Jan 2026 15:33:55 -0800 Subject: [PATCH 06/30] feat: add click-action config option for time widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `click-action` config option to TimeWidget - Supports "calendar" (default) or "notification-center" - When set to "notification-center", clicking time opens macOS Notification Center Usage in config.toml: ```toml [widgets.default.time] click-action = "notification-center" ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Barik/Widgets/Time+Calendar/TimeWidget.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Barik/Widgets/Time+Calendar/TimeWidget.swift b/Barik/Widgets/Time+Calendar/TimeWidget.swift index bb8ac60..c450608 100644 --- a/Barik/Widgets/Time+Calendar/TimeWidget.swift +++ b/Barik/Widgets/Time+Calendar/TimeWidget.swift @@ -8,6 +8,7 @@ struct TimeWidget: View { var format: String { config["format"]?.stringValue ?? "E d, J:mm" } var timeZone: String? { config["time-zone"]?.stringValue } + var clickAction: String { config["click-action"]?.stringValue ?? "calendar" } var calendarFormat: String { calendarConfig?["format"]?.stringValue ?? "J:mm" @@ -57,10 +58,17 @@ struct TimeWidget: View { .background(.black.opacity(0.001)) .monospacedDigit() .onTapGesture { - MenuBarPopup.show(rect: rect, id: "calendar") { - CalendarPopup( - calendarManager: calendarManager, - configProvider: configProvider) + switch clickAction { + case "notification-center": + SystemUIHelper.openNotificationCenter() + case "calendar": + fallthrough + default: + MenuBarPopup.show(rect: rect, id: "calendar") { + CalendarPopup( + calendarManager: calendarManager, + configProvider: configProvider) + } } } } From c78e49e19098e47e9a2f200514795c43fe02a25b Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Sat, 3 Jan 2026 15:43:22 -0800 Subject: [PATCH 07/30] feat: add click-action config for time widget with accessibility prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `click-action` config option to TimeWidget ("calendar" or "notification-center") - Request Accessibility permission before opening Notification Center - System prompts user to grant permission if not already allowed Usage: ```toml [widgets.default.time] click-action = "notification-center" ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Barik/Helpers/SystemUIHelper.swift | 10 ++++++++++ Barik/Widgets/Time+Calendar/TimeWidget.swift | 2 -- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Barik/Helpers/SystemUIHelper.swift b/Barik/Helpers/SystemUIHelper.swift index 2c9a3df..9d8b819 100644 --- a/Barik/Helpers/SystemUIHelper.swift +++ b/Barik/Helpers/SystemUIHelper.swift @@ -6,6 +6,16 @@ final class SystemUIHelper { /// Opens the macOS Notification Center static func openNotificationCenter() { + // Check for Accessibility permission first + let trusted = AXIsProcessTrustedWithOptions( + [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary + ) + + guard trusted else { + // System will show the prompt automatically + return + } + let script = """ tell application "System Events" tell process "ControlCenter" diff --git a/Barik/Widgets/Time+Calendar/TimeWidget.swift b/Barik/Widgets/Time+Calendar/TimeWidget.swift index c450608..b111d05 100644 --- a/Barik/Widgets/Time+Calendar/TimeWidget.swift +++ b/Barik/Widgets/Time+Calendar/TimeWidget.swift @@ -61,8 +61,6 @@ struct TimeWidget: View { switch clickAction { case "notification-center": SystemUIHelper.openNotificationCenter() - case "calendar": - fallthrough default: MenuBarPopup.show(rect: rect, id: "calendar") { CalendarPopup( From 0e0a782f04a1066d463fb6659c8623f965a606f3 Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Sat, 3 Jan 2026 15:53:31 -0800 Subject: [PATCH 08/30] fix: use SOCK_STREAM for yabai socket communication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed from SOCK_DGRAM to SOCK_STREAM for nc -U compatibility - Added listen() and accept() for stream socket handling - Fixes event-based space subscription not receiving messages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../Widgets/Spaces/Yabai/YabaiProvider.swift | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift b/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift index 48366b6..2ef7bdf 100644 --- a/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift +++ b/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift @@ -51,8 +51,8 @@ class YabaiSpacesProvider: SpacesProvider, SwitchableSpacesProvider, EventBasedS // Remove existing socket file unlink(socketPath) - // Create Unix domain socket - socketFileDescriptor = socket(AF_UNIX, SOCK_DGRAM, 0) + // Create Unix domain socket (SOCK_STREAM for nc -U compatibility) + socketFileDescriptor = socket(AF_UNIX, SOCK_STREAM, 0) guard socketFileDescriptor >= 0 else { print("Failed to create socket") return @@ -82,14 +82,26 @@ class YabaiSpacesProvider: SpacesProvider, SwitchableSpacesProvider, EventBasedS return } - // Listen for messages + // Listen for incoming connections + guard listen(socketFileDescriptor, 5) >= 0 else { + print("Failed to listen on socket: \(String(cString: strerror(errno)))") + close(socketFileDescriptor) + socketFileDescriptor = -1 + return + } + + // Accept connections and read messages var buffer = [CChar](repeating: 0, count: 1024) while isObserving && socketFileDescriptor >= 0 { - let bytesRead = recv(socketFileDescriptor, &buffer, buffer.count - 1, 0) - if bytesRead > 0 { - buffer[bytesRead] = 0 - let message = String(cString: buffer) - handleSocketMessage(message.trimmingCharacters(in: .whitespacesAndNewlines)) + let clientFd = accept(socketFileDescriptor, nil, nil) + if clientFd >= 0 { + let bytesRead = recv(clientFd, &buffer, buffer.count - 1, 0) + if bytesRead > 0 { + buffer[bytesRead] = 0 + let message = String(cString: buffer) + handleSocketMessage(message.trimmingCharacters(in: .whitespacesAndNewlines)) + } + close(clientFd) } } } From ded70fbd8aefe7b294430c45fdd7e529ceaa83c3 Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Sat, 3 Jan 2026 16:03:59 -0800 Subject: [PATCH 09/30] feat: enhanced WiFi popup with macOS-style controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add WiFi toggle switch at the top - Show connected network in "Known Network" section - Add expandable "Other Networks" section with available networks - Add network scanning functionality - Add "Wi-Fi Settings..." button to open System Preferences - Fixed width to 280px for consistent appearance 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Barik/Widgets/Network/NetworkPopup.swift | 300 ++++++++++++------- Barik/Widgets/Network/NetworkViewModel.swift | 152 ++++++++++ 2 files changed, 348 insertions(+), 104 deletions(-) diff --git a/Barik/Widgets/Network/NetworkPopup.swift b/Barik/Widgets/Network/NetworkPopup.swift index 8d5465d..623f427 100644 --- a/Barik/Widgets/Network/NetworkPopup.swift +++ b/Barik/Widgets/Network/NetworkPopup.swift @@ -1,129 +1,221 @@ import SwiftUI -/// Window displaying detailed network status information. +/// Window displaying detailed network status information with WiFi controls. struct NetworkPopup: View { @StateObject private var viewModel = NetworkStatusViewModel() + @State private var showOtherNetworks = false var body: some View { - VStack(alignment: .leading, spacing: 16) { - if viewModel.wifiState != .notSupported { - HStack(spacing: 8) { - wifiIcon - Text(viewModel.ssid) + VStack(alignment: .leading, spacing: 0) { + // WiFi Toggle Header + wifiToggleHeader + + Divider() + .background(Color.gray.opacity(0.3)) + .padding(.vertical, 8) + + if viewModel.isWiFiEnabled { + // Known Network (currently connected) + if viewModel.ssid != "Not connected" && viewModel.ssid != "No interface" { + knownNetworkSection + + Divider() + .background(Color.gray.opacity(0.3)) + .padding(.vertical, 8) + } + + // Other Networks + otherNetworksSection + + Divider() + .background(Color.gray.opacity(0.3)) + .padding(.vertical, 8) + } + + // WiFi Settings Button + wifiSettingsButton + } + .padding(16) + .frame(width: 280) + .background(Color.black) + .onAppear { + if viewModel.isWiFiEnabled { + viewModel.scanForNetworks() + } + } + } + + // MARK: - WiFi Toggle Header + + private var wifiToggleHeader: some View { + HStack { + Text("Wi-Fi") + .font(.headline) + .foregroundColor(.white) + + Spacer() + + Toggle("", isOn: Binding( + get: { viewModel.isWiFiEnabled }, + set: { _ in viewModel.toggleWiFi() } + )) + .toggleStyle(SwitchToggleStyle(tint: .blue)) + .labelsHidden() + } + .padding(.bottom, 4) + } + + // MARK: - Known Network Section + + private var knownNetworkSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Known Network") + .font(.subheadline) + .foregroundColor(.gray) + + HStack(spacing: 12) { + // WiFi icon with signal strength + ZStack { + Circle() + .fill(Color.blue) + .frame(width: 32, height: 32) + + Image(systemName: wifiIconName(for: viewModel.rssi)) + .font(.system(size: 14)) .foregroundColor(.white) - .font(.headline) } - if viewModel.ssid != "Not connected" - && viewModel.ssid != "No interface" - { - VStack(alignment: .leading, spacing: 4) { - Text( - "Signal strength: \(viewModel.wifiSignalStrength.rawValue)" - ) - Text("RSSI: \(viewModel.rssi)") - Text("Noise: \(viewModel.noise)") - Text("Channel: \(viewModel.channel)") + Text(viewModel.ssid) + .font(.body) + .foregroundColor(.white) + + Spacer() + + Image(systemName: "lock.fill") + .font(.system(size: 12)) + .foregroundColor(.gray) + } + .padding(.vertical, 4) + } + } + + // MARK: - Other Networks Section + + private var otherNetworksSection: some View { + VStack(alignment: .leading, spacing: 8) { + // Header with expand/collapse + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + showOtherNetworks.toggle() + } + if showOtherNetworks && viewModel.availableNetworks.isEmpty { + viewModel.scanForNetworks() + } + }) { + HStack { + Text("Other Networks") + .font(.subheadline) + .foregroundColor(.gray) + + Spacer() + + if viewModel.isScanning { + ProgressView() + .scaleEffect(0.7) + .frame(width: 16, height: 16) + } else { + Image(systemName: showOtherNetworks ? "chevron.down" : "chevron.right") + .font(.system(size: 12)) + .foregroundColor(.gray) } - .font(.subheadline) } } + .buttonStyle(PlainButtonStyle()) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(showOtherNetworks ? Color.blue.opacity(0.3) : Color.clear) + .padding(.horizontal, -8) + .padding(.vertical, -4) + ) - // Ethernet section - if viewModel.ethernetState != .notSupported { - HStack(spacing: 8) { - ethernetIcon - Text("Ethernet: \(viewModel.ethernetState.rawValue)") - .foregroundColor(.white) - .font(.headline) + // Network list + if showOtherNetworks { + ScrollView { + VStack(alignment: .leading, spacing: 2) { + ForEach(otherNetworks) { network in + networkRow(network) + } + } } + .frame(maxHeight: 300) } } - .padding(25) - .background(Color.black) } - /// Chooses the Wi‑Fi icon based on the status and connection availability. - private var wifiIcon: some View { - if viewModel.ssid == "Not connected" { - return Image(systemName: "wifi.slash") - .padding(8) - .background(Color.red.opacity(0.8)) - .clipShape(Circle()) - .foregroundStyle(.white) + private var otherNetworks: [WiFiNetwork] { + viewModel.availableNetworks.filter { !$0.isConnected } + } + + private func networkRow(_ network: WiFiNetwork) -> some View { + Button(action: { + viewModel.connectToNetwork(network) + }) { + HStack(spacing: 12) { + Image(systemName: wifiIconName(for: network.rssi)) + .font(.system(size: 14)) + .foregroundColor(.gray) + .frame(width: 20) + + Text(network.ssid) + .font(.body) + .foregroundColor(.white) + .lineLimit(1) + + Spacer() + + if network.isSecure { + Image(systemName: "lock.fill") + .font(.system(size: 12)) + .foregroundColor(.gray) + } + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.white.opacity(0.001)) + ) + .onHover { hovering in + // Could add hover effect here } - switch viewModel.wifiState { - case .connected: - return Image(systemName: "wifi") - .padding(8) - .background(Color.blue.opacity(0.8)) - .clipShape(Circle()) - .foregroundStyle(.white) - case .connecting: - return Image(systemName: "wifi") - .padding(8) - .background(Color.yellow.opacity(0.8)) - .clipShape(Circle()) - .foregroundStyle(.white) - case .connectedWithoutInternet: - return Image(systemName: "wifi.exclamationmark") - .padding(8) - .background(Color.yellow.opacity(0.8)) - .clipShape(Circle()) - .foregroundStyle(.white) - case .disconnected: - return Image(systemName: "wifi.slash") - .padding(8) - .background(Color.gray.opacity(0.8)) - .clipShape(Circle()) - .foregroundStyle(.white) - case .disabled: - return Image(systemName: "wifi.slash") - .padding(8) - .background(Color.red.opacity(0.8)) - .clipShape(Circle()) - .foregroundStyle(.white) - case .notSupported: - return Image(systemName: "wifi.exclamationmark") - .padding(8) - .background(Color.gray.opacity(0.8)) - .clipShape(Circle()) - .foregroundStyle(.white) + } + + // MARK: - WiFi Settings Button + + private var wifiSettingsButton: some View { + Button(action: { + viewModel.openWiFiSettings() + }) { + Text("Wi-Fi Settings...") + .font(.body) + .foregroundColor(.white) } + .buttonStyle(PlainButtonStyle()) } - private var ethernetIcon: some View { - switch viewModel.ethernetState { - case .connected: - return Image(systemName: "network") - .padding(8) - .background(Color.blue.opacity(0.8)) - .clipShape(Circle()) - case .connectedWithoutInternet: - return Image(systemName: "network") - .padding(8) - .background(Color.yellow.opacity(0.8)) - .clipShape(Circle()) - case .connecting: - return Image(systemName: "network.slash") - .padding(8) - .background(Color.yellow.opacity(0.8)) - .clipShape(Circle()) - case .disconnected: - return Image(systemName: "network.slash") - .padding(8) - .background(Color.gray.opacity(0.8)) - .clipShape(Circle()) - case .disabled: - return Image(systemName: "network.slash") - .padding(8) - .background(Color.red.opacity(0.8)) - .clipShape(Circle()) - case .notSupported: - return Image(systemName: "questionmark.circle") - .padding(8) - .background(Color.gray.opacity(0.8)) - .clipShape(Circle()) + // MARK: - Helpers + + private func wifiIconName(for rssi: Int) -> String { + if rssi >= -50 { + return "wifi" + } else if rssi >= -70 { + return "wifi" + } else { + return "wifi" } } } diff --git a/Barik/Widgets/Network/NetworkViewModel.swift b/Barik/Widgets/Network/NetworkViewModel.swift index 2a0ba3e..3d8a2fd 100644 --- a/Barik/Widgets/Network/NetworkViewModel.swift +++ b/Barik/Widgets/Network/NetworkViewModel.swift @@ -19,6 +19,28 @@ enum WifiSignalStrength: String { case unknown = "Unknown" } +struct WiFiNetwork: Identifiable, Hashable { + let id = UUID() + let ssid: String + let rssi: Int + let isSecure: Bool + let isConnected: Bool + + var signalBars: Int { + if rssi >= -50 { return 3 } + else if rssi >= -70 { return 2 } + else { return 1 } + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ssid) + } + + static func == (lhs: WiFiNetwork, rhs: WiFiNetwork) -> Bool { + lhs.ssid == rhs.ssid + } +} + /// Unified view model for monitoring network and Wi‑Fi status. final class NetworkStatusViewModel: NSObject, ObservableObject, CLLocationManagerDelegate @@ -34,6 +56,11 @@ final class NetworkStatusViewModel: NSObject, ObservableObject, @Published var noise: Int = 0 @Published var channel: String = "N/A" + // WiFi control and scanning + @Published var isWiFiEnabled: Bool = true + @Published var availableNetworks: [WiFiNetwork] = [] + @Published var isScanning: Bool = false + /// Computed property for signal strength. var wifiSignalStrength: WifiSignalStrength { // If Wi‑Fi is not connected or the interface is missing – return unknown. @@ -59,6 +86,7 @@ final class NetworkStatusViewModel: NSObject, ObservableObject, super.init() locationManager.delegate = self locationManager.requestWhenInUseAuthorization() + checkWiFiPowerState() startNetworkMonitoring() startWiFiMonitoring() } @@ -178,4 +206,128 @@ final class NetworkStatusViewModel: NSObject, ObservableObject, ) { updateWiFiInfo() } + + // MARK: — WiFi Control Methods + + /// Toggle WiFi on/off + func toggleWiFi() { + let client = CWWiFiClient.shared() + guard let interface = client.interface() else { return } + + do { + let newState = !isWiFiEnabled + try interface.setPower(newState) + DispatchQueue.main.async { + self.isWiFiEnabled = newState + if !newState { + self.ssid = "Not connected" + self.availableNetworks = [] + } else { + self.updateWiFiInfo() + self.scanForNetworks() + } + } + } catch { + print("Failed to toggle WiFi: \(error)") + } + } + + /// Scan for available WiFi networks + func scanForNetworks() { + guard isWiFiEnabled else { return } + + isScanning = true + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { return } + + let client = CWWiFiClient.shared() + guard let interface = client.interface() else { + DispatchQueue.main.async { + self.isScanning = false + } + return + } + + do { + let networks = try interface.scanForNetworks(withSSID: nil) + let currentSSID = interface.ssid() + + var networkList: [WiFiNetwork] = [] + var seenSSIDs = Set() + + for network in networks { + guard let ssid = network.ssid, !ssid.isEmpty, !seenSSIDs.contains(ssid) else { + continue + } + seenSSIDs.insert(ssid) + + let wifiNetwork = WiFiNetwork( + ssid: ssid, + rssi: network.rssiValue, + isSecure: network.supportsSecurity(.wpaPersonal) || + network.supportsSecurity(.wpa2Personal) || + network.supportsSecurity(.wpa3Personal) || + network.supportsSecurity(.dynamicWEP), + isConnected: ssid == currentSSID + ) + networkList.append(wifiNetwork) + } + + // Sort: connected first, then by signal strength + networkList.sort { lhs, rhs in + if lhs.isConnected != rhs.isConnected { + return lhs.isConnected + } + return lhs.rssi > rhs.rssi + } + + DispatchQueue.main.async { + self.availableNetworks = networkList + self.isScanning = false + } + } catch { + print("Failed to scan for networks: \(error)") + DispatchQueue.main.async { + self.isScanning = false + } + } + } + } + + /// Connect to a WiFi network + func connectToNetwork(_ network: WiFiNetwork, password: String? = nil) { + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let client = CWWiFiClient.shared() + guard let interface = client.interface() else { return } + + do { + let networks = try interface.scanForNetworks(withSSID: network.ssid.data(using: .utf8)) + guard let targetNetwork = networks.first else { return } + + try interface.associate(to: targetNetwork, password: password) + + DispatchQueue.main.async { + self?.updateWiFiInfo() + self?.scanForNetworks() + } + } catch { + print("Failed to connect to network: \(error)") + } + } + } + + /// Open WiFi settings in System Preferences + func openWiFiSettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.Network-Settings.extension") { + NSWorkspace.shared.open(url) + } + } + + /// Check WiFi power state + private func checkWiFiPowerState() { + let client = CWWiFiClient.shared() + if let interface = client.interface() { + isWiFiEnabled = interface.powerOn() + } + } } From 0171ff07b73610ca6ea7ce3e016fcafe885e2cb0 Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Sat, 3 Jan 2026 23:35:52 -0800 Subject: [PATCH 10/30] feat: use keyboard shortcut simulation for Notification Center MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace fragile AppleScript approach with CGEvent keyboard simulation. Simulates Ctrl+Option+N keypress which user has mapped to Notification Center. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Barik/Helpers/SystemUIHelper.swift | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/Barik/Helpers/SystemUIHelper.swift b/Barik/Helpers/SystemUIHelper.swift index 9d8b819..155e99d 100644 --- a/Barik/Helpers/SystemUIHelper.swift +++ b/Barik/Helpers/SystemUIHelper.swift @@ -4,26 +4,23 @@ import Foundation /// Helper for triggering macOS system UI elements final class SystemUIHelper { - /// Opens the macOS Notification Center + /// Opens the macOS Notification Center by simulating Ctrl+Option+N keypress static func openNotificationCenter() { - // Check for Accessibility permission first - let trusted = AXIsProcessTrustedWithOptions( - [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary - ) + // Simulate Ctrl+Option+N keyboard shortcut + let keyCode: CGKeyCode = 45 // 'n' key + let flags: CGEventFlags = [.maskControl, .maskAlternate] - guard trusted else { - // System will show the prompt automatically - return + // Create and post key down event + if let keyDown = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true) { + keyDown.flags = flags + keyDown.post(tap: .cghidEventTap) } - let script = """ - tell application "System Events" - tell process "ControlCenter" - click menu bar item "Clock" of menu bar 1 - end tell - end tell - """ - runAppleScript(script) + // Create and post key up event + if let keyUp = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: false) { + keyUp.flags = flags + keyUp.post(tap: .cghidEventTap) + } } /// Opens the macOS Weather menu bar dropdown From f9ee3739c244d574214b2a79246576c2897fea33 Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Sat, 3 Jan 2026 23:37:37 -0800 Subject: [PATCH 11/30] fix: correct popup Y positioning to appear directly below menu bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove unnecessary Y offset that pushed popups down by half their height. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Barik/MenuBarPopup/MenuBarPopupView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Barik/MenuBarPopup/MenuBarPopupView.swift b/Barik/MenuBarPopup/MenuBarPopupView.swift index 8cd889b..2518d9f 100644 --- a/Barik/MenuBarPopup/MenuBarPopupView.swift +++ b/Barik/MenuBarPopup/MenuBarPopupView.swift @@ -151,7 +151,7 @@ struct MenuBarPopupView: View { } var computedYOffset: CGFloat { - return viewFrame.height / 2 + return 0 } } From 2335adbb881a99789fe125e79a24dfc1bfa14274 Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Sat, 3 Jan 2026 23:51:46 -0800 Subject: [PATCH 12/30] Fix popup positioning to appear directly below widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use widgetRect.maxY to position popups just below the clicked widget instead of using a fixed foreground height. This ensures popups appear at the correct vertical position regardless of widget location. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Barik/MenuBarPopup/MenuBarPopup.swift | 4 ++-- Barik/MenuBarPopup/MenuBarPopupView.swift | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Barik/MenuBarPopup/MenuBarPopup.swift b/Barik/MenuBarPopup/MenuBarPopup.swift index ef7a111..fa31d6e 100644 --- a/Barik/MenuBarPopup/MenuBarPopup.swift +++ b/Barik/MenuBarPopup/MenuBarPopup.swift @@ -74,7 +74,7 @@ class MenuBarPopup { panel.contentView = NSHostingView( rootView: ZStack { - MenuBarPopupView { + MenuBarPopupView(widgetRect: rect) { content() } .position(x: rect.midX) @@ -92,7 +92,7 @@ class MenuBarPopup { panel.contentView = NSHostingView( rootView: ZStack { - MenuBarPopupView { + MenuBarPopupView(widgetRect: rect) { content() } .position(x: rect.midX) diff --git a/Barik/MenuBarPopup/MenuBarPopupView.swift b/Barik/MenuBarPopup/MenuBarPopupView.swift index 2518d9f..29daa59 100644 --- a/Barik/MenuBarPopup/MenuBarPopupView.swift +++ b/Barik/MenuBarPopup/MenuBarPopupView.swift @@ -3,9 +3,9 @@ import SwiftUI struct MenuBarPopupView: View { let content: Content let isPreview: Bool + let widgetRect: CGRect @ObservedObject var configManager = ConfigManager.shared - var foregroundHeight: CGFloat { configManager.config.experimental.foreground.resolveHeight() } @State private var contentHeight: CGFloat = 0 @State private var viewFrame: CGRect = .zero @@ -21,7 +21,8 @@ struct MenuBarPopupView: View { private let willChangeContent = NotificationCenter.default.publisher( for: .willChangeContent) - init(isPreview: Bool = false, @ViewBuilder content: () -> Content) { + init(widgetRect: CGRect = .zero, isPreview: Bool = false, @ViewBuilder content: () -> Content) { + self.widgetRect = widgetRect self.content = content() self.isPreview = isPreview if isPreview { @@ -29,12 +30,18 @@ struct MenuBarPopupView: View { } } + // Position popup just below the widget + // Simply use widgetRect.maxY (bottom of widget in SwiftUI coords) + small gap + var popupTopPosition: CGFloat { + return widgetRect.maxY + 5 + } + var body: some View { ZStack(alignment: .topTrailing) { content .background(Color.black) .cornerRadius(((1.0 - animationValue) * 1) + 40) - .padding(.top, foregroundHeight + 5) + .padding(.top, popupTopPosition) .offset(x: computedOffset, y: computedYOffset) .shadow(radius: 30) .blur(radius: (1.0 - (0.1 + 0.9 * animationValue)) * 20) From 25be29b8412647e95002951402127677b9c9a1cb Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Sun, 4 Jan 2026 00:03:20 -0800 Subject: [PATCH 13/30] Fix popup positioning to appear just below menu bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adjusted popup offset calculation and added anchor: .top to scaleEffect so popups animate and position correctly below the Barik menu bar. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Barik/MenuBarPopup/MenuBarPopupView.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Barik/MenuBarPopup/MenuBarPopupView.swift b/Barik/MenuBarPopup/MenuBarPopupView.swift index 29daa59..54b3810 100644 --- a/Barik/MenuBarPopup/MenuBarPopupView.swift +++ b/Barik/MenuBarPopup/MenuBarPopupView.swift @@ -6,6 +6,7 @@ struct MenuBarPopupView: View { let widgetRect: CGRect @ObservedObject var configManager = ConfigManager.shared + var foregroundHeight: CGFloat { configManager.config.experimental.foreground.resolveHeight() } @State private var contentHeight: CGFloat = 0 @State private var viewFrame: CGRect = .zero @@ -30,10 +31,9 @@ struct MenuBarPopupView: View { } } - // Position popup just below the widget - // Simply use widgetRect.maxY (bottom of widget in SwiftUI coords) + small gap + // Position popup just below the menu bar var popupTopPosition: CGFloat { - return widgetRect.maxY + 5 + return foregroundHeight + 52 } var body: some View { @@ -41,11 +41,10 @@ struct MenuBarPopupView: View { content .background(Color.black) .cornerRadius(((1.0 - animationValue) * 1) + 40) - .padding(.top, popupTopPosition) - .offset(x: computedOffset, y: computedYOffset) .shadow(radius: 30) .blur(radius: (1.0 - (0.1 + 0.9 * animationValue)) * 20) - .scaleEffect(x: 0.2 + 0.8 * animationValue, y: animationValue) + .scaleEffect(x: 0.2 + 0.8 * animationValue, y: animationValue, anchor: .top) + .offset(x: computedOffset, y: popupTopPosition) .opacity(animationValue) .transaction { transaction in if isHideAnimation { From 713d1153c605c5dca78d7654ccca39f809d80dd1 Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Fri, 16 Jan 2026 13:38:38 -0800 Subject: [PATCH 14/30] feat: replace polling with event-driven notifications - Battery: use IOPSNotificationCreateRunLoopSource instead of 1s timer - Calendar: use EKEventStoreChangedNotification instead of 5s timer - WiFi: use CWEventDelegate for SSID/link changes, reduce RSSI polling to 30s - Spaces: use NSWorkspace notifications instead of 100ms fallback timer - MenuBarPopup: use DispatchWorkItem + asyncAfter instead of Timer This significantly reduces CPU usage and improves power efficiency by responding to system events rather than continuously polling. Co-Authored-By: Claude Opus 4.5 --- Barik/MenuBarPopup/MenuBarPopup.swift | 15 ++-- Barik/Widgets/Battery/BatteryManager.swift | 30 ++++++-- Barik/Widgets/Network/NetworkViewModel.swift | 71 +++++++++++++++++-- Barik/Widgets/Spaces/SpacesViewModel.swift | 50 ++++++++++--- .../Time+Calendar/CalendarManager.swift | 33 ++++++--- 5 files changed, 162 insertions(+), 37 deletions(-) diff --git a/Barik/MenuBarPopup/MenuBarPopup.swift b/Barik/MenuBarPopup/MenuBarPopup.swift index fa31d6e..0643611 100644 --- a/Barik/MenuBarPopup/MenuBarPopup.swift +++ b/Barik/MenuBarPopup/MenuBarPopup.swift @@ -3,7 +3,7 @@ import SwiftUI private var panel: NSPanel? class HidingPanel: NSPanel, NSWindowDelegate { - var hideTimer: Timer? + var hideWorkItem: DispatchWorkItem? override var canBecomeKey: Bool { return true @@ -23,13 +23,12 @@ class HidingPanel: NSPanel, NSWindowDelegate { func windowDidResignKey(_ notification: Notification) { NotificationCenter.default.post(name: .willHideWindow, object: nil) - hideTimer = Timer.scheduledTimer( - withTimeInterval: TimeInterval( - Constants.menuBarPopupAnimationDurationInMilliseconds) / 1000.0, - repeats: false - ) { [weak self] _ in + let workItem = DispatchWorkItem { [weak self] in self?.orderOut(nil) } + hideWorkItem = workItem + let duration = Double(Constants.menuBarPopupAnimationDurationInMilliseconds) / 1000.0 + DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: workItem) } } @@ -59,8 +58,8 @@ class MenuBarPopup { lastContentIdentifier = id if let hidingPanel = panel as? HidingPanel { - hidingPanel.hideTimer?.invalidate() - hidingPanel.hideTimer = nil + hidingPanel.hideWorkItem?.cancel() + hidingPanel.hideWorkItem = nil } if panel.isKeyWindow { diff --git a/Barik/Widgets/Battery/BatteryManager.swift b/Barik/Widgets/Battery/BatteryManager.swift index 7d4e700..7a1e5f3 100644 --- a/Barik/Widgets/Battery/BatteryManager.swift +++ b/Barik/Widgets/Battery/BatteryManager.swift @@ -7,7 +7,7 @@ class BatteryManager: ObservableObject { @Published var batteryLevel: Int = 0 @Published var isCharging: Bool = false @Published var isPluggedIn: Bool = false - private var timer: Timer? + private var runLoopSource: CFRunLoopSource? init() { startMonitoring() @@ -18,17 +18,33 @@ class BatteryManager: ObservableObject { } private func startMonitoring() { - // Update every 1 second. - timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { - [weak self] _ in - self?.updateBatteryStatus() + let context = UnsafeMutableRawPointer( + Unmanaged.passUnretained(self).toOpaque()) + + runLoopSource = IOPSNotificationCreateRunLoopSource( + { context in + guard let context = context else { return } + let manager = Unmanaged.fromOpaque(context) + .takeUnretainedValue() + DispatchQueue.main.async { + manager.updateBatteryStatus() + } + }, + context + )?.takeRetainedValue() + + if let source = runLoopSource { + CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .defaultMode) } + updateBatteryStatus() } private func stopMonitoring() { - timer?.invalidate() - timer = nil + if let source = runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .defaultMode) + runLoopSource = nil + } } /// This method updates the battery level and charging state. diff --git a/Barik/Widgets/Network/NetworkViewModel.swift b/Barik/Widgets/Network/NetworkViewModel.swift index 3d8a2fd..10a09b7 100644 --- a/Barik/Widgets/Network/NetworkViewModel.swift +++ b/Barik/Widgets/Network/NetworkViewModel.swift @@ -43,7 +43,7 @@ struct WiFiNetwork: Identifiable, Hashable { /// Unified view model for monitoring network and Wi‑Fi status. final class NetworkStatusViewModel: NSObject, ObservableObject, - CLLocationManagerDelegate + CLLocationManagerDelegate, CWEventDelegate { // States for Wi‑Fi and Ethernet obtained via NWPathMonitor. @@ -81,6 +81,7 @@ final class NetworkStatusViewModel: NSObject, ObservableObject, private var timer: Timer? private let locationManager = CLLocationManager() + private var wifiClient: CWWiFiClient? override init() { super.init() @@ -153,16 +154,61 @@ final class NetworkStatusViewModel: NSObject, ObservableObject, // MARK: — Updating Wi‑Fi information via CoreWLAN. private func startWiFiMonitoring() { - timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { - [weak self] _ in - self?.updateWiFiInfo() + // Set up CWWiFiClient delegate for event-based SSID and link changes + wifiClient = CWWiFiClient.shared() + wifiClient?.delegate = self + do { + try wifiClient?.startMonitoringEvent(with: .ssidDidChange) + try wifiClient?.startMonitoringEvent(with: .linkDidChange) + } catch { + print("Failed to start WiFi event monitoring: \(error)") } + + // Initial update updateWiFiInfo() + + // Reduced polling (30 seconds) for signal strength updates only when connected + // RSSI has no event API, so we need polling for signal strength + startSignalStrengthPolling() } private func stopWiFiMonitoring() { timer?.invalidate() timer = nil + + // Stop monitoring WiFi events + do { + try wifiClient?.stopMonitoringEvent(with: .ssidDidChange) + try wifiClient?.stopMonitoringEvent(with: .linkDidChange) + } catch { + print("Failed to stop WiFi event monitoring: \(error)") + } + wifiClient?.delegate = nil + wifiClient = nil + } + + /// Start reduced polling for signal strength (RSSI) updates. + /// Only polls when WiFi is connected since RSSI has no event API. + private func startSignalStrengthPolling() { + timer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { + [weak self] _ in + guard let self = self else { return } + // Only poll for signal strength when WiFi is connected + if self.wifiState == .connected || self.wifiState == .connectedWithoutInternet { + self.updateSignalStrength() + } + } + } + + /// Update only signal strength (RSSI and noise) - used for polling + private func updateSignalStrength() { + let client = CWWiFiClient.shared() + if let interface = client.interface(), interface.ssid() != nil { + DispatchQueue.main.async { + self.rssi = interface.rssiValue() + self.noise = interface.noiseMeasurement() + } + } } private func updateWiFiInfo() { @@ -198,6 +244,23 @@ final class NetworkStatusViewModel: NSObject, ObservableObject, } } + // MARK: — CWEventDelegate + + /// Called when SSID changes (connecting to different network) + func ssidDidChangeForWiFiInterface(withName interfaceName: String) { + DispatchQueue.main.async { + self.updateWiFiInfo() + } + } + + /// Called when link state changes (connected/disconnected) + func linkDidChangeForWiFiInterface(withName interfaceName: String) { + DispatchQueue.main.async { + self.updateWiFiInfo() + self.checkWiFiPowerState() + } + } + // MARK: — CLLocationManagerDelegate. func locationManager( diff --git a/Barik/Widgets/Spaces/SpacesViewModel.swift b/Barik/Widgets/Spaces/SpacesViewModel.swift index e05d744..6efba17 100644 --- a/Barik/Widgets/Spaces/SpacesViewModel.swift +++ b/Barik/Widgets/Spaces/SpacesViewModel.swift @@ -4,10 +4,10 @@ import Foundation class SpacesViewModel: ObservableObject { @Published var spaces: [AnySpace] = [] - private var timer: Timer? private var provider: AnySpacesProvider? private var cancellables: Set = [] private var spacesById: [String: AnySpace] = [:] + private var workspaceObservers: [NSObjectProtocol] = [] init() { let runningApps = NSWorkspace.shared.runningApplications.compactMap { @@ -32,7 +32,7 @@ class SpacesViewModel: ObservableObject { if provider.isEventBased { startMonitoringEventBasedProvider() } else { - startMonitoringPollingBasedProvider() + startMonitoringWithWorkspaceNotifications() } } } @@ -42,22 +42,54 @@ class SpacesViewModel: ObservableObject { if provider.isEventBased { stopMonitoringEventBasedProvider() } else { - stopMonitoringPollingBasedProvider() + stopMonitoringWorkspaceNotifications() } } } - private func startMonitoringPollingBasedProvider() { - timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { - [weak self] _ in + private func startMonitoringWithWorkspaceNotifications() { + let notificationCenter = NSWorkspace.shared.notificationCenter + + // Observe space changes + let spaceObserver = notificationCenter.addObserver( + forName: NSWorkspace.activeSpaceDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in self?.loadSpaces() } + workspaceObservers.append(spaceObserver) + + // Observe application activation (may indicate space/window changes) + let activateObserver = notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.loadSpaces() + } + workspaceObservers.append(activateObserver) + + // Observe application deactivation + let deactivateObserver = notificationCenter.addObserver( + forName: NSWorkspace.didDeactivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.loadSpaces() + } + workspaceObservers.append(deactivateObserver) + + // Load initial state loadSpaces() } - private func stopMonitoringPollingBasedProvider() { - timer?.invalidate() - timer = nil + private func stopMonitoringWorkspaceNotifications() { + let notificationCenter = NSWorkspace.shared.notificationCenter + for observer in workspaceObservers { + notificationCenter.removeObserver(observer) + } + workspaceObservers.removeAll() } private func startMonitoringEventBasedProvider() { diff --git a/Barik/Widgets/Time+Calendar/CalendarManager.swift b/Barik/Widgets/Time+Calendar/CalendarManager.swift index e7ea454..28d44dc 100644 --- a/Barik/Widgets/Time+Calendar/CalendarManager.swift +++ b/Barik/Widgets/Time+Calendar/CalendarManager.swift @@ -22,7 +22,7 @@ class CalendarManager: ObservableObject { @Published var todaysEvents: [EKEvent] = [] @Published var tomorrowsEvents: [EKEvent] = [] private let eventStore = EKEventStore() - private var timer: Timer? + private var debounceTimer: Timer? init(configProvider: ConfigProvider) { self.configProvider = configProvider @@ -35,20 +35,35 @@ class CalendarManager: ObservableObject { } private func startMonitoring() { - timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { - [weak self] _ in - self?.fetchTodaysEvents() - self?.fetchTomorrowsEvents() - self?.fetchNextEvent() - } + NotificationCenter.default.addObserver( + self, + selector: #selector(handleCalendarStoreChanged), + name: .EKEventStoreChanged, + object: eventStore + ) fetchTodaysEvents() fetchTomorrowsEvents() fetchNextEvent() } private func stopMonitoring() { - timer?.invalidate() - timer = nil + debounceTimer?.invalidate() + debounceTimer = nil + NotificationCenter.default.removeObserver( + self, + name: .EKEventStoreChanged, + object: eventStore + ) + } + + @objc private func handleCalendarStoreChanged() { + debounceTimer?.invalidate() + debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { + [weak self] _ in + self?.fetchTodaysEvents() + self?.fetchTomorrowsEvents() + self?.fetchNextEvent() + } } private func requestAccess() { From 54aae173e0bd8209a862e699d5cfc6dcc617a242 Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Sun, 18 Jan 2026 21:46:42 -0800 Subject: [PATCH 15/30] fix: rewrite popup positioning for consistent Y level across all widgets - Changed from center-based positioning (.position()) to top-aligned positioning (.topLeading alignment with offset) - All popups now appear at exactly the same Y level regardless of content height - Popup top edge aligns precisely with the bottom of the Barik menu bar using foregroundHeight directly - X positioning centers popup under widget with edge constraints Co-Authored-By: Claude Opus 4.5 --- Barik/MenuBarPopup/MenuBarPopup.swift | 30 +++++++++-------------- Barik/MenuBarPopup/MenuBarPopupView.swift | 29 +++++++++++++--------- 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/Barik/MenuBarPopup/MenuBarPopup.swift b/Barik/MenuBarPopup/MenuBarPopup.swift index 0643611..6a8d7c3 100644 --- a/Barik/MenuBarPopup/MenuBarPopup.swift +++ b/Barik/MenuBarPopup/MenuBarPopup.swift @@ -72,13 +72,10 @@ class MenuBarPopup { DispatchQueue.main.asyncAfter(deadline: .now() + duration) { panel.contentView = NSHostingView( rootView: - ZStack { - MenuBarPopupView(widgetRect: rect) { - content() - } - .position(x: rect.midX) + MenuBarPopupView(widgetRect: rect) { + content() } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .id(UUID()) ) panel.makeKeyAndOrderFront(nil) @@ -90,13 +87,10 @@ class MenuBarPopup { } else { panel.contentView = NSHostingView( rootView: - ZStack { - MenuBarPopupView(widgetRect: rect) { - content() - } - .position(x: rect.midX) + MenuBarPopupView(widgetRect: rect) { + content() } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) ) panel.makeKeyAndOrderFront(nil) DispatchQueue.main.async { @@ -107,13 +101,11 @@ class MenuBarPopup { } static func setup() { - guard let screen = NSScreen.main?.visibleFrame else { return } - let panelFrame = NSRect( - x: 0, - y: 0, - width: screen.size.width, - height: screen.size.height - ) + guard let screen = NSScreen.main else { return } + + // Use full screen frame so the panel covers the entire screen including menu bar area + // This ensures consistent positioning regardless of dock position or menu bar configuration + let panelFrame = screen.frame let newPanel = HidingPanel( contentRect: panelFrame, diff --git a/Barik/MenuBarPopup/MenuBarPopupView.swift b/Barik/MenuBarPopup/MenuBarPopupView.swift index 54b3810..81c8f76 100644 --- a/Barik/MenuBarPopup/MenuBarPopupView.swift +++ b/Barik/MenuBarPopup/MenuBarPopupView.swift @@ -31,9 +31,10 @@ struct MenuBarPopupView: View { } } - // Position popup just below the menu bar + // Position popup directly below the Barik menu bar + // foregroundHeight is the exact height of the Barik bar, which overlays the system menu bar var popupTopPosition: CGFloat { - return foregroundHeight + 52 + return foregroundHeight } var body: some View { @@ -141,19 +142,25 @@ struct MenuBarPopupView: View { .preferredColorScheme(.dark) } + // Calculate X offset to center popup under widget, with edge constraints var computedOffset: CGFloat { let screenWidth = NSScreen.main?.frame.width ?? 0 - let W = viewFrame.width - let M = viewFrame.midX - let newLeft = (M - W / 2) - 20 - let newRight = (M + W / 2) + 20 + let contentWidth = viewFrame.width > 0 ? viewFrame.width : 200 // Fallback width - if newRight > screenWidth { - return screenWidth - newRight - } else if newLeft < 0 { - return -newLeft + // Start by centering under the widget + var xOffset = widgetRect.midX - contentWidth / 2 + + // Constrain to screen edges with 20pt margin + let rightEdge = xOffset + contentWidth + 20 + let leftEdge = xOffset - 20 + + if rightEdge > screenWidth { + xOffset -= (rightEdge - screenWidth) + } else if leftEdge < 0 { + xOffset -= leftEdge } - return 0 + + return xOffset } var computedYOffset: CGFloat { From 1904b49ca4b8b3ca094c450ad47ff913cf3af261 Mon Sep 17 00:00:00 2001 From: bettercoderthanyou Date: Tue, 20 Jan 2026 10:54:11 -0800 Subject: [PATCH 16/30] feat: add calendar day view popup with today/tomorrow layout (#2) - Add dayView variant to MenuBarPopupVariantView for calendar-style layouts - Implement CalendarDayViewPopup showing today's schedule on the left and tomorrow's events on the right (mimicking macOS sidebar calendar) - Add expandable all-day events section with click-to-expand - Fix tap gesture detection on TimeWidget and NowPlayingWidget by using .contentShape(Rectangle()) instead of transparent background hack Co-authored-by: Claude Opus 4.5 --- .../MenuBarPopupVariantView.swift | 14 +- .../Widgets/NowPlaying/NowPlayingWidget.swift | 1 + .../Widgets/Time+Calendar/CalendarPopup.swift | 365 +++++++++++++++++- Barik/Widgets/Time+Calendar/TimeWidget.swift | 2 +- 4 files changed, 379 insertions(+), 3 deletions(-) diff --git a/Barik/MenuBarPopup/MenuBarPopupVariantView.swift b/Barik/MenuBarPopup/MenuBarPopupVariantView.swift index 88e0c15..fa22316 100644 --- a/Barik/MenuBarPopup/MenuBarPopupVariantView.swift +++ b/Barik/MenuBarPopup/MenuBarPopupVariantView.swift @@ -1,13 +1,14 @@ import SwiftUI enum MenuBarPopupVariant: String, Equatable { - case box, vertical, horizontal, settings + case box, vertical, horizontal, dayView, settings } struct MenuBarPopupVariantView: View { private let box: AnyView? private let vertical: AnyView? private let horizontal: AnyView? + private let dayView: AnyView? private let settings: AnyView? var selectedVariant: MenuBarPopupVariant @@ -22,6 +23,7 @@ struct MenuBarPopupVariantView: View { @ViewBuilder box: () -> some View = { EmptyView() }, @ViewBuilder vertical: () -> some View = { EmptyView() }, @ViewBuilder horizontal: () -> some View = { EmptyView() }, + @ViewBuilder dayView: () -> some View = { EmptyView() }, @ViewBuilder settings: () -> some View = { EmptyView() } ) { self.selectedVariant = selectedVariant @@ -30,6 +32,7 @@ struct MenuBarPopupVariantView: View { let boxView = box() let verticalView = vertical() let horizontalView = horizontal() + let dayViewView = dayView() let settingsView = settings() self.box = (boxView is EmptyView) ? nil : AnyView(boxView) @@ -37,6 +40,8 @@ struct MenuBarPopupVariantView: View { (verticalView is EmptyView) ? nil : AnyView(verticalView) self.horizontal = (horizontalView is EmptyView) ? nil : AnyView(horizontalView) + self.dayView = + (dayViewView is EmptyView) ? nil : AnyView(dayViewView) self.settings = (settingsView is EmptyView) ? nil : AnyView(settingsView) } @@ -63,6 +68,11 @@ struct MenuBarPopupVariantView: View { variant: .horizontal, systemImageName: "rectangle.inset.filled") } + if dayView != nil { + variantButton( + variant: .dayView, + systemImageName: "calendar.day.timeline.left") + } if settings != nil { variantButton( variant: .settings, systemImageName: "gearshape.fill") @@ -89,6 +99,8 @@ struct MenuBarPopupVariantView: View { if let view = vertical { view } case .horizontal: if let view = horizontal { view } + case .dayView: + if let view = dayView { view } case .settings: if let view = settings { view } } diff --git a/Barik/Widgets/NowPlaying/NowPlayingWidget.swift b/Barik/Widgets/NowPlaying/NowPlayingWidget.swift index 81040d4..4fc2299 100644 --- a/Barik/Widgets/NowPlaying/NowPlayingWidget.swift +++ b/Barik/Widgets/NowPlaying/NowPlayingWidget.swift @@ -26,6 +26,7 @@ struct NowPlayingWidget: View { // Visible content with fixed animated width. VisibleNowPlayingContent(song: song, width: animatedWidth) + .contentShape(Rectangle()) .onTapGesture { MenuBarPopup.show(rect: widgetFrame, id: "nowplaying") { NowPlayingPopup(configProvider: configProvider) diff --git a/Barik/Widgets/Time+Calendar/CalendarPopup.swift b/Barik/Widgets/Time+Calendar/CalendarPopup.swift index 7918dbb..5983093 100644 --- a/Barik/Widgets/Time+Calendar/CalendarPopup.swift +++ b/Barik/Widgets/Time+Calendar/CalendarPopup.swift @@ -19,7 +19,8 @@ struct CalendarPopup: View { }, box: { CalendarBoxPopup() }, vertical: { CalendarVerticalPopup(calendarManager) }, - horizontal: { CalendarHorizontalPopup(calendarManager) } + horizontal: { CalendarHorizontalPopup(calendarManager) }, + dayView: { CalendarDayViewPopup(calendarManager) } ) .onAppear { if let variantString = configProvider.config["popup"]? @@ -357,6 +358,364 @@ private struct EventRow: View { } } +// MARK: - Day View Popup (Mac Sidebar Style) + +struct CalendarDayViewPopup: View { + let calendarManager: CalendarManager + + init(_ calendarManager: CalendarManager) { + self.calendarManager = calendarManager + } + + private let hourHeight: CGFloat = 44 + private let startHour: Int = 8 + private let endHour: Int = 18 + + var body: some View { + HStack(alignment: .top, spacing: 0) { + // Left side: Today + TodayColumnView(startHour: startHour, endHour: endHour, hourHeight: hourHeight, events: calendarManager.todaysEvents) + + // Divider + Rectangle() + .fill(Color.white.opacity(0.1)) + .frame(width: 1) + + // Right side: Tomorrow + TomorrowColumnView(startHour: startHour, endHour: endHour, hourHeight: hourHeight, events: calendarManager.tomorrowsEvents) + } + .padding(20) + .fontWeight(.semibold) + .foregroundStyle(.white) + } +} + +private struct TodayColumnView: View { + let startHour: Int + let endHour: Int + let hourHeight: CGFloat + let events: [EKEvent] + + @State private var currentTime = Date() + private let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() + + private var dayOfWeek: String { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE" + return formatter.string(from: Date()).uppercased() + } + + private var dayNumber: String { + let formatter = DateFormatter() + formatter.dateFormat = "d" + return formatter.string(from: Date()) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header: Day of week + date + VStack(alignment: .leading, spacing: 2) { + Text(dayOfWeek) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Color(red: 1.0, green: 0.4, blue: 0.4)) + Text(dayNumber) + .font(.system(size: 34, weight: .light)) + } + .padding(.bottom, 15) + + // Time slots with current time indicator + ZStack(alignment: .topLeading) { + // Hour labels and lines + VStack(alignment: .leading, spacing: 0) { + ForEach(startHour..= startHour && hour < endHour { + HStack(spacing: 0) { + Circle() + .fill(Color.red) + .frame(width: 8, height: 8) + Rectangle() + .fill(Color.red) + .frame(height: 1) + } + .offset(x: 28, y: yPosition - 4) + } + } + } + + private func formatHour(_ hour: Int) -> String { + let h = hour > 12 ? hour - 12 : hour + return "\(h)" + } +} + +private struct TomorrowColumnView: View { + let startHour: Int + let endHour: Int + let hourHeight: CGFloat + let events: [EKEvent] + + @State private var showAllDayEvents = false + + private var allDayEvents: [EKEvent] { + events.filter { $0.isAllDay } + } + + private var timedEvents: [EKEvent] { + events.filter { !$0.isAllDay } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header: TOMORROW + VStack(alignment: .leading, spacing: 6) { + Text("TOMORROW") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.gray) + + // All-day events badge (clickable) + if !allDayEvents.isEmpty { + allDayEventsBadge + .onTapGesture { + withAnimation(.smooth(duration: 0.2)) { + showAllDayEvents.toggle() + } + } + + // Expanded all-day events list + if showAllDayEvents { + allDayEventsList + } + } + } + .padding(.bottom, 15) + + // Time slots with events + ZStack(alignment: .topLeading) { + // Hour labels + VStack(alignment: .leading, spacing: 0) { + ForEach(startHour..= startHour && hour < endHour + } + + // Group overlapping events + let groupedEvents = groupOverlappingEvents(visibleEvents) + + return ZStack(alignment: .topLeading) { + ForEach(groupedEvents, id: \.0.eventIdentifier) { event, column, totalColumns in + eventBlock(event: event, column: column, totalColumns: totalColumns) + } + + // "X more events" indicator if there are events outside visible range + let hiddenCount = timedEvents.count - visibleEvents.count + if hiddenCount > 0 { + Text("\(hiddenCount) more event\(hiddenCount == 1 ? "" : "s")") + .font(.system(size: 11)) + .foregroundColor(.gray) + .offset(x: 36, y: CGFloat(endHour - startHour) * hourHeight - 20) + } + } + } + + private func eventBlock(event: EKEvent, column: Int, totalColumns: Int) -> some View { + let calendar = Calendar.current + let hour = calendar.component(.hour, from: event.startDate) + let minute = calendar.component(.minute, from: event.startDate) + + let hourOffset = hour - startHour + let minuteOffset = CGFloat(minute) / 60.0 + let yPosition = CGFloat(hourOffset) * hourHeight + minuteOffset * hourHeight + + // Calculate duration for height + let duration = event.endDate.timeIntervalSince(event.startDate) / 3600.0 + let height = max(CGFloat(duration) * hourHeight - 2, 20) + + // Calculate width based on overlapping events + let availableWidth: CGFloat = 160 + let eventWidth = (availableWidth / CGFloat(totalColumns)) - 2 + let xOffset: CGFloat = 36 + CGFloat(column) * (eventWidth + 2) + + return HStack(spacing: 0) { + Rectangle() + .fill(Color(event.calendar.cgColor)) + .frame(width: 3) + + Text(event.title ?? "") + .font(.system(size: 11)) + .lineLimit(height > 30 ? 2 : 1) + .padding(.horizontal, 6) + .padding(.vertical, 2) + } + .frame(width: eventWidth, height: height, alignment: .leading) + .background(Color(event.calendar.cgColor).opacity(0.25)) + .cornerRadius(4) + .offset(x: xOffset, y: yPosition) + } + + private func groupOverlappingEvents(_ events: [EKEvent]) -> [(EKEvent, Int, Int)] { + guard !events.isEmpty else { return [] } + + var result: [(EKEvent, Int, Int)] = [] + var groups: [[EKEvent]] = [] + + let sortedEvents = events.sorted { $0.startDate < $1.startDate } + + for event in sortedEvents { + var placed = false + for i in groups.indices { + let groupEnd = groups[i].map { $0.endDate }.max() ?? Date.distantPast + if event.startDate >= groupEnd { + groups[i].append(event) + placed = true + break + } + } + if !placed { + groups.append([event]) + } + } + + // Flatten with column info + for event in sortedEvents { + var column = 0 + var overlappingCount = 1 + + for (idx, group) in groups.enumerated() { + if group.contains(where: { $0.eventIdentifier == event.eventIdentifier }) { + column = idx + // Count how many groups overlap with this event + overlappingCount = groups.filter { group in + group.contains { otherEvent in + !(event.endDate <= otherEvent.startDate || event.startDate >= otherEvent.endDate) + } + }.count + break + } + } + + result.append((event, column, max(overlappingCount, groups.count))) + } + + return result + } + + private func formatHour(_ hour: Int) -> String { + let h = hour > 12 ? hour - 12 : hour + return "\(h)" + } +} + struct CalendarPopup_Previews: PreviewProvider { var configProvider: ConfigProvider = ConfigProvider(config: ConfigData()) var calendarManager: CalendarManager @@ -381,5 +740,9 @@ struct CalendarPopup_Previews: PreviewProvider { .background(Color.black) .previewLayout(.sizeThatFits) .previewDisplayName("Horizontal") + CalendarDayViewPopup(calendarManager) + .background(Color.black) + .previewLayout(.sizeThatFits) + .previewDisplayName("Day View") } } diff --git a/Barik/Widgets/Time+Calendar/TimeWidget.swift b/Barik/Widgets/Time+Calendar/TimeWidget.swift index b111d05..5c9f961 100644 --- a/Barik/Widgets/Time+Calendar/TimeWidget.swift +++ b/Barik/Widgets/Time+Calendar/TimeWidget.swift @@ -55,7 +55,7 @@ struct TimeWidget: View { ) .experimentalConfiguration(cornerRadius: 15) .frame(maxHeight: .infinity) - .background(.black.opacity(0.001)) + .contentShape(Rectangle()) .monospacedDigit() .onTapGesture { switch clickAction { From 23c28e86edd19b3a6f372cd4ca1a5e8c13949065 Mon Sep 17 00:00:00 2001 From: bettercoderthanyou Date: Wed, 21 Jan 2026 13:10:51 -0800 Subject: [PATCH 17/30] feat: enhanced calendar popup with day view, selection, and event details (#3) * fix: improve calendar day view popup sizing and add today's all-day events - Add all-day events display to today column (was only showing tomorrow's) - Fix popup height to fit content using fixedSize modifier - Reduce hour range to 9am-5pm and height to 24pt for compact display - Set fixed column widths (180pt today, 220pt tomorrow) to prevent resize on expand Co-Authored-By: Claude Opus 4.5 * feat: add calendar selection UI and event detail view - Add CalendarSettingsView with toggle for each calendar - Use calendarIdentifier instead of title to handle duplicate calendar names - Add ExpandedEventView that shows full event details when clicking an event - CalendarManager now subscribes to config changes for automatic refresh - Add updateConfigValueRaw to ConfigManager for TOML array values Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- Barik/Config/ConfigManager.swift | 36 +- Barik/Views/MenuBarView.swift | 2 +- .../Time+Calendar/CalendarManager.swift | 41 +- .../Widgets/Time+Calendar/CalendarPopup.swift | 796 ++++++++++++++++-- Barik/Widgets/Time+Calendar/TimeWidget.swift | 9 +- 5 files changed, 781 insertions(+), 103 deletions(-) diff --git a/Barik/Config/ConfigManager.swift b/Barik/Config/ConfigManager.swift index 70af4d6..8fbffaa 100644 --- a/Barik/Config/ConfigManager.swift +++ b/Barik/Config/ConfigManager.swift @@ -134,7 +134,26 @@ final class ConfigManager: ObservableObject { do { let currentText = try String(contentsOfFile: path, encoding: .utf8) let updatedText = updatedTOMLString( - original: currentText, key: key, newValue: newValue) + original: currentText, key: key, newValue: newValue, quoteValue: true) + try updatedText.write( + toFile: path, atomically: false, encoding: .utf8) + DispatchQueue.main.async { + self.parseConfigFile(at: path) + } + } catch { + print("Error updating config:", error) + } + } + + func updateConfigValueRaw(key: String, newValue: String) { + guard let path = configFilePath else { + print("Config file path is not set") + return + } + do { + let currentText = try String(contentsOfFile: path, encoding: .utf8) + let updatedText = updatedTOMLString( + original: currentText, key: key, newValue: newValue, quoteValue: false) try updatedText.write( toFile: path, atomically: false, encoding: .utf8) DispatchQueue.main.async { @@ -146,8 +165,9 @@ final class ConfigManager: ObservableObject { } private func updatedTOMLString( - original: String, key: String, newValue: String + original: String, key: String, newValue: String, quoteValue: Bool = true ) -> String { + let formattedValue = quoteValue ? "\"\(newValue)\"" : newValue if key.contains(".") { let components = key.split(separator: ".").map(String.init) guard components.count >= 2 else { @@ -168,7 +188,7 @@ final class ConfigManager: ObservableObject { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { if insideTargetTable && !updatedKey { - newLines.append("\(actualKey) = \"\(newValue)\"") + newLines.append("\(actualKey) = \(formattedValue)") updatedKey = true } if trimmed == tableHeader { @@ -185,7 +205,7 @@ final class ConfigManager: ObservableObject { if line.range(of: pattern, options: .regularExpression) != nil { - newLines.append("\(actualKey) = \"\(newValue)\"") + newLines.append("\(actualKey) = \(formattedValue)") updatedKey = true continue } @@ -195,13 +215,13 @@ final class ConfigManager: ObservableObject { } if foundTable && insideTargetTable && !updatedKey { - newLines.append("\(actualKey) = \"\(newValue)\"") + newLines.append("\(actualKey) = \(formattedValue)") } if !foundTable { newLines.append("") newLines.append("[\(tablePath)]") - newLines.append("\(actualKey) = \"\(newValue)\"") + newLines.append("\(actualKey) = \(formattedValue)") } return newLines.joined(separator: "\n") } else { @@ -217,7 +237,7 @@ final class ConfigManager: ObservableObject { if line.range(of: pattern, options: .regularExpression) != nil { - newLines.append("\(key) = \"\(newValue)\"") + newLines.append("\(key) = \(formattedValue)") updatedAtLeastOnce = true continue } @@ -225,7 +245,7 @@ final class ConfigManager: ObservableObject { newLines.append(line) } if !updatedAtLeastOnce { - newLines.append("\(key) = \"\(newValue)\"") + newLines.append("\(key) = \(formattedValue)") } return newLines.joined(separator: "\n") } diff --git a/Barik/Views/MenuBarView.swift b/Barik/Views/MenuBarView.swift index 7dbc449..7c48370 100644 --- a/Barik/Views/MenuBarView.swift +++ b/Barik/Views/MenuBarView.swift @@ -52,7 +52,7 @@ struct MenuBarView: View { BatteryWidget().environmentObject(config) case "default.time": - TimeWidget(calendarManager: CalendarManager(configProvider: config)) + TimeWidget(configProvider: config) .environmentObject(config) case "default.nowplaying": diff --git a/Barik/Widgets/Time+Calendar/CalendarManager.swift b/Barik/Widgets/Time+Calendar/CalendarManager.swift index 28d44dc..0d6b85b 100644 --- a/Barik/Widgets/Time+Calendar/CalendarManager.swift +++ b/Barik/Widgets/Time+Calendar/CalendarManager.swift @@ -4,34 +4,50 @@ import Foundation class CalendarManager: ObservableObject { let configProvider: ConfigProvider - var config: ConfigData? { - configProvider.config["calendar"]?.dictionaryValue + + // Read config directly from ConfigManager.shared to get latest values + private var calendarConfig: ConfigData? { + let widgetConfig = ConfigManager.shared.globalWidgetConfig(for: "default.time") + return widgetConfig["calendar"]?.dictionaryValue } var allowList: [String] { Array( - (config?["allow-list"]?.arrayValue?.map { $0.stringValue ?? "" } + (calendarConfig?["allow-list"]?.arrayValue?.map { $0.stringValue ?? "" } .drop(while: { $0 == "" })) ?? []) } var denyList: [String] { Array( - (config?["deny-list"]?.arrayValue?.map { $0.stringValue ?? "" } + (calendarConfig?["deny-list"]?.arrayValue?.map { $0.stringValue ?? "" } .drop(while: { $0 == "" })) ?? []) } @Published var nextEvent: EKEvent? @Published var todaysEvents: [EKEvent] = [] @Published var tomorrowsEvents: [EKEvent] = [] - private let eventStore = EKEventStore() + @Published var allCalendars: [EKCalendar] = [] + let eventStore = EKEventStore() private var debounceTimer: Timer? + private var configCancellable: AnyCancellable? init(configProvider: ConfigProvider) { self.configProvider = configProvider requestAccess() startMonitoring() + + // Subscribe to ConfigManager.shared config changes to re-fetch events when deny-list changes + configCancellable = ConfigManager.shared.$config + .dropFirst() // Skip initial value + .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + self?.fetchTodaysEvents() + self?.fetchTomorrowsEvents() + self?.fetchNextEvent() + } } deinit { stopMonitoring() + configCancellable?.cancel() } private func startMonitoring() { @@ -41,11 +57,20 @@ class CalendarManager: ObservableObject { name: .EKEventStoreChanged, object: eventStore ) + fetchAllCalendars() fetchTodaysEvents() fetchTomorrowsEvents() fetchNextEvent() } + func fetchAllCalendars() { + let calendars = eventStore.calendars(for: .event) + .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + DispatchQueue.main.async { + self.allCalendars = calendars + } + } + private func stopMonitoring() { debounceTimer?.invalidate() debounceTimer = nil @@ -60,6 +85,7 @@ class CalendarManager: ObservableObject { debounceTimer?.invalidate() debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in + self?.fetchAllCalendars() self?.fetchTodaysEvents() self?.fetchTomorrowsEvents() self?.fetchNextEvent() @@ -69,6 +95,7 @@ class CalendarManager: ObservableObject { private func requestAccess() { eventStore.requestFullAccessToEvents { [weak self] granted, error in if granted && error == nil { + self?.fetchAllCalendars() self?.fetchTodaysEvents() self?.fetchTomorrowsEvents() self?.fetchNextEvent() @@ -82,10 +109,10 @@ class CalendarManager: ObservableObject { private func filterEvents(_ events: [EKEvent]) -> [EKEvent] { var filtered = events if !allowList.isEmpty { - filtered = filtered.filter { allowList.contains($0.calendar.title) } + filtered = filtered.filter { allowList.contains($0.calendar.calendarIdentifier) } } if !denyList.isEmpty { - filtered = filtered.filter { !denyList.contains($0.calendar.title) } + filtered = filtered.filter { !denyList.contains($0.calendar.calendarIdentifier) } } return filtered } diff --git a/Barik/Widgets/Time+Calendar/CalendarPopup.swift b/Barik/Widgets/Time+Calendar/CalendarPopup.swift index 5983093..b06fd18 100644 --- a/Barik/Widgets/Time+Calendar/CalendarPopup.swift +++ b/Barik/Widgets/Time+Calendar/CalendarPopup.swift @@ -1,3 +1,4 @@ +import AppKit import EventKit import SwiftUI @@ -11,16 +12,27 @@ struct CalendarPopup: View { MenuBarPopupVariantView( selectedVariant: selectedVariant, onVariantSelected: { variant in - selectedVariant = variant + // If clicking settings while already in settings, go back to dayView + let newVariant = (variant == .settings && selectedVariant == .settings) ? .dayView : variant + selectedVariant = newVariant ConfigManager.shared.updateConfigValue( key: "widgets.default.time.popup.view-variant", - newValue: variant.rawValue + newValue: newVariant.rawValue ) }, box: { CalendarBoxPopup() }, vertical: { CalendarVerticalPopup(calendarManager) }, horizontal: { CalendarHorizontalPopup(calendarManager) }, - dayView: { CalendarDayViewPopup(calendarManager) } + dayView: { CalendarDayViewPopup(calendarManager) }, + settings: { + CalendarSettingsView(calendarManager, onBack: { + selectedVariant = .dayView + ConfigManager.shared.updateConfigValue( + key: "widgets.default.time.popup.view-variant", + newValue: "dayView" + ) + }) + } ) .onAppear { if let variantString = configProvider.config["popup"]? @@ -316,6 +328,7 @@ private struct EventListView: View { private struct EventRow: View { let event: EKEvent + @State private var showDetail = false var body: some View { let eventTime = getEventTime(event) @@ -341,6 +354,15 @@ private struct EventRow: View { .background(Color(event.calendar.cgColor).opacity(0.2)) .cornerRadius(6) .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .onTapGesture { + showDetail.toggle() + } + .popover(isPresented: $showDetail, arrowEdge: .leading) { + EventDetailView(event: event) { + showDetail = false + } + } } func getEventTime(_ event: EKEvent) -> String { @@ -358,35 +380,326 @@ private struct EventRow: View { } } +// MARK: - Event Detail View + +private struct EventDetailView: View { + let event: EKEvent + let onDismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header with close button + HStack { + Circle() + .fill(Color(event.calendar.cgColor)) + .frame(width: 10, height: 10) + Text(event.calendar.title) + .font(.system(size: 11)) + .foregroundColor(.gray) + Spacer() + Button { + onDismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundColor(.gray) + } + .buttonStyle(.plain) + } + + // Event title + Text(event.title ?? "Untitled Event") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + .fixedSize(horizontal: false, vertical: true) + + // Time + HStack(spacing: 6) { + Image(systemName: "clock") + .font(.system(size: 12)) + .foregroundColor(.gray) + Text(formatEventTime()) + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.8)) + } + + // Location (if available) + if let location = event.location, !location.isEmpty { + HStack(spacing: 6) { + Image(systemName: "location") + .font(.system(size: 12)) + .foregroundColor(.gray) + Text(location) + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.8)) + .lineLimit(2) + } + } + + // Notes (if available) + if let notes = event.notes, !notes.isEmpty { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "note.text") + .font(.system(size: 12)) + .foregroundColor(.gray) + Text(notes) + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.7)) + .lineLimit(3) + } + } + + // Open in Calendar button + Button { + openInCalendar() + } label: { + HStack { + Image(systemName: "calendar") + .font(.system(size: 12)) + Text("Open in Calendar") + .font(.system(size: 12, weight: .medium)) + } + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(event.calendar.cgColor).opacity(0.5)) + .cornerRadius(6) + } + .buttonStyle(.plain) + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.9)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color(event.calendar.cgColor).opacity(0.4), lineWidth: 1) + ) + ) + .frame(width: 220) + } + + private func formatEventTime() -> String { + if event.isAllDay { + return NSLocalizedString("ALL_DAY", comment: "") + } + let formatter = DateFormatter() + formatter.setLocalizedDateFormatFromTemplate("j:mm") + let start = formatter.string(from: event.startDate) + let end = formatter.string(from: event.endDate) + return "\(start) — \(end)" + } + + private func openInCalendar() { + if let url = URL(string: "x-apple-calendar://") { + NSWorkspace.shared.open(url) + } + } +} + // MARK: - Day View Popup (Mac Sidebar Style) struct CalendarDayViewPopup: View { let calendarManager: CalendarManager + @State private var selectedEvent: EKEvent? init(_ calendarManager: CalendarManager) { self.calendarManager = calendarManager } - private let hourHeight: CGFloat = 44 - private let startHour: Int = 8 - private let endHour: Int = 18 + private let hourHeight: CGFloat = 24 + private let startHour: Int = 9 + private let endHour: Int = 24 // Extend to midnight + private let visibleHours: Int = 8 // Show 8 hours at a time (same as original 9-17) - var body: some View { - HStack(alignment: .top, spacing: 0) { - // Left side: Today - TodayColumnView(startHour: startHour, endHour: endHour, hourHeight: hourHeight, events: calendarManager.todaysEvents) + private var scrollableHeight: CGFloat { + CGFloat(visibleHours) * hourHeight + } - // Divider - Rectangle() - .fill(Color.white.opacity(0.1)) - .frame(width: 1) + var body: some View { + Group { + if let event = selectedEvent { + // Show expanded event detail view + ExpandedEventView(event: event) { + withAnimation(.smooth(duration: 0.2)) { + selectedEvent = nil + } + } + } else { + // Show normal day view + HStack(alignment: .top, spacing: 0) { + // Left side: Today + TodayColumnView( + startHour: startHour, + endHour: endHour, + hourHeight: hourHeight, + scrollableHeight: scrollableHeight, + events: calendarManager.todaysEvents, + onEventSelected: { event in + withAnimation(.smooth(duration: 0.2)) { + selectedEvent = event + } + } + ) - // Right side: Tomorrow - TomorrowColumnView(startHour: startHour, endHour: endHour, hourHeight: hourHeight, events: calendarManager.tomorrowsEvents) + // Divider + Rectangle() + .fill(Color.white.opacity(0.1)) + .frame(width: 1) + + // Right side: Tomorrow + TomorrowColumnView( + startHour: startHour, + endHour: endHour, + hourHeight: hourHeight, + scrollableHeight: scrollableHeight, + events: calendarManager.tomorrowsEvents, + onEventSelected: { event in + withAnimation(.smooth(duration: 0.2)) { + selectedEvent = event + } + } + ) + } + } } .padding(20) .fontWeight(.semibold) .foregroundStyle(.white) + .fixedSize(horizontal: false, vertical: true) + } +} + +// MARK: - Expanded Event View (fills popup space) + +private struct ExpandedEventView: View { + let event: EKEvent + let onBack: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Header with back button + HStack { + Button { + onBack() + } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 14, weight: .semibold)) + Text("Back") + .font(.system(size: 13)) + } + .foregroundColor(.gray) + } + .buttonStyle(.plain) + + Spacer() + + // Calendar indicator + HStack(spacing: 6) { + Circle() + .fill(Color(event.calendar.cgColor)) + .frame(width: 10, height: 10) + Text(event.calendar.title) + .font(.system(size: 12)) + .foregroundColor(.gray) + } + } + + // Event title + Text(event.title ?? "Untitled Event") + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(.white) + .fixedSize(horizontal: false, vertical: true) + + // Time + HStack(spacing: 8) { + Image(systemName: "clock") + .font(.system(size: 14)) + .foregroundColor(Color(event.calendar.cgColor)) + VStack(alignment: .leading, spacing: 2) { + Text(formatEventDate()) + .font(.system(size: 13)) + .foregroundColor(.white.opacity(0.9)) + Text(formatEventTime()) + .font(.system(size: 13)) + .foregroundColor(.white.opacity(0.7)) + } + } + + // Location (if available) + if let location = event.location, !location.isEmpty { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "location") + .font(.system(size: 14)) + .foregroundColor(Color(event.calendar.cgColor)) + Text(location) + .font(.system(size: 13)) + .foregroundColor(.white.opacity(0.9)) + .fixedSize(horizontal: false, vertical: true) + } + } + + // Notes (if available) + if let notes = event.notes, !notes.isEmpty { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "note.text") + .font(.system(size: 14)) + .foregroundColor(Color(event.calendar.cgColor)) + ScrollView { + Text(notes) + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.8)) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxHeight: 100) + } + } + + Spacer() + + // Open in Calendar button + Button { + openInCalendar() + } label: { + HStack { + Image(systemName: "calendar") + .font(.system(size: 13)) + Text("Open in Calendar") + .font(.system(size: 13, weight: .medium)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(Color(event.calendar.cgColor).opacity(0.5)) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + .frame(width: 380) // Match the width of the day view (180 + 1 + 220 - some padding) + } + + private func formatEventDate() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, MMMM d, yyyy" + return formatter.string(from: event.startDate) + } + + private func formatEventTime() -> String { + if event.isAllDay { + return NSLocalizedString("ALL_DAY", comment: "") + } + let formatter = DateFormatter() + formatter.setLocalizedDateFormatFromTemplate("j:mm") + let start = formatter.string(from: event.startDate) + let end = formatter.string(from: event.endDate) + return "\(start) — \(end)" + } + + private func openInCalendar() { + if let url = URL(string: "x-apple-calendar://") { + NSWorkspace.shared.open(url) + } } } @@ -394,11 +707,22 @@ private struct TodayColumnView: View { let startHour: Int let endHour: Int let hourHeight: CGFloat + let scrollableHeight: CGFloat let events: [EKEvent] + let onEventSelected: (EKEvent) -> Void @State private var currentTime = Date() + @State private var showAllDayEvents = false private let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() + private var allDayEvents: [EKEvent] { + events.filter { $0.isAllDay } + } + + private var timedEvents: [EKEvent] { + events.filter { !$0.isAllDay } + } + private var dayOfWeek: String { let formatter = DateFormatter() formatter.dateFormat = "EEEE" @@ -414,40 +738,63 @@ private struct TodayColumnView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { // Header: Day of week + date - VStack(alignment: .leading, spacing: 2) { - Text(dayOfWeek) - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(Color(red: 1.0, green: 0.4, blue: 0.4)) - Text(dayNumber) - .font(.system(size: 34, weight: .light)) + VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 2) { + Text(dayOfWeek) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Color(red: 1.0, green: 0.4, blue: 0.4)) + Text(dayNumber) + .font(.system(size: 34, weight: .light)) + } + + // All-day events badge (clickable) + if !allDayEvents.isEmpty { + allDayEventsBadge + .onTapGesture { + withAnimation(.smooth(duration: 0.2)) { + showAllDayEvents.toggle() + } + } + + // Expanded all-day events list + if showAllDayEvents { + allDayEventsList + } + } } .padding(.bottom, 15) - // Time slots with current time indicator - ZStack(alignment: .topLeading) { - // Hour labels and lines - VStack(alignment: .leading, spacing: 0) { - ForEach(startHour..= startHour && hour < endHour + } + + // Group overlapping events + let groupedEvents = groupOverlappingEvents(visibleEvents) + + return ZStack(alignment: .topLeading) { + ForEach(groupedEvents, id: \.0.eventIdentifier) { event, column, totalColumns in + eventBlock(event: event, column: column, totalColumns: totalColumns) + } + } + } + + private func eventBlock(event: EKEvent, column: Int, totalColumns: Int) -> some View { + let calendar = Calendar.current + let hour = calendar.component(.hour, from: event.startDate) + let minute = calendar.component(.minute, from: event.startDate) + + let hourOffset = hour - startHour + let minuteOffset = CGFloat(minute) / 60.0 + let yPosition = CGFloat(hourOffset) * hourHeight + minuteOffset * hourHeight + + // Calculate duration for height + let duration = event.endDate.timeIntervalSince(event.startDate) / 3600.0 + let height = max(CGFloat(duration) * hourHeight - 2, 20) + + // Calculate width based on overlapping events + let availableWidth: CGFloat = 120 // Slightly smaller for today column + let eventWidth = (availableWidth / CGFloat(totalColumns)) - 2 + let xOffset: CGFloat = 36 + CGFloat(column) * (eventWidth + 2) + + return Button { + onEventSelected(event) + } label: { + HStack(spacing: 0) { + Rectangle() + .fill(Color(event.calendar.cgColor)) + .frame(width: 3) + + Text(event.title ?? "") + .font(.system(size: 11)) + .lineLimit(height > 30 ? 2 : 1) + .padding(.horizontal, 6) + .padding(.vertical, 2) + } + .frame(width: eventWidth, height: height, alignment: .leading) + .background(Color(event.calendar.cgColor).opacity(0.25)) + .cornerRadius(4) + } + .buttonStyle(.plain) + .offset(x: xOffset, y: yPosition) + } + + private func groupOverlappingEvents(_ events: [EKEvent]) -> [(EKEvent, Int, Int)] { + guard !events.isEmpty else { return [] } + + var result: [(EKEvent, Int, Int)] = [] + var groups: [[EKEvent]] = [] + + let sortedEvents = events.sorted { $0.startDate < $1.startDate } + + for event in sortedEvents { + var placed = false + for i in groups.indices { + let groupEnd = groups[i].map { $0.endDate }.max() ?? Date.distantPast + if event.startDate >= groupEnd { + groups[i].append(event) + placed = true + break + } + } + if !placed { + groups.append([event]) + } + } + + // Flatten with column info + for event in sortedEvents { + var column = 0 + var overlappingCount = 1 + + for (idx, group) in groups.enumerated() { + if group.contains(where: { $0.eventIdentifier == event.eventIdentifier }) { + column = idx + // Count how many groups overlap with this event + overlappingCount = groups.filter { group in + group.contains { otherEvent in + !(event.endDate <= otherEvent.startDate || event.startDate >= otherEvent.endDate) + } + }.count + break + } + } + + result.append((event, column, overlappingCount)) + } + + return result + } + private func formatHour(_ hour: Int) -> String { let h = hour > 12 ? hour - 12 : hour return "\(h)" } + + private var allDayEventsBadge: some View { + HStack(spacing: 4) { + // Show colored dots for first 3 calendars + HStack(spacing: -4) { + ForEach(Array(allDayEvents.prefix(3).enumerated()), id: \.offset) { _, event in + Circle() + .fill(Color(event.calendar.cgColor)) + .frame(width: 14, height: 14) + .overlay( + Circle() + .stroke(Color.black, lineWidth: 2) + ) + } + } + + Text("\(allDayEvents.count) all-day") + .font(.system(size: 12)) + .foregroundColor(.white) + + Image(systemName: showAllDayEvents ? "chevron.up" : "chevron.down") + .font(.system(size: 10)) + .foregroundColor(.gray) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.white.opacity(0.1)) + .cornerRadius(6) + .contentShape(Rectangle()) + } + + private var allDayEventsList: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(allDayEvents, id: \.eventIdentifier) { event in + HStack(spacing: 6) { + Rectangle() + .fill(Color(event.calendar.cgColor)) + .frame(width: 3, height: 16) + .cornerRadius(1.5) + + Text(event.title ?? "Untitled") + .font(.system(size: 12)) + .foregroundColor(.white) + .lineLimit(1) + } + .padding(.vertical, 2) + } + } + .padding(.top, 6) + .transition(.opacity.combined(with: .move(edge: .top))) + } } private struct TomorrowColumnView: View { let startHour: Int let endHour: Int let hourHeight: CGFloat + let scrollableHeight: CGFloat let events: [EKEvent] + let onEventSelected: (EKEvent) -> Void @State private var showAllDayEvents = false @@ -524,31 +1028,34 @@ private struct TomorrowColumnView: View { } .padding(.bottom, 15) - // Time slots with events - ZStack(alignment: .topLeading) { - // Hour labels - VStack(alignment: .leading, spacing: 0) { - ForEach(startHour.. 0 { - Text("\(hiddenCount) more event\(hiddenCount == 1 ? "" : "s")") - .font(.system(size: 11)) - .foregroundColor(.gray) - .offset(x: 36, y: CGFloat(endHour - startHour) * hourHeight - 20) - } } } @@ -646,20 +1144,25 @@ private struct TomorrowColumnView: View { let eventWidth = (availableWidth / CGFloat(totalColumns)) - 2 let xOffset: CGFloat = 36 + CGFloat(column) * (eventWidth + 2) - return HStack(spacing: 0) { - Rectangle() - .fill(Color(event.calendar.cgColor)) - .frame(width: 3) + return Button { + onEventSelected(event) + } label: { + HStack(spacing: 0) { + Rectangle() + .fill(Color(event.calendar.cgColor)) + .frame(width: 3) - Text(event.title ?? "") - .font(.system(size: 11)) - .lineLimit(height > 30 ? 2 : 1) - .padding(.horizontal, 6) - .padding(.vertical, 2) + Text(event.title ?? "") + .font(.system(size: 11)) + .lineLimit(height > 30 ? 2 : 1) + .padding(.horizontal, 6) + .padding(.vertical, 2) + } + .frame(width: eventWidth, height: height, alignment: .leading) + .background(Color(event.calendar.cgColor).opacity(0.25)) + .cornerRadius(4) } - .frame(width: eventWidth, height: height, alignment: .leading) - .background(Color(event.calendar.cgColor).opacity(0.25)) - .cornerRadius(4) + .buttonStyle(.plain) .offset(x: xOffset, y: yPosition) } @@ -704,7 +1207,7 @@ private struct TomorrowColumnView: View { } } - result.append((event, column, max(overlappingCount, groups.count))) + result.append((event, column, overlappingCount)) } return result @@ -716,6 +1219,131 @@ private struct TomorrowColumnView: View { } } +// MARK: - Calendar Settings View + +struct CalendarSettingsView: View { + @ObservedObject var calendarManager: CalendarManager + @State private var denyListState: Set = [] + var onBack: (() -> Void)? + + init(_ calendarManager: CalendarManager, onBack: (() -> Void)? = nil) { + self.calendarManager = calendarManager + self.onBack = onBack + } + + private var maxHeight: CGFloat { + (NSScreen.main?.frame.height ?? 800) / 2 + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header with back button and title + HStack { + Button { + onBack?() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.gray) + } + .buttonStyle(.plain) + + Spacer() + + Text("CALENDARS") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.gray) + } + .padding(.bottom, 15) + + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach(calendarManager.allCalendars, id: \.calendarIdentifier) { calendar in + CalendarToggleRow( + calendar: calendar, + isEnabled: !denyListState.contains(calendar.calendarIdentifier), + onToggle: { enabled in + toggleCalendar(calendar, enabled: enabled) + } + ) + } + } + } + .frame(maxHeight: maxHeight - 80) // Account for header and padding + } + .frame(width: 250) + .padding(20) + .fontWeight(.semibold) + .foregroundStyle(.white) + .onAppear { + loadDenyListFromConfig() + } + } + + private func loadDenyListFromConfig() { + // Read deny-list directly from ConfigManager to ensure we have the latest persisted values + let widgetConfig = ConfigManager.shared.globalWidgetConfig(for: "default.time") + if let calendarConfig = widgetConfig["calendar"]?.dictionaryValue, + let denyListArray = calendarConfig["deny-list"]?.arrayValue { + denyListState = Set(denyListArray.compactMap { $0.stringValue }.filter { !$0.isEmpty }) + } else { + denyListState = [] + } + } + + private func toggleCalendar(_ calendar: EKCalendar, enabled: Bool) { + // Update local state immediately for responsive UI + if enabled { + denyListState.remove(calendar.calendarIdentifier) + } else { + denyListState.insert(calendar.calendarIdentifier) + } + + // Format as TOML array string and save + let tomlArray = "[" + denyListState.sorted().map { "\"\($0)\"" }.joined(separator: ", ") + "]" + ConfigManager.shared.updateConfigValueRaw( + key: "widgets.default.time.calendar.deny-list", + newValue: tomlArray + ) + } +} + +private struct CalendarToggleRow: View { + let calendar: EKCalendar + let isEnabled: Bool + let onToggle: (Bool) -> Void + + var body: some View { + HStack(spacing: 10) { + // Calendar color indicator + Circle() + .fill(Color(calendar.cgColor)) + .frame(width: 12, height: 12) + + // Calendar name + Text(calendar.title) + .font(.system(size: 13)) + .foregroundColor(.white) + .lineLimit(1) + + Spacer() + + // Toggle checkbox + Image(systemName: isEnabled ? "checkmark.circle.fill" : "circle") + .font(.system(size: 18)) + .foregroundColor(isEnabled ? Color(calendar.cgColor) : .gray.opacity(0.5)) + } + .padding(.vertical, 6) + .padding(.horizontal, 10) + .background(Color.white.opacity(0.05)) + .cornerRadius(8) + .contentShape(Rectangle()) + .onTapGesture { + onToggle(!isEnabled) + } + } +} + struct CalendarPopup_Previews: PreviewProvider { var configProvider: ConfigProvider = ConfigProvider(config: ConfigData()) var calendarManager: CalendarManager diff --git a/Barik/Widgets/Time+Calendar/TimeWidget.swift b/Barik/Widgets/Time+Calendar/TimeWidget.swift index 5c9f961..e94e5d3 100644 --- a/Barik/Widgets/Time+Calendar/TimeWidget.swift +++ b/Barik/Widgets/Time+Calendar/TimeWidget.swift @@ -18,7 +18,11 @@ struct TimeWidget: View { } @State private var currentTime = Date() - let calendarManager: CalendarManager + @StateObject private var calendarManager: CalendarManager + + init(configProvider: ConfigProvider) { + _calendarManager = StateObject(wrappedValue: CalendarManager(configProvider: configProvider)) + } @State private var rect = CGRect() @@ -103,10 +107,9 @@ struct TimeWidget: View { struct TimeWidget_Previews: PreviewProvider { static var previews: some View { let provider = ConfigProvider(config: ConfigData()) - let manager = CalendarManager(configProvider: provider) ZStack { - TimeWidget(calendarManager: manager) + TimeWidget(configProvider: provider) .environmentObject(provider) }.frame(width: 500, height: 100) } From d1bcef63293e9b6984a43d98e56ba74cf6b8cf2e Mon Sep 17 00:00:00 2001 From: bettercoderthanyou Date: Wed, 21 Jan 2026 19:25:38 -0800 Subject: [PATCH 18/30] feat: click album art or song info to open music player (#4) Adds tap gesture handlers to the album cover, song title, and artist in the now playing popup. Clicking these elements opens the currently playing music application (Spotify or Apple Music). Co-authored-by: Claude Opus 4.5 --- Barik/Widgets/NowPlaying/NowPlayingManager.swift | 11 +++++++++++ Barik/Widgets/NowPlaying/NowPlayingPopup.swift | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/Barik/Widgets/NowPlaying/NowPlayingManager.swift b/Barik/Widgets/NowPlaying/NowPlayingManager.swift index c0a2ea1..7cbdb87 100644 --- a/Barik/Widgets/NowPlaying/NowPlayingManager.swift +++ b/Barik/Widgets/NowPlaying/NowPlayingManager.swift @@ -283,4 +283,15 @@ final class NowPlayingManager: ObservableObject { func nextTrack() { NowPlayingProvider.executeCommand { $0.nextTrackCommand } } + + /// Opens and activates the music application that is currently playing. + func openMusicApp() { + guard let song = nowPlaying else { return } + if let app = NSWorkspace.shared.runningApplications.first(where: { + $0.localizedName == song.appName + }), + let bundleURL = app.bundleURL { + NSWorkspace.shared.open(bundleURL) + } + } } diff --git a/Barik/Widgets/NowPlaying/NowPlayingPopup.swift b/Barik/Widgets/NowPlaying/NowPlayingPopup.swift index 071cafb..39775d3 100644 --- a/Barik/Widgets/NowPlaying/NowPlayingPopup.swift +++ b/Barik/Widgets/NowPlaying/NowPlayingPopup.swift @@ -66,6 +66,7 @@ private struct NowPlayingVerticalPopup: View { : nil ) .animation(.smooth(duration: 0.5, extraBounce: 0.4), value: song.state == .paused) + .onTapGesture { playingManager.openMusicApp() } VStack(alignment: .center) { Text(song.title) @@ -77,6 +78,7 @@ private struct NowPlayingVerticalPopup: View { .font(.system(size: 15)) .fontWeight(.light) } + .onTapGesture { playingManager.openMusicApp() } HStack { Text(timeString(from: position)) @@ -135,6 +137,7 @@ struct NowPlayingHorizontalPopup: View { : nil ) .animation(.smooth(duration: 0.5, extraBounce: 0.4), value: song.state == .paused) + .onTapGesture { playingManager.openMusicApp() } VStack(alignment: .leading, spacing: 0) { Text(song.title) @@ -147,6 +150,7 @@ struct NowPlayingHorizontalPopup: View { } .padding(.trailing, 8) .frame(maxWidth: .infinity, alignment: .leading) + .onTapGesture { playingManager.openMusicApp() } } HStack { From 85892fbc3b14a8d29dc9e7858f9efe18a1c1f7a0 Mon Sep 17 00:00:00 2001 From: bettercoderthanyou Date: Wed, 21 Jan 2026 19:39:22 -0800 Subject: [PATCH 19/30] docs: update README for barik-but-better fork (#5) * feat: click album art or song info to open music player Adds tap gesture handlers to the album cover, song title, and artist in the now playing popup. Clicking these elements opens the currently playing music application (Spotify or Apple Music). Co-Authored-By: Claude Opus 4.5 * docs: update README for barik-but-better fork - Remove maintenance notice - Add improvements section highlighting fork enhancements - Rename to barik-but-better Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- README.md | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 491a885..07c0abb 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,20 @@ -**NOTICE**: Unfortunately, I don’t have much free time to actively maintain this project. If you like the project but are not satisfied with its current state, you can explore the many forks or create your own. Even if you’re unfamiliar with **Swift**, tools like **Claude Code** and **Codex** can effectively help implement projects like this. This is a great opportunity to tailor **barik** to your needs and make it exactly the way you’d like. - ----- -

- Barik -

- - License Badge - - - Issues Badge - - - Changelog Badge - - - GitHub Downloads (all assets, all releases) - -

+ Barik

+# barik-but-better + +A fork of [barik](https://github.com/mocki-toki/barik) with active improvements and new features. + +## Improvements over barik + +- **Event-driven updates** - Replaced polling with event-driven notifications for music playback and space changes. More efficient and responsive. +- **Enhanced calendar popup** - Day view with event selection and detailed event information +- **Click to open music player** - Click on album art, song title, or artist in the now playing popup to open Spotify or Apple Music +- **Fixed popup positioning** - Consistent popup positioning across all widgets + +--- + **barik** is a lightweight macOS menu bar replacement. If you use [**yabai**](https://github.com/koekeishiya/yabai) or [**AeroSpace**](https://github.com/nikitabobko/AeroSpace) for tiling WM, you can display the current space in a sleek macOS-style panel with smooth animations. This makes it easy to see which number to press to switch spaces.
From 630186ab0a2417e8760c380e4365ebe112f9fa21 Mon Sep 17 00:00:00 2001 From: bettercoderthanyou Date: Wed, 21 Jan 2026 19:49:55 -0800 Subject: [PATCH 20/30] docs: add more improvements to README (#6) - Add WiFi popup and weather popup to improvements list - Emphasize CPU usage reduction as the key benefit Co-authored-by: Claude Opus 4.5 --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 07c0abb..e4dfebb 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,11 @@ A fork of [barik](https://github.com/mocki-toki/barik) with active improvements ## Improvements over barik -- **Event-driven updates** - Replaced polling with event-driven notifications for music playback and space changes. More efficient and responsive. +- **Drastically reduced CPU usage** - Replaced polling with event-driven notifications for music playback and space changes - **Enhanced calendar popup** - Day view with event selection and detailed event information - **Click to open music player** - Click on album art, song title, or artist in the now playing popup to open Spotify or Apple Music +- **Improved WiFi popup** - macOS-style controls and better network information +- **New weather popup** - Hourly forecast using Open-Meteo API - **Fixed popup positioning** - Consistent popup positioning across all widgets --- From 0455b69adf5741248f7560cb019a3b0656a5946a Mon Sep 17 00:00:00 2001 From: Jarrett Date: Fri, 23 Jan 2026 15:09:07 -0500 Subject: [PATCH 21/30] Added support for changing the keybind of openning the notification center, and made it ensure accessibility permissions are granted --- Barik/Config/ConfigModels.swift | 62 ++++++++++++++++++++++++++ Barik/Helpers/SystemUIHelper.swift | 31 ++++++++----- Barik/Resources/Localizable.xcstrings | 64 +++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 10 deletions(-) diff --git a/Barik/Config/ConfigModels.swift b/Barik/Config/ConfigModels.swift index d5f16a1..34112b8 100644 --- a/Barik/Config/ConfigModels.swift +++ b/Barik/Config/ConfigModels.swift @@ -5,6 +5,7 @@ struct RootToml: Decodable { var theme: String? var yabai: YabaiConfig? var aerospace: AerospaceConfig? + var keybinds: KeybindsConfig? var experimental: ExperimentalConfig? var widgets: WidgetsSection @@ -12,6 +13,7 @@ struct RootToml: Decodable { self.theme = nil self.yabai = nil self.aerospace = nil + self.keybinds = nil self.widgets = WidgetsSection(displayed: [], others: [:]) } } @@ -35,6 +37,10 @@ struct Config { rootToml.aerospace ?? AerospaceConfig() } + var keybinds: KeybindsConfig { + rootToml.keybinds ?? KeybindsConfig() + } + var experimental: ExperimentalConfig { rootToml.experimental ?? ExperimentalConfig() } @@ -246,6 +252,62 @@ struct AerospaceConfig: Decodable { } } + +struct KeybindsConfig: Decodable { + let notifications: NotificationConfig + + enum CodingKeys: String, CodingKey { + case notifications + } + + init() { + self.notifications = NotificationConfig() + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + notifications = try container.decodeIfPresent(NotificationConfig.self, forKey: .notifications) ?? NotificationConfig() + } +} + +struct NotificationConfig: Decodable { + let keyCode: CGKeyCode + var flags: CGEventFlags + + init() { + keyCode = 45 // 'n' key + flags = [.maskCommand, .maskAlternate] // Cmd and Opt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + keyCode = try container.decodeIfPresent(UInt16.self, forKey: .keyCode) ?? 45 + flags = [] + + let flagStrings = try container.decodeIfPresent(Array.self, forKey: .flags) ?? ["ctrl", "opt"] + for flag in flagStrings { + + switch flag { + case "cmd": + flags.insert(.maskCommand) + case "opt": + flags.insert(.maskAlternate) + case "ctrl": + flags.insert(.maskControl) + case "shift": + flags.insert(.maskShift) + default: + print("No flag match for: \(flag)") + } + } + } + + enum CodingKeys: String, CodingKey { + case keyCode, flags + } +} + struct ExperimentalConfig: Decodable { let foreground: ForegroundConfig let background: BackgroundConfig diff --git a/Barik/Helpers/SystemUIHelper.swift b/Barik/Helpers/SystemUIHelper.swift index 155e99d..acd5e01 100644 --- a/Barik/Helpers/SystemUIHelper.swift +++ b/Barik/Helpers/SystemUIHelper.swift @@ -3,26 +3,30 @@ import Foundation /// Helper for triggering macOS system UI elements final class SystemUIHelper { - - /// Opens the macOS Notification Center by simulating Ctrl+Option+N keypress + /// Opens the macOS Notification Center by simulating the configured keypress static func openNotificationCenter() { - // Simulate Ctrl+Option+N keyboard shortcut - let keyCode: CGKeyCode = 45 // 'n' key - let flags: CGEventFlags = [.maskControl, .maskAlternate] - + // Check for accessibility permissions first + guard checkAccessibilityPermissions() else { + print("Accessibility permissions not granted") + return + } + + let keyCode: CGKeyCode = ConfigManager.shared.config.keybinds.notifications.keyCode + let flags: CGEventFlags = ConfigManager.shared.config.keybinds.notifications.flags + // Create and post key down event if let keyDown = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true) { keyDown.flags = flags keyDown.post(tap: .cghidEventTap) } - + // Create and post key up event if let keyUp = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: false) { keyUp.flags = flags keyUp.post(tap: .cghidEventTap) } } - + /// Opens the macOS Weather menu bar dropdown static func openWeatherDropdown() { let script = """ @@ -39,7 +43,7 @@ final class SystemUIHelper { """ runAppleScript(script) } - + /// Opens the Weather app static func openWeatherApp() { NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.Weather")!) @@ -48,7 +52,7 @@ final class SystemUIHelper { NSWorkspace.shared.open(weatherURL) } } - + /// Runs an AppleScript @discardableResult private static func runAppleScript(_ script: String) -> String? { @@ -63,4 +67,11 @@ final class SystemUIHelper { } return result.stringValue } + + /// Quick way to check for accessibility permissions + static func checkAccessibilityPermissions() -> Bool { + let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] + let accessEnabled = AXIsProcessTrustedWithOptions(options) + return accessEnabled + } } diff --git a/Barik/Resources/Localizable.xcstrings b/Barik/Resources/Localizable.xcstrings index ca19551..ec604ea 100644 --- a/Barik/Resources/Localizable.xcstrings +++ b/Barik/Resources/Localizable.xcstrings @@ -1,11 +1,33 @@ { "sourceLanguage" : "en", "strings" : { + "" : { + + }, "?%@?" : { "shouldTranslate" : false }, "%lld" : { "shouldTranslate" : false + }, + "%lld all-day" : { + + }, + "%lld all-day event%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld all-day event%2$@" + } + } + } + }, + "%lld%%" : { + + }, + "%lld%% chance of rain" : { + }, "ALL_DAY" : { "extractionState" : "manual", @@ -131,8 +153,15 @@ } } } + }, + "Back" : { + + }, + "CALENDARS" : { + }, "Channel: %@" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -382,6 +411,7 @@ } }, "Ethernet: %@" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -504,8 +534,25 @@ } } } + }, + "H:%@ L:%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "H:%1$@ L:%2$@" + } + } + } + }, + "Known Network" : { + + }, + "Loading weather..." : { + }, "Noise: %lld" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -628,8 +675,18 @@ } } } + }, + "Open in Calendar" : { + + }, + "Open Weather" : { + + }, + "Other Networks" : { + }, "RSSI: %lld" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -754,6 +811,7 @@ } }, "Signal strength: %@" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1622,6 +1680,12 @@ } } } + }, + "Wi-Fi" : { + + }, + "Wi-Fi Settings..." : { + } }, "version" : "1.0" From bc986fdca65e0ded7a2624256a0d5df32ee4fdcd Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Fri, 30 Jan 2026 14:21:22 -0800 Subject: [PATCH 22/30] fix: restore now playing widget in default config The music widget was dropped from the default widget list when it was renamed from "default.spotify" to "default.nowplaying". Add it back and accept the old name as an alias for backwards compatibility. Co-Authored-By: Claude Opus 4.5 --- Barik/Config/ConfigManager.swift | 1 + Barik/Views/MenuBarView.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Barik/Config/ConfigManager.swift b/Barik/Config/ConfigManager.swift index 8fbffaa..95b63b2 100644 --- a/Barik/Config/ConfigManager.swift +++ b/Barik/Config/ConfigManager.swift @@ -72,6 +72,7 @@ final class ConfigManager: ObservableObject { displayed = [ # widgets on menu bar "default.spaces", "spacer", + "default.nowplaying", "default.network", "default.battery", "divider", diff --git a/Barik/Views/MenuBarView.swift b/Barik/Views/MenuBarView.swift index 7c48370..5209225 100644 --- a/Barik/Views/MenuBarView.swift +++ b/Barik/Views/MenuBarView.swift @@ -55,7 +55,7 @@ struct MenuBarView: View { TimeWidget(configProvider: config) .environmentObject(config) - case "default.nowplaying": + case "default.nowplaying", "default.spotify": NowPlayingWidget() .environmentObject(config) From 91800baf71629c77acb88b3b9f248902cd327adb Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Mon, 2 Feb 2026 13:02:13 -0800 Subject: [PATCH 23/30] feat: add Claude Code usage tracking widget Adds a new menu bar widget that displays Claude Code API usage as a circular donut ring around the Claude icon. Tracks 5-hour rolling window and weekly message counts from ~/.claude/ data files with real-time file watching (no polling). Co-Authored-By: Claude Opus 4.5 --- Barik/Config/ConfigManager.swift | 6 + .../ClaudeIcon.imageset/Contents.json | 16 ++ .../ClaudeIcon.imageset/claude-icon.svg | 3 + Barik/Views/MenuBarView.swift | 4 + .../ClaudeUsage/ClaudeUsageManager.swift | 265 ++++++++++++++++++ .../ClaudeUsage/ClaudeUsagePopup.swift | 221 +++++++++++++++ .../ClaudeUsage/ClaudeUsageWidget.swift | 61 ++++ 7 files changed, 576 insertions(+) create mode 100644 Barik/Resources/Assets.xcassets/ClaudeIcon.imageset/Contents.json create mode 100644 Barik/Resources/Assets.xcassets/ClaudeIcon.imageset/claude-icon.svg create mode 100644 Barik/Widgets/ClaudeUsage/ClaudeUsageManager.swift create mode 100644 Barik/Widgets/ClaudeUsage/ClaudeUsagePopup.swift create mode 100644 Barik/Widgets/ClaudeUsage/ClaudeUsageWidget.swift diff --git a/Barik/Config/ConfigManager.swift b/Barik/Config/ConfigManager.swift index 95b63b2..76d02dd 100644 --- a/Barik/Config/ConfigManager.swift +++ b/Barik/Config/ConfigManager.swift @@ -72,6 +72,7 @@ final class ConfigManager: ObservableObject { displayed = [ # widgets on menu bar "default.spaces", "spacer", + "default.claude-usage", "default.nowplaying", "default.network", "default.battery", @@ -85,6 +86,11 @@ final class ConfigManager: ObservableObject { window.show-title = true window.title.max-length = 50 + [widgets.default.claude-usage] + plan = "pro" + five-hour-limit = 80 + weekly-limit = 500 + [widgets.default.battery] show-percentage = true warning-level = 30 diff --git a/Barik/Resources/Assets.xcassets/ClaudeIcon.imageset/Contents.json b/Barik/Resources/Assets.xcassets/ClaudeIcon.imageset/Contents.json new file mode 100644 index 0000000..6d46d5e --- /dev/null +++ b/Barik/Resources/Assets.xcassets/ClaudeIcon.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "claude-icon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Barik/Resources/Assets.xcassets/ClaudeIcon.imageset/claude-icon.svg b/Barik/Resources/Assets.xcassets/ClaudeIcon.imageset/claude-icon.svg new file mode 100644 index 0000000..5714e77 --- /dev/null +++ b/Barik/Resources/Assets.xcassets/ClaudeIcon.imageset/claude-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/Barik/Views/MenuBarView.swift b/Barik/Views/MenuBarView.swift index 5209225..b5f26d5 100644 --- a/Barik/Views/MenuBarView.swift +++ b/Barik/Views/MenuBarView.swift @@ -63,6 +63,10 @@ struct MenuBarView: View { WeatherWidget() .environmentObject(config) + case "default.claude-usage": + ClaudeUsageWidget() + .environmentObject(config) + case "spacer": Spacer().frame(minWidth: 50, maxWidth: .infinity) diff --git a/Barik/Widgets/ClaudeUsage/ClaudeUsageManager.swift b/Barik/Widgets/ClaudeUsage/ClaudeUsageManager.swift new file mode 100644 index 0000000..d245f87 --- /dev/null +++ b/Barik/Widgets/ClaudeUsage/ClaudeUsageManager.swift @@ -0,0 +1,265 @@ +import Foundation +import SwiftUI + +// MARK: - Data Models + +struct ClaudeStatsCache: Codable { + let version: Int + let lastComputedDate: String + let dailyActivity: [DailyActivity] + let modelUsage: [String: ModelUsage] + let totalSessions: Int + let totalMessages: Int + + struct DailyActivity: Codable { + let date: String + let messageCount: Int + let sessionCount: Int + let toolCallCount: Int + } + + struct ModelUsage: Codable { + let inputTokens: Int + let outputTokens: Int + let cacheReadInputTokens: Int + let cacheCreationInputTokens: Int + } +} + +struct HistoryEntry: Codable { + let display: String? + let timestamp: Double + let project: String? +} + +struct ClaudeUsageData { + var fiveHourCount: Int = 0 + var fiveHourLimit: Int = 80 + var fiveHourPercentage: Double = 0 + var fiveHourResetDate: Date? + + var weeklyCount: Int = 0 + var weeklyLimit: Int = 500 + var weeklyPercentage: Double = 0 + var weeklyResetDate: Date? + + var todayMessages: Int = 0 + + var plan: String = "Pro" + var lastUpdated: Date = Date() + var isAvailable: Bool = false +} + +// MARK: - Manager + +@MainActor +final class ClaudeUsageManager: ObservableObject { + static let shared = ClaudeUsageManager() + + @Published private(set) var usageData = ClaudeUsageData() + + private var statsWatchSource: DispatchSourceFileSystemObject? + private var statsFileDescriptor: CInt = -1 + private var historyWatchSource: DispatchSourceFileSystemObject? + private var historyFileDescriptor: CInt = -1 + + private var currentConfig: ConfigData = [:] + + private let statsCachePath: String + private let historyPath: String + + private init() { + let home = FileManager.default.homeDirectoryForCurrentUser.path + statsCachePath = "\(home)/.claude/stats-cache.json" + historyPath = "\(home)/.claude/history.jsonl" + } + + func startUpdating(config: ConfigData) { + currentConfig = config + fetchData() + startWatching(path: statsCachePath, source: &statsWatchSource, descriptor: &statsFileDescriptor) + startWatching(path: historyPath, source: &historyWatchSource, descriptor: &historyFileDescriptor) + } + + func stopUpdating() { + stopWatching(source: &statsWatchSource, descriptor: &statsFileDescriptor) + stopWatching(source: &historyWatchSource, descriptor: &historyFileDescriptor) + } + + func refresh() { + fetchData() + } + + // MARK: - File Watching + + private func startWatching( + path: String, + source: inout DispatchSourceFileSystemObject?, + descriptor: inout CInt + ) { + stopWatching(source: &source, descriptor: &descriptor) + + descriptor = open(path, O_EVTONLY) + if descriptor == -1 { return } + + let newSource = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, + eventMask: [.write, .extend], + queue: DispatchQueue.global() + ) + newSource.setEventHandler { [weak self] in + Task { @MainActor in + self?.fetchData() + } + } + let fd = descriptor + newSource.setCancelHandler { + if fd != -1 { close(fd) } + } + newSource.resume() + source = newSource + } + + private func stopWatching( + source: inout DispatchSourceFileSystemObject?, + descriptor: inout CInt + ) { + source?.cancel() + source = nil + descriptor = -1 + } + + // MARK: - Data Fetching + + private func fetchData() { + let config = currentConfig + let fiveHourLimit = config["five-hour-limit"]?.intValue ?? 80 + let weeklyLimit = config["weekly-limit"]?.intValue ?? 500 + let plan = config["plan"]?.stringValue ?? "Pro" + + // Get live counts from history.jsonl (always up to date) + let (fiveHourCount, fiveHourResetDate, todayMessages) = computeFromHistory() + + // Get weekly count from stats-cache.json (may lag behind) + var weeklyCount = todayMessages // Start with today from history + if FileManager.default.fileExists(atPath: statsCachePath) { + do { + let statsData = try Data(contentsOf: URL(fileURLWithPath: statsCachePath)) + let stats = try JSONDecoder().decode(ClaudeStatsCache.self, from: statsData) + + let todayString = Self.dateFormatter.string(from: Date()) + + // Add prior days this week from stats-cache + weeklyCount += computeWeeklyCount(from: stats.dailyActivity, excludingToday: todayString) + } catch { + print("ClaudeUsageManager: Error reading stats-cache: \(error)") + } + } + + var data = ClaudeUsageData() + data.fiveHourCount = fiveHourCount + data.fiveHourLimit = fiveHourLimit + data.fiveHourPercentage = fiveHourLimit > 0 ? Double(fiveHourCount) / Double(fiveHourLimit) : 0 + data.fiveHourResetDate = fiveHourResetDate + + data.weeklyCount = weeklyCount + data.weeklyLimit = weeklyLimit + data.weeklyPercentage = weeklyLimit > 0 ? Double(weeklyCount) / Double(weeklyLimit) : 0 + data.weeklyResetDate = nextWeeklyReset() + + data.todayMessages = todayMessages + data.plan = plan + data.lastUpdated = Date() + data.isAvailable = todayMessages > 0 || FileManager.default.fileExists(atPath: statsCachePath) + + usageData = data + } + + // MARK: - Computations + + private func computeWeeklyCount(from dailyActivity: [ClaudeStatsCache.DailyActivity], excludingToday todayString: String) -> Int { + let now = Date() + var calendar = Calendar.current + calendar.firstWeekday = 2 // Monday + guard let startOfWeek = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: now)) else { + return 0 + } + let startString = Self.dateFormatter.string(from: startOfWeek) + return dailyActivity + .filter { $0.date >= startString && $0.date != todayString } + .reduce(0) { $0 + $1.messageCount } + } + + /// Parses history.jsonl and returns (fiveHourCount, fiveHourResetDate, todayCount) + private func computeFromHistory() -> (fiveHourCount: Int, fiveHourResetDate: Date?, todayCount: Int) { + guard FileManager.default.fileExists(atPath: historyPath), + let data = FileManager.default.contents(atPath: historyPath), + let content = String(data: data, encoding: .utf8) else { + return (0, nil, 0) + } + + let now = Date() + let fiveHoursAgo = now.addingTimeInterval(-5 * 3600) + let fiveHoursAgoMs = fiveHoursAgo.timeIntervalSince1970 * 1000 + let startOfDay = Calendar.current.startOfDay(for: now) + let startOfDayMs = startOfDay.timeIntervalSince1970 * 1000 + + let decoder = JSONDecoder() + let lines = content.components(separatedBy: "\n") + + var fiveHourCount = 0 + var todayCount = 0 + var oldestInWindow: Double? + + for line in lines.reversed() { + guard !line.isEmpty, + let lineData = line.data(using: .utf8), + let entry = try? decoder.decode(HistoryEntry.self, from: lineData) else { + continue + } + + // Stop once we're past both windows + if entry.timestamp < startOfDayMs && entry.timestamp < fiveHoursAgoMs { + break + } + + if entry.timestamp >= fiveHoursAgoMs { + fiveHourCount += 1 + if oldestInWindow == nil || entry.timestamp < (oldestInWindow ?? .infinity) { + oldestInWindow = entry.timestamp + } + } + + if entry.timestamp >= startOfDayMs { + todayCount += 1 + } + } + + let resetDate = oldestInWindow.map { + Date(timeIntervalSince1970: $0 / 1000).addingTimeInterval(5 * 3600) + } + + return (fiveHourCount, resetDate, todayCount) + } + + private func nextWeeklyReset() -> Date { + var calendar = Calendar.current + calendar.firstWeekday = 2 + let now = Date() + var components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: now) + components.weekday = 2 + components.hour = 0 + components.minute = 0 + var resetDate = calendar.date(from: components) ?? now + if resetDate <= now { + resetDate = calendar.date(byAdding: .weekOfYear, value: 1, to: resetDate) ?? now + } + return resetDate + } + + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f + }() +} diff --git a/Barik/Widgets/ClaudeUsage/ClaudeUsagePopup.swift b/Barik/Widgets/ClaudeUsage/ClaudeUsagePopup.swift new file mode 100644 index 0000000..26ba329 --- /dev/null +++ b/Barik/Widgets/ClaudeUsage/ClaudeUsagePopup.swift @@ -0,0 +1,221 @@ +import SwiftUI + +struct ClaudeUsagePopup: View { + @EnvironmentObject var configProvider: ConfigProvider + @ObservedObject private var usageManager = ClaudeUsageManager.shared + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if usageManager.usageData.isAvailable { + titleBar + Divider().background(Color.white.opacity(0.2)) + rateLimitSection( + icon: "clock", + title: "5-Hour Window", + percentage: usageManager.usageData.fiveHourPercentage, + count: usageManager.usageData.fiveHourCount, + limit: usageManager.usageData.fiveHourLimit, + resetDate: usageManager.usageData.fiveHourResetDate, + resetPrefix: "Resets in" + ) + Divider().background(Color.white.opacity(0.2)) + rateLimitSection( + icon: "calendar", + title: "Weekly", + percentage: usageManager.usageData.weeklyPercentage, + count: usageManager.usageData.weeklyCount, + limit: usageManager.usageData.weeklyLimit, + resetDate: usageManager.usageData.weeklyResetDate, + resetPrefix: "Resets" + ) + Divider().background(Color.white.opacity(0.2)) + todaySection + Divider().background(Color.white.opacity(0.2)) + footerSection + } else { + unavailableView + } + } + .frame(width: 280) + .background(Color.black) + } + + // MARK: - Title Bar + + private var titleBar: some View { + HStack(spacing: 8) { + Image("ClaudeIcon") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + Text("Claude Usage") + .font(.system(size: 14, weight: .semibold)) + Spacer() + Text(usageManager.usageData.plan) + .font(.system(size: 11, weight: .medium)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(planBadgeColor.opacity(0.3)) + .foregroundColor(planBadgeColor) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + + private var planBadgeColor: Color { + switch usageManager.usageData.plan.lowercased() { + case "pro": return .orange + case "max": return .purple + case "team": return .blue + case "free": return .gray + default: return .orange + } + } + + // MARK: - Rate Limit Section + + private func rateLimitSection( + icon: String, + title: String, + percentage: Double, + count: Int, + limit: Int, + resetDate: Date?, + resetPrefix: String + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: icon) + .font(.system(size: 12)) + .opacity(0.6) + Text(title) + .font(.system(size: 13, weight: .medium)) + Spacer() + Text("\(Int(min(percentage, 1.0) * 100))%") + .font(.system(size: 24, weight: .semibold)) + } + + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3) + .fill(Color.gray.opacity(0.3)) + .frame(height: 6) + RoundedRectangle(cornerRadius: 3) + .fill(progressColor(for: percentage)) + .frame( + width: geometry.size.width * min(percentage, 1.0), + height: 6 + ) + .animation(.easeOut(duration: 0.3), value: percentage) + } + } + .frame(height: 6) + + if let resetDate = resetDate { + Text("\(resetPrefix) \(resetTimeString(resetDate))") + .font(.system(size: 11)) + .opacity(0.5) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + + private func progressColor(for percentage: Double) -> Color { + if percentage >= 0.8 { return .red } + if percentage >= 0.6 { return .orange } + return .white + } + + private func resetTimeString(_ date: Date) -> String { + let interval = date.timeIntervalSince(Date()) + if interval <= 0 { return "soon" } + + let hours = Int(interval) / 3600 + let minutes = (Int(interval) % 3600) / 60 + + if hours > 24 { + let formatter = DateFormatter() + formatter.dateFormat = "E h:mm a" + return formatter.string(from: date) + } else if hours > 0 { + return "\(hours)h \(minutes)m" + } else { + return "\(minutes)m" + } + } + + // MARK: - Today Section + + private var todaySection: some View { + HStack { + Text("Today") + .font(.system(size: 13, weight: .medium)) + .opacity(0.7) + Spacer() + Text("\(usageManager.usageData.todayMessages) messages") + .font(.system(size: 13, weight: .semibold)) + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + + // MARK: - Footer + + private var footerSection: some View { + HStack { + Text("Updated \(timeAgoString(usageManager.usageData.lastUpdated))") + .font(.system(size: 11)) + .opacity(0.4) + + Spacer() + + Button(action: { + usageManager.refresh() + }) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 12)) + .opacity(0.6) + } + .buttonStyle(.plain) + .onHover { hovering in + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + } + + private func timeAgoString(_ date: Date) -> String { + let seconds = Int(Date().timeIntervalSince(date)) + if seconds < 60 { return "\(seconds) sec ago" } + let minutes = seconds / 60 + if minutes < 60 { return "\(minutes) min ago" } + return "\(minutes / 60)h ago" + } + + // MARK: - Unavailable + + private var unavailableView: some View { + VStack(spacing: 12) { + Image("ClaudeIcon") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + .opacity(0.4) + Text("Claude Code not found") + .font(.system(size: 13, weight: .medium)) + Text("Install Claude Code to see usage stats") + .font(.system(size: 11)) + .opacity(0.5) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(40) + } +} diff --git a/Barik/Widgets/ClaudeUsage/ClaudeUsageWidget.swift b/Barik/Widgets/ClaudeUsage/ClaudeUsageWidget.swift new file mode 100644 index 0000000..d8afd2f --- /dev/null +++ b/Barik/Widgets/ClaudeUsage/ClaudeUsageWidget.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct ClaudeUsageWidget: View { + @EnvironmentObject var configProvider: ConfigProvider + @ObservedObject private var usageManager = ClaudeUsageManager.shared + + @State private var widgetFrame: CGRect = .zero + + private var percentage: Double { + usageManager.usageData.fiveHourPercentage + } + + private var ringColor: Color { + if percentage >= 0.8 { return .red } + if percentage >= 0.6 { return .orange } + return .white + } + + var body: some View { + ZStack { + // Filled ring — splits from bottom, fills both sides equally + Circle() + .trim(from: 0.5 - min(percentage, 1.0) / 2, to: 0.5 + min(percentage, 1.0) / 2) + .stroke(ringColor, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(90)) + .animation(.easeOut(duration: 0.3), value: percentage) + + // Claude icon in center + Image("ClaudeIcon") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + .frame(width: 28, height: 28) + .foregroundStyle(.foregroundOutside) + .shadow(color: .foregroundShadowOutside, radius: 3) + .experimentalConfiguration(cornerRadius: 15) + .frame(maxHeight: .infinity) + .background(.black.opacity(0.001)) + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + widgetFrame = geometry.frame(in: .global) + } + .onChange(of: geometry.frame(in: .global)) { _, newFrame in + widgetFrame = newFrame + } + } + ) + .onTapGesture { + MenuBarPopup.show(rect: widgetFrame, id: "claude-usage") { + ClaudeUsagePopup() + .environmentObject(configProvider) + } + } + .onAppear { + usageManager.startUpdating(config: configProvider.config) + } + } +} From 03f745704a7f68728d9bebde8bf2ffd75e45b40e Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Mon, 2 Feb 2026 13:25:08 -0800 Subject: [PATCH 24/30] feat: drag-and-drop widget reordering in menu bar Allow users to drag widgets to reorganize their order directly in the menu bar. The new order persists to ~/.barik-config.toml automatically. Co-Authored-By: Claude Opus 4.5 --- Barik/Config/ConfigManager.swift | 83 ++++++++++++++++++++++++++++++++ Barik/Config/ConfigModels.swift | 48 +++++++++++++++++- Barik/Views/MenuBarView.swift | 80 +++++++++++++++++++++++++++--- 3 files changed, 203 insertions(+), 8 deletions(-) diff --git a/Barik/Config/ConfigManager.swift b/Barik/Config/ConfigManager.swift index 95b63b2..7acaa9b 100644 --- a/Barik/Config/ConfigManager.swift +++ b/Barik/Config/ConfigManager.swift @@ -11,6 +11,7 @@ final class ConfigManager: ObservableObject { private var fileWatchSource: DispatchSourceFileSystemObject? private var fileDescriptor: CInt = -1 private var configFilePath: String? + private var suppressNextReload = false private init() { loadOrCreateConfigIfNeeded() @@ -117,6 +118,10 @@ final class ConfigManager: ObservableObject { guard let self = self, let path = self.configFilePath else { return } + if self.suppressNextReload { + self.suppressNextReload = false + return + } self.parseConfigFile(at: path) } fileWatchSource?.setCancelHandler { [weak self] in @@ -252,6 +257,84 @@ final class ConfigManager: ObservableObject { } } + func updateDisplayedWidgets(_ items: [TomlWidgetItem]) { + guard let path = configFilePath else { + print("Config file path is not set") + return + } + do { + let currentText = try String(contentsOfFile: path, encoding: .utf8) + let updatedText = replaceDisplayedArray(in: currentText, with: items) + suppressNextReload = true + try updatedText.write(toFile: path, atomically: true, encoding: .utf8) + DispatchQueue.main.async { + self.parseConfigFile(at: path) + } + } catch { + suppressNextReload = false + print("Error updating displayed widgets:", error) + } + } + + private func replaceDisplayedArray(in original: String, with items: [TomlWidgetItem]) -> String { + let lines = original.components(separatedBy: "\n") + var inWidgetsSection = false + var arrayStartLine: Int? + var arrayEndLine: Int? + var bracketDepth = 0 + var foundStart = false + + for (lineIndex, line) in lines.enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { + inWidgetsSection = (trimmed == "[widgets]") + if foundStart && !inWidgetsSection { + break + } + continue + } + + if inWidgetsSection && !foundStart { + if trimmed.hasPrefix("displayed") && trimmed.contains("=") { + arrayStartLine = lineIndex + foundStart = true + for char in trimmed { + if char == Character("[") { bracketDepth += 1 } + if char == Character("]") { bracketDepth -= 1 } + } + if bracketDepth == 0 { + arrayEndLine = lineIndex + break + } + } + } else if foundStart && arrayEndLine == nil { + for char in trimmed { + if char == Character("[") { bracketDepth += 1 } + if char == Character("]") { bracketDepth -= 1 } + } + if bracketDepth == 0 { + arrayEndLine = lineIndex + break + } + } + } + + guard let start = arrayStartLine, let end = arrayEndLine else { + return original + } + + let newArrayLines = "displayed = " + items.toTomlDisplayedArray() + + var newLines = Array(lines[0.. ConfigData { config.rootToml.widgets.config(for: widgetId) ?? [:] } diff --git a/Barik/Config/ConfigModels.swift b/Barik/Config/ConfigModels.swift index d5f16a1..c8069b3 100644 --- a/Barik/Config/ConfigModels.swift +++ b/Barik/Config/ConfigModels.swift @@ -113,16 +113,19 @@ struct WidgetsSection: Decodable { } } -struct TomlWidgetItem: Decodable { +struct TomlWidgetItem: Decodable, Equatable, Hashable { + let instanceID: UUID let id: String let inlineParams: ConfigData init(id: String, inlineParams: ConfigData) { + self.instanceID = UUID() self.id = id self.inlineParams = inlineParams } init(from decoder: Decoder) throws { + self.instanceID = UUID() let container = try decoder.singleValueContainer() if let strValue = try? container.decode(String.self) { @@ -148,9 +151,34 @@ struct TomlWidgetItem: Decodable { self.id = widgetId self.inlineParams = params } + + static func == (lhs: TomlWidgetItem, rhs: TomlWidgetItem) -> Bool { + lhs.instanceID == rhs.instanceID + } + + func hash(into hasher: inout Hasher) { + hasher.combine(instanceID) + } + + func toTomlString() -> String { + if inlineParams.isEmpty { + return "\"\(id)\"" + } + let paramsString = inlineParams.map { key, value in + "\(key) = \(value.toTomlValueString())" + }.joined(separator: ", ") + return "{ \"\(id)\" = { \(paramsString) } }" + } } -enum TOMLValue: Decodable { +extension Array where Element == TomlWidgetItem { + func toTomlDisplayedArray() -> String { + let items = self.map { " \($0.toTomlString())" }.joined(separator: ",\n") + return "[\n\(items)\n]" + } +} + +enum TOMLValue: Decodable, Equatable, Hashable { case string(String) case bool(Bool) case int(Int) @@ -216,6 +244,22 @@ extension TOMLValue { if case let .dictionary(dict) = self { return dict } return nil } + + func toTomlValueString() -> String { + switch self { + case .string(let s): return "\"\(s)\"" + case .bool(let b): return b ? "true" : "false" + case .int(let i): return "\(i)" + case .double(let d): return "\(d)" + case .array(let arr): + let inner = arr.map { $0.toTomlValueString() }.joined(separator: ", ") + return "[\(inner)]" + case .dictionary(let dict): + let inner = dict.map { "\($0.key) = \($0.value.toTomlValueString())" }.joined(separator: ", ") + return "{ \(inner) }" + case .null: return "\"\"" + } + } } struct YabaiConfig: Decodable { diff --git a/Barik/Views/MenuBarView.swift b/Barik/Views/MenuBarView.swift index 5209225..12c11ea 100644 --- a/Barik/Views/MenuBarView.swift +++ b/Barik/Views/MenuBarView.swift @@ -1,7 +1,16 @@ import SwiftUI +import UniformTypeIdentifiers struct MenuBarView: View { @ObservedObject var configManager = ConfigManager.shared + @State private var widgetItems: [TomlWidgetItem] = [] + @State private var draggingItem: TomlWidgetItem? + + private var displayedFingerprint: String { + configManager.config.rootToml.widgets.displayed + .map { $0.id } + .joined(separator: "|") + } var body: some View { let theme: ColorScheme? = @@ -14,17 +23,28 @@ struct MenuBarView: View { .none } - let items = configManager.config.rootToml.widgets.displayed - HStack(spacing: 0) { HStack(spacing: configManager.config.experimental.foreground.spacing) { - ForEach(0.. Void + + func performDrop(info: DropInfo) -> Bool { + draggingItem = nil + onReorderComplete() + return true + } + + func dropEntered(info: DropInfo) { + guard let dragging = draggingItem, + dragging.instanceID != item.instanceID, + let fromIndex = items.firstIndex(where: { $0.instanceID == dragging.instanceID }), + let toIndex = items.firstIndex(where: { $0.instanceID == item.instanceID }) + else { return } + + withAnimation(.smooth(duration: 0.2)) { + items.move( + fromOffsets: IndexSet(integer: fromIndex), + toOffset: toIndex > fromIndex ? toIndex + 1 : toIndex + ) + } + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + DropProposal(operation: .move) + } + + func validateDrop(info: DropInfo) -> Bool { + draggingItem != nil + } +} From 4a28e2875af2042c60c20ffc601a253030b41ba0 Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Mon, 2 Feb 2026 13:42:25 -0800 Subject: [PATCH 25/30] ci: auto-build on push + update README quickstart Add GitHub Actions workflow that builds and publishes a release on every push to main. Update README with Homebrew and build-from-source install instructions for the fork. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/build.yml | 39 +++++++++++++++++++++++++++++++++++++ README.md | 38 ++++++++++++++++++++++++++---------- 2 files changed, 67 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..eeff6f0 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,39 @@ +name: Build and Release + +on: + push: + branches: [main] + +jobs: + build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: Build Release + run: | + xcodebuild -scheme Barik -configuration Release \ + -derivedDataPath build \ + CODE_SIGNING_ALLOWED=NO + + - name: Zip app + run: ditto -c -k --keepParent build/Build/Products/Release/Barik.app Barik.zip + + - name: Get version + id: version + run: | + VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" build/Build/Products/Release/Barik.app/Contents/Info.plist) + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Update latest release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release delete latest --yes 2>/dev/null || true + git push origin :refs/tags/latest 2>/dev/null || true + gh release create latest Barik.zip \ + --title "Latest Build (v${{ steps.version.outputs.version }})" \ + --notes "Automated build from \`main\` branch ($(date -u +%Y-%m-%d)). + + Built from commit ${{ github.sha }}." \ + --prerelease diff --git a/README.md b/README.md index e4dfebb..5293d97 100644 --- a/README.md +++ b/README.md @@ -42,23 +42,41 @@ https://github.com/user-attachments/assets/d3799e24-c077-4c6a-a7da-a1f2eee1a07f ## Quick Start -1. Install **barik** via [Homebrew](https://brew.sh/) +### Install via Homebrew ```sh -brew install --cask mocki-toki/formulae/barik +brew install --cask bettercoderthanyou/formulae/barik-but-better ``` -Or you can download from [Releases](https://github.com/mocki-toki/barik/releases), unzip it, and move it to your Applications folder. +### Or build from source -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). +1. Clone the repo and build with Xcode: -3. Hide the system menu bar in **System Settings** and uncheck **Desktop & Dock → Show items → On Desktop**. +```sh +git clone https://github.com/bettercoderthanyou/barik-but-better.git +cd barik-but-better +xcodebuild -scheme Barik -configuration Release build +``` + +2. Copy the built app to Applications: + +```sh +cp -R ~/Library/Developer/Xcode/DerivedData/Barik-*/Build/Products/Release/Barik.app /Applications/ +``` + +> **Note:** Building from source requires Xcode (not just Command Line Tools). + +### Set up your desktop + +1. _(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. Hide the system menu bar in **System Settings** and uncheck **Desktop & Dock → Show items → On Desktop**. -4. Launch **barik** from the Applications folder. +3. Launch **barik** from the Applications folder. -5. Add **barik** to your login items for automatic startup. +4. Add **barik** to your login items for automatic startup. -**That's it!** Try switching spaces and see the panel in action. +**That's it!** Try switching spaces and see the panel in action. You can drag widgets to reorder them directly in the menu bar. ## Configuration @@ -146,7 +164,7 @@ Unfortunately, macOS does not support access to its API that allows music contro 1. Spotify (requires the desktop application) 2. Apple Music (requires the desktop application) -Create an issue so we can add your favorite music service: https://github.com/mocki-toki/barik/issues/new +Create an issue so we can add your favorite music service: https://github.com/bettercoderthanyou/barik-but-better/issues/new ## Where Are the Menu Items? @@ -172,4 +190,4 @@ Apple and macOS are trademarks of Apple Inc. This project is not connected to Ap ## Stars -[![Stargazers over time](https://starchart.cc/mocki-toki/barik.svg?variant=adaptive)](https://starchart.cc/mocki-toki/barik) +[![Stargazers over time](https://starchart.cc/bettercoderthanyou/barik-but-better.svg?variant=adaptive)](https://starchart.cc/bettercoderthanyou/barik-but-better) From 2e4435cccfbd120b69c97d4e11a283efb12e36b4 Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Mon, 2 Feb 2026 13:45:26 -0800 Subject: [PATCH 26/30] docs: add drag-and-drop and Claude widget to improvements list Co-Authored-By: Claude Opus 4.5 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 5293d97..8e57e39 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ A fork of [barik](https://github.com/mocki-toki/barik) with active improvements ## Improvements over barik +- **Drag-and-drop widget reordering** - Drag widgets directly in the menu bar to rearrange them, order persists to config +- **Claude Code usage widget** - Track API usage in real-time with a donut ring indicator that changes color at thresholds, plus a popup with rolling window and weekly stats - **Drastically reduced CPU usage** - Replaced polling with event-driven notifications for music playback and space changes - **Enhanced calendar popup** - Day view with event selection and detailed event information - **Click to open music player** - Click on album art, song title, or artist in the now playing popup to open Spotify or Apple Music From a69ef83de66c3ae5c1c827b17acec26d00cf87bd Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Mon, 2 Feb 2026 14:05:51 -0800 Subject: [PATCH 27/30] fix: hide claude widget when no data, count session messages accurately The widget now hides itself when there's no Claude usage data available instead of always rendering in the menu bar. Also fixes message counting to scan session JSONL files in ~/.claude/projects/ (capturing Conductor and agent sessions) rather than only reading history.jsonl which misses programmatic sessions. Tool-result API continuations are filtered out so only real conversation turns are counted. Co-Authored-By: Claude Opus 4.5 --- .../ClaudeUsage/ClaudeUsageManager.swift | 93 ++++++++++++++++++- .../ClaudeUsage/ClaudeUsageWidget.swift | 68 +++++++------- 2 files changed, 126 insertions(+), 35 deletions(-) diff --git a/Barik/Widgets/ClaudeUsage/ClaudeUsageManager.swift b/Barik/Widgets/ClaudeUsage/ClaudeUsageManager.swift index d245f87..7450fa0 100644 --- a/Barik/Widgets/ClaudeUsage/ClaudeUsageManager.swift +++ b/Barik/Widgets/ClaudeUsage/ClaudeUsageManager.swift @@ -62,16 +62,19 @@ final class ClaudeUsageManager: ObservableObject { private var statsFileDescriptor: CInt = -1 private var historyWatchSource: DispatchSourceFileSystemObject? private var historyFileDescriptor: CInt = -1 + private var refreshTimer: Timer? private var currentConfig: ConfigData = [:] private let statsCachePath: String private let historyPath: String + private let projectsPath: String private init() { let home = FileManager.default.homeDirectoryForCurrentUser.path statsCachePath = "\(home)/.claude/stats-cache.json" historyPath = "\(home)/.claude/history.jsonl" + projectsPath = "\(home)/.claude/projects" } func startUpdating(config: ConfigData) { @@ -79,11 +82,20 @@ final class ClaudeUsageManager: ObservableObject { fetchData() startWatching(path: statsCachePath, source: &statsWatchSource, descriptor: &statsFileDescriptor) startWatching(path: historyPath, source: &historyWatchSource, descriptor: &historyFileDescriptor) + // Periodically rescan session files since we can't watch them all individually + refreshTimer?.invalidate() + refreshTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.fetchData() + } + } } func stopUpdating() { stopWatching(source: &statsWatchSource, descriptor: &statsFileDescriptor) stopWatching(source: &historyWatchSource, descriptor: &historyFileDescriptor) + refreshTimer?.invalidate() + refreshTimer = nil } func refresh() { @@ -137,11 +149,20 @@ final class ClaudeUsageManager: ObservableObject { let weeklyLimit = config["weekly-limit"]?.intValue ?? 500 let plan = config["plan"]?.stringValue ?? "Pro" - // Get live counts from history.jsonl (always up to date) - let (fiveHourCount, fiveHourResetDate, todayMessages) = computeFromHistory() + // Get counts from session JSONL files (captures all sessions including Conductor agents) + let sessionCounts = computeFromSessions() + // Fall back to history.jsonl for direct CLI usage + let historyCounts = computeFromHistory() + + // Use whichever source reports more messages (session files are the superset) + let fiveHourCount = max(sessionCounts.fiveHourCount, historyCounts.fiveHourCount) + let todayMessages = max(sessionCounts.todayCount, historyCounts.todayCount) + let fiveHourResetDate = sessionCounts.fiveHourCount >= historyCounts.fiveHourCount + ? sessionCounts.fiveHourResetDate + : historyCounts.fiveHourResetDate // Get weekly count from stats-cache.json (may lag behind) - var weeklyCount = todayMessages // Start with today from history + var weeklyCount = todayMessages // Start with today's count if FileManager.default.fileExists(atPath: statsCachePath) { do { let statsData = try Data(contentsOf: URL(fileURLWithPath: statsCachePath)) @@ -190,6 +211,72 @@ final class ClaudeUsageManager: ObservableObject { .reduce(0) { $0 + $1.messageCount } } + /// Scans session JSONL files in ~/.claude/projects/ for user messages + private func computeFromSessions() -> (fiveHourCount: Int, fiveHourResetDate: Date?, todayCount: Int) { + let fm = FileManager.default + guard fm.fileExists(atPath: projectsPath), + let enumerator = fm.enumerator(atPath: projectsPath) else { + return (0, nil, 0) + } + + let now = Date() + let fiveHoursAgo = now.addingTimeInterval(-5 * 3600) + let startOfDay = Calendar.current.startOfDay(for: now) + let todayPrefix = Self.dateFormatter.string(from: now) + + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + var fiveHourCount = 0 + var todayCount = 0 + var oldestInWindow: Date? + + while let relativePath = enumerator.nextObject() as? String { + guard relativePath.hasSuffix(".jsonl") else { continue } + + let fullPath = "\(projectsPath)/\(relativePath)" + + // Skip files not modified since start of day + guard let attrs = try? fm.attributesOfItem(atPath: fullPath), + let modDate = attrs[.modificationDate] as? Date, + modDate >= startOfDay else { continue } + + guard let data = fm.contents(atPath: fullPath), + let content = String(data: data, encoding: .utf8) else { continue } + + for line in content.components(separatedBy: "\n") { + // Quick pre-filter to avoid JSON parsing on non-user lines + guard !line.isEmpty, line.contains("\"type\":\"user\"") || line.contains("\"type\": \"user\"") else { + continue + } + // Skip tool-result continuations — these are internal API round-trips, not real messages + if line.contains("\"tool_result\"") { continue } + + guard let lineData = line.data(using: .utf8), + let entry = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any], + entry["type"] as? String == "user", + let timestamp = entry["timestamp"] as? String else { continue } + + // Quick prefix check before full ISO parse + guard timestamp.hasPrefix(todayPrefix) else { continue } + + guard let date = isoFormatter.date(from: timestamp) else { continue } + + todayCount += 1 + + if date >= fiveHoursAgo { + fiveHourCount += 1 + if oldestInWindow == nil || date < oldestInWindow! { + oldestInWindow = date + } + } + } + } + + let resetDate = oldestInWindow?.addingTimeInterval(5 * 3600) + return (fiveHourCount, resetDate, todayCount) + } + /// Parses history.jsonl and returns (fiveHourCount, fiveHourResetDate, todayCount) private func computeFromHistory() -> (fiveHourCount: Int, fiveHourResetDate: Date?, todayCount: Int) { guard FileManager.default.fileExists(atPath: historyPath), diff --git a/Barik/Widgets/ClaudeUsage/ClaudeUsageWidget.swift b/Barik/Widgets/ClaudeUsage/ClaudeUsageWidget.swift index d8afd2f..5694f73 100644 --- a/Barik/Widgets/ClaudeUsage/ClaudeUsageWidget.swift +++ b/Barik/Widgets/ClaudeUsage/ClaudeUsageWidget.swift @@ -17,41 +17,45 @@ struct ClaudeUsageWidget: View { } var body: some View { - ZStack { - // Filled ring — splits from bottom, fills both sides equally - Circle() - .trim(from: 0.5 - min(percentage, 1.0) / 2, to: 0.5 + min(percentage, 1.0) / 2) - .stroke(ringColor, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) - .rotationEffect(.degrees(90)) - .animation(.easeOut(duration: 0.3), value: percentage) + Group { + if usageManager.usageData.isAvailable { + ZStack { + // Filled ring — splits from bottom, fills both sides equally + Circle() + .trim(from: 0.5 - min(percentage, 1.0) / 2, to: 0.5 + min(percentage, 1.0) / 2) + .stroke(ringColor, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(90)) + .animation(.easeOut(duration: 0.3), value: percentage) - // Claude icon in center - Image("ClaudeIcon") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - } - .frame(width: 28, height: 28) - .foregroundStyle(.foregroundOutside) - .shadow(color: .foregroundShadowOutside, radius: 3) - .experimentalConfiguration(cornerRadius: 15) - .frame(maxHeight: .infinity) - .background(.black.opacity(0.001)) - .background( - GeometryReader { geometry in - Color.clear - .onAppear { - widgetFrame = geometry.frame(in: .global) + // Claude icon in center + Image("ClaudeIcon") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + .frame(width: 28, height: 28) + .foregroundStyle(.foregroundOutside) + .shadow(color: .foregroundShadowOutside, radius: 3) + .experimentalConfiguration(cornerRadius: 15) + .frame(maxHeight: .infinity) + .background(.black.opacity(0.001)) + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + widgetFrame = geometry.frame(in: .global) + } + .onChange(of: geometry.frame(in: .global)) { _, newFrame in + widgetFrame = newFrame + } } - .onChange(of: geometry.frame(in: .global)) { _, newFrame in - widgetFrame = newFrame + ) + .onTapGesture { + MenuBarPopup.show(rect: widgetFrame, id: "claude-usage") { + ClaudeUsagePopup() + .environmentObject(configProvider) } - } - ) - .onTapGesture { - MenuBarPopup.show(rect: widgetFrame, id: "claude-usage") { - ClaudeUsagePopup() - .environmentObject(configProvider) + } } } .onAppear { From de321cea06b914dc0341210301d3f53490631403 Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Mon, 2 Feb 2026 15:03:31 -0800 Subject: [PATCH 28/30] feat: use Anthropic usage API with deferred keychain access Replace local file scanning with Anthropic's OAuth usage API for accurate rate limit data. Defer keychain permission to explicit user action via "Allow Access" button in popup. Co-Authored-By: Claude Opus 4.5 --- .../ClaudeUsage/ClaudeUsageManager.swift | 373 +++++------------- .../ClaudeUsage/ClaudeUsagePopup.swift | 84 ++-- .../ClaudeUsage/ClaudeUsageWidget.swift | 66 ++-- 3 files changed, 177 insertions(+), 346 deletions(-) diff --git a/Barik/Widgets/ClaudeUsage/ClaudeUsageManager.swift b/Barik/Widgets/ClaudeUsage/ClaudeUsageManager.swift index 7450fa0..677c75d 100644 --- a/Barik/Widgets/ClaudeUsage/ClaudeUsageManager.swift +++ b/Barik/Widgets/ClaudeUsage/ClaudeUsageManager.swift @@ -1,55 +1,43 @@ import Foundation +import Security import SwiftUI // MARK: - Data Models -struct ClaudeStatsCache: Codable { - let version: Int - let lastComputedDate: String - let dailyActivity: [DailyActivity] - let modelUsage: [String: ModelUsage] - let totalSessions: Int - let totalMessages: Int - - struct DailyActivity: Codable { - let date: String - let messageCount: Int - let sessionCount: Int - let toolCallCount: Int - } - - struct ModelUsage: Codable { - let inputTokens: Int - let outputTokens: Int - let cacheReadInputTokens: Int - let cacheCreationInputTokens: Int - } -} - -struct HistoryEntry: Codable { - let display: String? - let timestamp: Double - let project: String? -} - struct ClaudeUsageData { - var fiveHourCount: Int = 0 - var fiveHourLimit: Int = 80 var fiveHourPercentage: Double = 0 var fiveHourResetDate: Date? - var weeklyCount: Int = 0 - var weeklyLimit: Int = 500 var weeklyPercentage: Double = 0 var weeklyResetDate: Date? - var todayMessages: Int = 0 - var plan: String = "Pro" var lastUpdated: Date = Date() var isAvailable: Bool = false } +private struct UsageResponse: Codable { + let fiveHour: UsageBucket? + let sevenDay: UsageBucket? + let sevenDaySonnet: UsageBucket? + + enum CodingKeys: String, CodingKey { + case fiveHour = "five_hour" + case sevenDay = "seven_day" + case sevenDaySonnet = "seven_day_sonnet" + } + + struct UsageBucket: Codable { + let utilization: Double + let resetsAt: String + + enum CodingKeys: String, CodingKey { + case utilization + case resetsAt = "resets_at" + } + } +} + // MARK: - Manager @MainActor @@ -57,43 +45,26 @@ final class ClaudeUsageManager: ObservableObject { static let shared = ClaudeUsageManager() @Published private(set) var usageData = ClaudeUsageData() + @Published private(set) var isConnected: Bool = false - private var statsWatchSource: DispatchSourceFileSystemObject? - private var statsFileDescriptor: CInt = -1 - private var historyWatchSource: DispatchSourceFileSystemObject? - private var historyFileDescriptor: CInt = -1 private var refreshTimer: Timer? - + private var cachedCredentials: (accessToken: String, plan: String)? private var currentConfig: ConfigData = [:] - private let statsCachePath: String - private let historyPath: String - private let projectsPath: String + private static let connectedKey = "claude-usage-connected" - private init() { - let home = FileManager.default.homeDirectoryForCurrentUser.path - statsCachePath = "\(home)/.claude/stats-cache.json" - historyPath = "\(home)/.claude/history.jsonl" - projectsPath = "\(home)/.claude/projects" - } + private init() {} func startUpdating(config: ConfigData) { currentConfig = config - fetchData() - startWatching(path: statsCachePath, source: &statsWatchSource, descriptor: &statsFileDescriptor) - startWatching(path: historyPath, source: &historyWatchSource, descriptor: &historyFileDescriptor) - // Periodically rescan session files since we can't watch them all individually - refreshTimer?.invalidate() - refreshTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in - Task { @MainActor in - self?.fetchData() - } + + // If user previously granted access, try to connect silently + if UserDefaults.standard.bool(forKey: Self.connectedKey) { + connectAndFetch() } } func stopUpdating() { - stopWatching(source: &statsWatchSource, descriptor: &statsFileDescriptor) - stopWatching(source: &historyWatchSource, descriptor: &historyFileDescriptor) refreshTimer?.invalidate() refreshTimer = nil } @@ -102,251 +73,95 @@ final class ClaudeUsageManager: ObservableObject { fetchData() } - // MARK: - File Watching + /// Called when user explicitly clicks "Allow Access" in the popup. + /// Triggers the macOS Keychain permission dialog. + func requestAccess() { + connectAndFetch() + } - private func startWatching( - path: String, - source: inout DispatchSourceFileSystemObject?, - descriptor: inout CInt - ) { - stopWatching(source: &source, descriptor: &descriptor) + private func connectAndFetch() { + guard let creds = readKeychainCredentials() else { + isConnected = false + cachedCredentials = nil + UserDefaults.standard.set(false, forKey: Self.connectedKey) + return + } - descriptor = open(path, O_EVTONLY) - if descriptor == -1 { return } + cachedCredentials = creds + isConnected = true + UserDefaults.standard.set(true, forKey: Self.connectedKey) + fetchData() - let newSource = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: descriptor, - eventMask: [.write, .extend], - queue: DispatchQueue.global() - ) - newSource.setEventHandler { [weak self] in + refreshTimer?.invalidate() + refreshTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in Task { @MainActor in self?.fetchData() } } - let fd = descriptor - newSource.setCancelHandler { - if fd != -1 { close(fd) } - } - newSource.resume() - source = newSource - } - - private func stopWatching( - source: inout DispatchSourceFileSystemObject?, - descriptor: inout CInt - ) { - source?.cancel() - source = nil - descriptor = -1 } // MARK: - Data Fetching private func fetchData() { - let config = currentConfig - let fiveHourLimit = config["five-hour-limit"]?.intValue ?? 80 - let weeklyLimit = config["weekly-limit"]?.intValue ?? 500 - let plan = config["plan"]?.stringValue ?? "Pro" - - // Get counts from session JSONL files (captures all sessions including Conductor agents) - let sessionCounts = computeFromSessions() - // Fall back to history.jsonl for direct CLI usage - let historyCounts = computeFromHistory() - - // Use whichever source reports more messages (session files are the superset) - let fiveHourCount = max(sessionCounts.fiveHourCount, historyCounts.fiveHourCount) - let todayMessages = max(sessionCounts.todayCount, historyCounts.todayCount) - let fiveHourResetDate = sessionCounts.fiveHourCount >= historyCounts.fiveHourCount - ? sessionCounts.fiveHourResetDate - : historyCounts.fiveHourResetDate - - // Get weekly count from stats-cache.json (may lag behind) - var weeklyCount = todayMessages // Start with today's count - if FileManager.default.fileExists(atPath: statsCachePath) { - do { - let statsData = try Data(contentsOf: URL(fileURLWithPath: statsCachePath)) - let stats = try JSONDecoder().decode(ClaudeStatsCache.self, from: statsData) - - let todayString = Self.dateFormatter.string(from: Date()) - - // Add prior days this week from stats-cache - weeklyCount += computeWeeklyCount(from: stats.dailyActivity, excludingToday: todayString) - } catch { - print("ClaudeUsageManager: Error reading stats-cache: \(error)") - } - } + guard let creds = cachedCredentials else { return } - var data = ClaudeUsageData() - data.fiveHourCount = fiveHourCount - data.fiveHourLimit = fiveHourLimit - data.fiveHourPercentage = fiveHourLimit > 0 ? Double(fiveHourCount) / Double(fiveHourLimit) : 0 - data.fiveHourResetDate = fiveHourResetDate + let plan = currentConfig["plan"]?.stringValue ?? creds.plan - data.weeklyCount = weeklyCount - data.weeklyLimit = weeklyLimit - data.weeklyPercentage = weeklyLimit > 0 ? Double(weeklyCount) / Double(weeklyLimit) : 0 - data.weeklyResetDate = nextWeeklyReset() + Task { + guard let response = await fetchUsageFromAPI(token: creds.accessToken) else { return } - data.todayMessages = todayMessages - data.plan = plan - data.lastUpdated = Date() - data.isAvailable = todayMessages > 0 || FileManager.default.fileExists(atPath: statsCachePath) + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - usageData = data - } - - // MARK: - Computations - - private func computeWeeklyCount(from dailyActivity: [ClaudeStatsCache.DailyActivity], excludingToday todayString: String) -> Int { - let now = Date() - var calendar = Calendar.current - calendar.firstWeekday = 2 // Monday - guard let startOfWeek = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: now)) else { - return 0 - } - let startString = Self.dateFormatter.string(from: startOfWeek) - return dailyActivity - .filter { $0.date >= startString && $0.date != todayString } - .reduce(0) { $0 + $1.messageCount } - } - - /// Scans session JSONL files in ~/.claude/projects/ for user messages - private func computeFromSessions() -> (fiveHourCount: Int, fiveHourResetDate: Date?, todayCount: Int) { - let fm = FileManager.default - guard fm.fileExists(atPath: projectsPath), - let enumerator = fm.enumerator(atPath: projectsPath) else { - return (0, nil, 0) - } - - let now = Date() - let fiveHoursAgo = now.addingTimeInterval(-5 * 3600) - let startOfDay = Calendar.current.startOfDay(for: now) - let todayPrefix = Self.dateFormatter.string(from: now) - - let isoFormatter = ISO8601DateFormatter() - isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - - var fiveHourCount = 0 - var todayCount = 0 - var oldestInWindow: Date? - - while let relativePath = enumerator.nextObject() as? String { - guard relativePath.hasSuffix(".jsonl") else { continue } - - let fullPath = "\(projectsPath)/\(relativePath)" - - // Skip files not modified since start of day - guard let attrs = try? fm.attributesOfItem(atPath: fullPath), - let modDate = attrs[.modificationDate] as? Date, - modDate >= startOfDay else { continue } - - guard let data = fm.contents(atPath: fullPath), - let content = String(data: data, encoding: .utf8) else { continue } - - for line in content.components(separatedBy: "\n") { - // Quick pre-filter to avoid JSON parsing on non-user lines - guard !line.isEmpty, line.contains("\"type\":\"user\"") || line.contains("\"type\": \"user\"") else { - continue - } - // Skip tool-result continuations — these are internal API round-trips, not real messages - if line.contains("\"tool_result\"") { continue } - - guard let lineData = line.data(using: .utf8), - let entry = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any], - entry["type"] as? String == "user", - let timestamp = entry["timestamp"] as? String else { continue } - - // Quick prefix check before full ISO parse - guard timestamp.hasPrefix(todayPrefix) else { continue } + var data = ClaudeUsageData() + data.fiveHourPercentage = (response.fiveHour?.utilization ?? 0) / 100 + data.fiveHourResetDate = response.fiveHour.flatMap { isoFormatter.date(from: $0.resetsAt) } + data.weeklyPercentage = (response.sevenDay?.utilization ?? 0) / 100 + data.weeklyResetDate = response.sevenDay.flatMap { isoFormatter.date(from: $0.resetsAt) } + data.plan = plan.capitalized + data.lastUpdated = Date() + data.isAvailable = true - guard let date = isoFormatter.date(from: timestamp) else { continue } - - todayCount += 1 - - if date >= fiveHoursAgo { - fiveHourCount += 1 - if oldestInWindow == nil || date < oldestInWindow! { - oldestInWindow = date - } - } - } + self.usageData = data } - - let resetDate = oldestInWindow?.addingTimeInterval(5 * 3600) - return (fiveHourCount, resetDate, todayCount) } - /// Parses history.jsonl and returns (fiveHourCount, fiveHourResetDate, todayCount) - private func computeFromHistory() -> (fiveHourCount: Int, fiveHourResetDate: Date?, todayCount: Int) { - guard FileManager.default.fileExists(atPath: historyPath), - let data = FileManager.default.contents(atPath: historyPath), - let content = String(data: data, encoding: .utf8) else { - return (0, nil, 0) - } + // MARK: - API - let now = Date() - let fiveHoursAgo = now.addingTimeInterval(-5 * 3600) - let fiveHoursAgoMs = fiveHoursAgo.timeIntervalSince1970 * 1000 - let startOfDay = Calendar.current.startOfDay(for: now) - let startOfDayMs = startOfDay.timeIntervalSince1970 * 1000 + private func fetchUsageFromAPI(token: String) async -> UsageResponse? { + guard let url = URL(string: "https://api.anthropic.com/api/oauth/usage") else { return nil } - let decoder = JSONDecoder() - let lines = content.components(separatedBy: "\n") + var request = URLRequest(url: url) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta") + request.timeoutInterval = 5 - var fiveHourCount = 0 - var todayCount = 0 - var oldestInWindow: Double? - - for line in lines.reversed() { - guard !line.isEmpty, - let lineData = line.data(using: .utf8), - let entry = try? decoder.decode(HistoryEntry.self, from: lineData) else { - continue - } - - // Stop once we're past both windows - if entry.timestamp < startOfDayMs && entry.timestamp < fiveHoursAgoMs { - break - } + guard let (data, response) = try? await URLSession.shared.data(for: request), + let http = response as? HTTPURLResponse, + http.statusCode == 200 else { return nil } - if entry.timestamp >= fiveHoursAgoMs { - fiveHourCount += 1 - if oldestInWindow == nil || entry.timestamp < (oldestInWindow ?? .infinity) { - oldestInWindow = entry.timestamp - } - } - - if entry.timestamp >= startOfDayMs { - todayCount += 1 - } - } - - let resetDate = oldestInWindow.map { - Date(timeIntervalSince1970: $0 / 1000).addingTimeInterval(5 * 3600) - } - - return (fiveHourCount, resetDate, todayCount) + return try? JSONDecoder().decode(UsageResponse.self, from: data) } - private func nextWeeklyReset() -> Date { - var calendar = Calendar.current - calendar.firstWeekday = 2 - let now = Date() - var components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: now) - components.weekday = 2 - components.hour = 0 - components.minute = 0 - var resetDate = calendar.date(from: components) ?? now - if resetDate <= now { - resetDate = calendar.date(byAdding: .weekOfYear, value: 1, to: resetDate) ?? now + // MARK: - Keychain + + private func readKeychainCredentials() -> (accessToken: String, plan: String)? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "Claude Code-credentials", + kSecReturnData as String: true, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, + let data = result as? Data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let oauth = json["claudeAiOauth"] as? [String: Any], + let token = oauth["accessToken"] as? String else { + return nil } - return resetDate + let plan = oauth["subscriptionType"] as? String ?? "pro" + return (token, plan) } - - private static let dateFormatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd" - return f - }() } diff --git a/Barik/Widgets/ClaudeUsage/ClaudeUsagePopup.swift b/Barik/Widgets/ClaudeUsage/ClaudeUsagePopup.swift index 26ba329..495f717 100644 --- a/Barik/Widgets/ClaudeUsage/ClaudeUsagePopup.swift +++ b/Barik/Widgets/ClaudeUsage/ClaudeUsagePopup.swift @@ -6,15 +6,15 @@ struct ClaudeUsagePopup: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - if usageManager.usageData.isAvailable { + if !usageManager.isConnected { + connectView + } else if usageManager.usageData.isAvailable { titleBar Divider().background(Color.white.opacity(0.2)) rateLimitSection( icon: "clock", title: "5-Hour Window", percentage: usageManager.usageData.fiveHourPercentage, - count: usageManager.usageData.fiveHourCount, - limit: usageManager.usageData.fiveHourLimit, resetDate: usageManager.usageData.fiveHourResetDate, resetPrefix: "Resets in" ) @@ -23,17 +23,13 @@ struct ClaudeUsagePopup: View { icon: "calendar", title: "Weekly", percentage: usageManager.usageData.weeklyPercentage, - count: usageManager.usageData.weeklyCount, - limit: usageManager.usageData.weeklyLimit, resetDate: usageManager.usageData.weeklyResetDate, resetPrefix: "Resets" ) Divider().background(Color.white.opacity(0.2)) - todaySection - Divider().background(Color.white.opacity(0.2)) footerSection } else { - unavailableView + loadingView } } .frame(width: 280) @@ -79,8 +75,6 @@ struct ClaudeUsagePopup: View { icon: String, title: String, percentage: Double, - count: Int, - limit: Int, resetDate: Date?, resetPrefix: String ) -> some View { @@ -146,21 +140,6 @@ struct ClaudeUsagePopup: View { } } - // MARK: - Today Section - - private var todaySection: some View { - HStack { - Text("Today") - .font(.system(size: 13, weight: .medium)) - .opacity(0.7) - Spacer() - Text("\(usageManager.usageData.todayMessages) messages") - .font(.system(size: 13, weight: .semibold)) - } - .padding(.horizontal, 20) - .padding(.vertical, 14) - } - // MARK: - Footer private var footerSection: some View { @@ -199,21 +178,62 @@ struct ClaudeUsagePopup: View { return "\(minutes / 60)h ago" } - // MARK: - Unavailable + // MARK: - Connect - private var unavailableView: some View { - VStack(spacing: 12) { + private var connectView: some View { + VStack(spacing: 14) { Image("ClaudeIcon") .resizable() .scaledToFit() .frame(width: 28, height: 28) - .opacity(0.4) - Text("Claude Code not found") - .font(.system(size: 13, weight: .medium)) - Text("Install Claude Code to see usage stats") + + Text("Claude Usage") + .font(.system(size: 14, weight: .semibold)) + + Text("View your Claude rate limit usage directly in the menu bar.") .font(.system(size: 11)) .opacity(0.5) .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Button(action: { + usageManager.requestAccess() + }) { + Text("Allow Access") + .font(.system(size: 12, weight: .medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.borderedProminent) + .tint(Color(red: 0.89, green: 0.45, blue: 0.29)) + .onHover { hovering in + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + + Text("Reads credentials from your Claude Code keychain entry.") + .font(.system(size: 10)) + .opacity(0.3) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 30) + .padding(.vertical, 30) + } + + // MARK: - Loading + + private var loadingView: some View { + VStack(spacing: 12) { + ProgressView() + .scaleEffect(0.8) + Text("Loading usage data...") + .font(.system(size: 11)) + .opacity(0.5) } .frame(maxWidth: .infinity) .padding(40) diff --git a/Barik/Widgets/ClaudeUsage/ClaudeUsageWidget.swift b/Barik/Widgets/ClaudeUsage/ClaudeUsageWidget.swift index 5694f73..6cc7b9a 100644 --- a/Barik/Widgets/ClaudeUsage/ClaudeUsageWidget.swift +++ b/Barik/Widgets/ClaudeUsage/ClaudeUsageWidget.swift @@ -17,45 +17,41 @@ struct ClaudeUsageWidget: View { } var body: some View { - Group { + ZStack { if usageManager.usageData.isAvailable { - ZStack { - // Filled ring — splits from bottom, fills both sides equally - Circle() - .trim(from: 0.5 - min(percentage, 1.0) / 2, to: 0.5 + min(percentage, 1.0) / 2) - .stroke(ringColor, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) - .rotationEffect(.degrees(90)) - .animation(.easeOut(duration: 0.3), value: percentage) + Circle() + .trim(from: 0.5 - min(percentage, 1.0) / 2, to: 0.5 + min(percentage, 1.0) / 2) + .stroke(ringColor, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(90)) + .animation(.easeOut(duration: 0.3), value: percentage) + } - // Claude icon in center - Image("ClaudeIcon") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - } - .frame(width: 28, height: 28) - .foregroundStyle(.foregroundOutside) - .shadow(color: .foregroundShadowOutside, radius: 3) - .experimentalConfiguration(cornerRadius: 15) - .frame(maxHeight: .infinity) - .background(.black.opacity(0.001)) - .background( - GeometryReader { geometry in - Color.clear - .onAppear { - widgetFrame = geometry.frame(in: .global) - } - .onChange(of: geometry.frame(in: .global)) { _, newFrame in - widgetFrame = newFrame - } + Image("ClaudeIcon") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + .frame(width: 28, height: 28) + .foregroundStyle(.foregroundOutside) + .shadow(color: .foregroundShadowOutside, radius: 3) + .experimentalConfiguration(cornerRadius: 15) + .frame(maxHeight: .infinity) + .background(.black.opacity(0.001)) + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + widgetFrame = geometry.frame(in: .global) } - ) - .onTapGesture { - MenuBarPopup.show(rect: widgetFrame, id: "claude-usage") { - ClaudeUsagePopup() - .environmentObject(configProvider) + .onChange(of: geometry.frame(in: .global)) { _, newFrame in + widgetFrame = newFrame } - } + } + ) + .onTapGesture { + MenuBarPopup.show(rect: widgetFrame, id: "claude-usage") { + ClaudeUsagePopup() + .environmentObject(configProvider) } } .onAppear { From 3e2644c961d0d308b8445cc22e5978e59361fd95 Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Tue, 3 Feb 2026 11:02:49 -0800 Subject: [PATCH 29/30] feat: add pomodoro timer widget Adds a new default.pomodoro widget with countdown timer, work/break/long break phases, circular progress ring popup, session tracking, adjustable durations via in-popup settings, and macOS notifications on phase changes. Co-Authored-By: Claude Opus 4.5 --- Barik/Views/MenuBarView.swift | 4 + Barik/Widgets/Pomodoro/PomodoroManager.swift | 153 ++++++++++++++++ Barik/Widgets/Pomodoro/PomodoroPopup.swift | 181 +++++++++++++++++++ Barik/Widgets/Pomodoro/PomodoroWidget.swift | 65 +++++++ README.md | 2 + 5 files changed, 405 insertions(+) create mode 100644 Barik/Widgets/Pomodoro/PomodoroManager.swift create mode 100644 Barik/Widgets/Pomodoro/PomodoroPopup.swift create mode 100644 Barik/Widgets/Pomodoro/PomodoroWidget.swift diff --git a/Barik/Views/MenuBarView.swift b/Barik/Views/MenuBarView.swift index d3b24b4..ec545cf 100644 --- a/Barik/Views/MenuBarView.swift +++ b/Barik/Views/MenuBarView.swift @@ -99,6 +99,10 @@ struct MenuBarView: View { ClaudeUsageWidget() .environmentObject(config) + case "default.pomodoro": + PomodoroWidget() + .environmentObject(config) + case "spacer": Spacer().frame(minWidth: 50, maxWidth: .infinity) diff --git a/Barik/Widgets/Pomodoro/PomodoroManager.swift b/Barik/Widgets/Pomodoro/PomodoroManager.swift new file mode 100644 index 0000000..f348aae --- /dev/null +++ b/Barik/Widgets/Pomodoro/PomodoroManager.swift @@ -0,0 +1,153 @@ +import Combine +import Foundation +import UserNotifications + +enum PomodoroPhase: String { + case idle + case working = "Working" + case onBreak = "Break" + case onLongBreak = "Long Break" +} + +class PomodoroManager: ObservableObject { + static let shared = PomodoroManager() + + @Published var phase: PomodoroPhase = .idle + @Published var timeRemaining: Int = 0 + @Published var completedSessions: Int = 0 + @Published var isPaused: Bool = false + + @Published var workDuration: Int = 25 + @Published var breakDuration: Int = 5 + @Published var longBreakDuration: Int = 15 + @Published var sessionsBeforeLongBreak: Int = 4 + + var isActive: Bool { phase != .idle } + + var totalDuration: Int { + switch phase { + case .idle: return workDuration * 60 + case .working: return workDuration * 60 + case .onBreak: return breakDuration * 60 + case .onLongBreak: return longBreakDuration * 60 + } + } + + var progress: Double { + guard totalDuration > 0 else { return 0 } + return Double(totalDuration - timeRemaining) / Double(totalDuration) + } + + private var timerCancellable: AnyCancellable? + + init() { + requestNotificationPermission() + } + + func start() { + phase = .working + timeRemaining = workDuration * 60 + isPaused = false + startTimer() + } + + func pause() { + isPaused = true + timerCancellable?.cancel() + timerCancellable = nil + } + + func resume() { + isPaused = false + startTimer() + } + + func reset() { + timerCancellable?.cancel() + timerCancellable = nil + phase = .idle + timeRemaining = 0 + completedSessions = 0 + isPaused = false + } + + func skip() { + timerCancellable?.cancel() + timerCancellable = nil + transitionToNextPhase() + } + + private func startTimer() { + timerCancellable = Timer.publish(every: 1, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.tick() + } + } + + private func tick() { + guard timeRemaining > 0 else { return } + timeRemaining -= 1 + if timeRemaining == 0 { + timerCancellable?.cancel() + timerCancellable = nil + sendNotification() + transitionToNextPhase() + } + } + + private func transitionToNextPhase() { + switch phase { + case .idle: + break + case .working: + completedSessions += 1 + if completedSessions >= sessionsBeforeLongBreak { + phase = .onLongBreak + timeRemaining = longBreakDuration * 60 + completedSessions = 0 + } else { + phase = .onBreak + timeRemaining = breakDuration * 60 + } + isPaused = false + startTimer() + case .onBreak, .onLongBreak: + phase = .working + timeRemaining = workDuration * 60 + isPaused = false + startTimer() + } + } + + private func requestNotificationPermission() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } + } + + private func sendNotification() { + let content = UNMutableNotificationContent() + switch phase { + case .working: + content.title = "Pomodoro Complete" + content.body = "Time for a break!" + case .onBreak, .onLongBreak: + content.title = "Break Over" + content.body = "Ready to focus?" + case .idle: + return + } + content.sound = .default + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil + ) + UNUserNotificationCenter.current().add(request) + } + + var formattedTime: String { + let minutes = timeRemaining / 60 + let seconds = timeRemaining % 60 + return String(format: "%d:%02d", minutes, seconds) + } +} diff --git a/Barik/Widgets/Pomodoro/PomodoroPopup.swift b/Barik/Widgets/Pomodoro/PomodoroPopup.swift new file mode 100644 index 0000000..d8b99a5 --- /dev/null +++ b/Barik/Widgets/Pomodoro/PomodoroPopup.swift @@ -0,0 +1,181 @@ +import SwiftUI + +struct PomodoroPopup: View { + @ObservedObject private var manager = PomodoroManager.shared + @State private var showSettings = false + + var body: some View { + VStack(spacing: 16) { + // Header with settings toggle + ZStack { + Text(manager.isActive ? manager.phase.rawValue : "Pomodoro") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(phaseColor) + HStack { + Spacer() + Button { + withAnimation(.easeInOut(duration: 0.2)) { + showSettings.toggle() + } + } label: { + Image(systemName: showSettings ? "xmark" : "gearshape") + .font(.system(size: 11)) + .foregroundStyle(.gray) + } + .buttonStyle(.plain) + } + } + + if showSettings { + settingsView + } else { + timerView + } + } + .frame(width: 180) + .padding(24) + } + + @ViewBuilder + private var timerView: some View { + // Circular progress ring + ZStack { + Circle() + .stroke(Color.gray.opacity(0.3), lineWidth: 6) + Circle() + .trim(from: 0, to: manager.isActive ? manager.progress : 0) + .stroke( + phaseColor, + style: StrokeStyle(lineWidth: 6, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.linear(duration: 1), value: manager.progress) + + VStack(spacing: 2) { + Text(manager.isActive ? manager.formattedTime : "0:00") + .font(.system(size: 20, weight: .bold, design: .monospaced)) + .foregroundStyle(.white) + .contentTransition(.numericText()) + .animation(.default, value: manager.timeRemaining) + + if manager.isPaused { + Text("Paused") + .font(.system(size: 10)) + .foregroundStyle(.gray) + } + } + } + .frame(width: 80, height: 80) + + // Session dots + if manager.isActive || manager.completedSessions > 0 { + HStack(spacing: 6) { + ForEach(0.. Void) -> some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 14)) + .foregroundStyle(color) + .frame(width: 32, height: 32) + .background(color.opacity(0.15)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + + private func durationStepper(_ label: String, value: Binding, range: ClosedRange) -> some View { + HStack { + Text(label) + .font(.system(size: 12)) + .foregroundStyle(.gray) + Spacer() + HStack(spacing: 8) { + Button { + if value.wrappedValue > range.lowerBound { + value.wrappedValue -= 1 + } + } label: { + Image(systemName: "minus") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 22, height: 22) + .background(Color.gray.opacity(0.3)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + + Text("\(value.wrappedValue)") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(.white) + .frame(width: 28) + + Button { + if value.wrappedValue < range.upperBound { + value.wrappedValue += 1 + } + } label: { + Image(systemName: "plus") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 22, height: 22) + .background(Color.gray.opacity(0.3)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + } + } +} diff --git a/Barik/Widgets/Pomodoro/PomodoroWidget.swift b/Barik/Widgets/Pomodoro/PomodoroWidget.swift new file mode 100644 index 0000000..682a7c3 --- /dev/null +++ b/Barik/Widgets/Pomodoro/PomodoroWidget.swift @@ -0,0 +1,65 @@ +import SwiftUI + +struct PomodoroWidget: View { + @EnvironmentObject var configProvider: ConfigProvider + var config: ConfigData { configProvider.config } + + @ObservedObject private var manager = PomodoroManager.shared + + @State private var rect: CGRect = .zero + + var body: some View { + HStack(spacing: 4) { + Image(systemName: manager.isActive ? "timer" : "timer") + .font(.system(size: 12)) + .foregroundStyle(phaseColor) + + if manager.isActive { + Text(manager.formattedTime) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(.foregroundOutside) + .contentTransition(.numericText()) + .animation(.default, value: manager.timeRemaining) + } + } + .shadow(color: .foregroundShadowOutside, radius: 3) + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + rect = geometry.frame(in: .global) + } + .onChange(of: geometry.frame(in: .global)) { _, newState in + rect = newState + } + } + ) + .experimentalConfiguration(cornerRadius: 15) + .frame(maxHeight: .infinity) + .background(.black.opacity(0.001)) + .onTapGesture { + MenuBarPopup.show(rect: rect, id: "pomodoro") { + PomodoroPopup() + } + } + .onAppear { + applyConfig() + } + } + + private var phaseColor: Color { + switch manager.phase { + case .idle: return .foregroundOutside + case .working: return .red + case .onBreak: return .green + case .onLongBreak: return .blue + } + } + + private func applyConfig() { + manager.workDuration = config["work-duration"]?.intValue ?? 25 + manager.breakDuration = config["break-duration"]?.intValue ?? 5 + manager.longBreakDuration = config["long-break-duration"]?.intValue ?? 15 + manager.sessionsBeforeLongBreak = config["sessions-before-long-break"]?.intValue ?? 4 + } +} diff --git a/README.md b/README.md index 8e57e39..24334cf 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A fork of [barik](https://github.com/mocki-toki/barik) with active improvements - **Drag-and-drop widget reordering** - Drag widgets directly in the menu bar to rearrange them, order persists to config - **Claude Code usage widget** - Track API usage in real-time with a donut ring indicator that changes color at thresholds, plus a popup with rolling window and weekly stats +- **Pomodoro timer widget** - Built-in pomodoro timer with work/break/long break phases, circular progress ring, session tracking, adjustable durations, and macOS notifications - **Drastically reduced CPU usage** - Replaced polling with event-driven notifications for music playback and space changes - **Enhanced calendar popup** - Day view with event selection and detailed event information - **Click to open music player** - Click on album art, song title, or artist in the now playing popup to open Spotify or Apple Music @@ -97,6 +98,7 @@ theme = "system" # system, light, dark displayed = [ # widgets on menu bar "default.spaces", "spacer", + "default.pomodoro", "default.nowplaying", "default.network", "default.battery", From 040132ad742b0ef15812d051bfc1bda93f39822f Mon Sep 17 00:00:00 2001 From: Daniel Walker Date: Tue, 3 Feb 2026 11:31:37 -0800 Subject: [PATCH 30/30] fix: show pomodoro notifications while app is active Add UNUserNotificationCenterDelegate so macOS displays banner notifications even when Barik is in the foreground. Co-Authored-By: Claude Opus 4.5 --- Barik/Widgets/Pomodoro/PomodoroManager.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Barik/Widgets/Pomodoro/PomodoroManager.swift b/Barik/Widgets/Pomodoro/PomodoroManager.swift index f348aae..ff7b7f4 100644 --- a/Barik/Widgets/Pomodoro/PomodoroManager.swift +++ b/Barik/Widgets/Pomodoro/PomodoroManager.swift @@ -9,9 +9,21 @@ enum PomodoroPhase: String { case onLongBreak = "Long Break" } +private class PomodoroNotificationDelegate: NSObject, UNUserNotificationCenterDelegate { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound]) + } +} + class PomodoroManager: ObservableObject { static let shared = PomodoroManager() + private let notificationDelegate = PomodoroNotificationDelegate() + @Published var phase: PomodoroPhase = .idle @Published var timeRemaining: Int = 0 @Published var completedSessions: Int = 0 @@ -41,6 +53,7 @@ class PomodoroManager: ObservableObject { private var timerCancellable: AnyCancellable? init() { + UNUserNotificationCenter.current().delegate = notificationDelegate requestNotificationPermission() }