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/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/Helpers/SystemUIHelper.swift b/Barik/Helpers/SystemUIHelper.swift
new file mode 100644
index 0000000..155e99d
--- /dev/null
+++ b/Barik/Helpers/SystemUIHelper.swift
@@ -0,0 +1,66 @@
+import AppKit
+import Foundation
+
+/// Helper for triggering macOS system UI elements
+final class SystemUIHelper {
+
+ /// Opens the macOS Notification Center by simulating Ctrl+Option+N keypress
+ static func openNotificationCenter() {
+ // Simulate Ctrl+Option+N keyboard shortcut
+ let keyCode: CGKeyCode = 45 // 'n' key
+ let flags: CGEventFlags = [.maskControl, .maskAlternate]
+
+ // 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 = """
+ 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/MenuBarPopup/MenuBarPopup.swift b/Barik/MenuBarPopup/MenuBarPopup.swift
index ef7a111..6a8d7c3 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 {
@@ -73,13 +72,10 @@ class MenuBarPopup {
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
panel.contentView = NSHostingView(
rootView:
- ZStack {
- MenuBarPopupView {
- 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)
@@ -91,13 +87,10 @@ class MenuBarPopup {
} else {
panel.contentView = NSHostingView(
rootView:
- ZStack {
- MenuBarPopupView {
- 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 {
@@ -108,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/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/MenuBarPopup/MenuBarPopupView.swift b/Barik/MenuBarPopup/MenuBarPopupView.swift
index 8cd889b..81c8f76 100644
--- a/Barik/MenuBarPopup/MenuBarPopupView.swift
+++ b/Barik/MenuBarPopup/MenuBarPopupView.swift
@@ -3,6 +3,7 @@ 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() }
@@ -21,7 +22,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,16 +31,21 @@ struct MenuBarPopupView: View {
}
}
+ // 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
+ }
+
var body: some View {
ZStack(alignment: .topTrailing) {
content
.background(Color.black)
.cornerRadius(((1.0 - animationValue) * 1) + 40)
- .padding(.top, foregroundHeight + 5)
- .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 {
@@ -135,23 +142,29 @@ 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
+
+ // 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 newRight > screenWidth {
- return screenWidth - newRight
- } else if newLeft < 0 {
- return -newLeft
+ if rightEdge > screenWidth {
+ xOffset -= (rightEdge - screenWidth)
+ } else if leftEdge < 0 {
+ xOffset -= leftEdge
}
- return 0
+
+ return xOffset
}
var computedYOffset: CGFloat {
- return viewFrame.height / 2
+ return 0
}
}
diff --git a/Barik/Views/MenuBarView.swift b/Barik/Views/MenuBarView.swift
index 31081ab..7c48370 100644
--- a/Barik/Views/MenuBarView.swift
+++ b/Barik/Views/MenuBarView.swift
@@ -52,13 +52,17 @@ struct MenuBarView: View {
BatteryWidget().environmentObject(config)
case "default.time":
- TimeWidget(calendarManager: CalendarManager(configProvider: config))
+ TimeWidget(configProvider: config)
.environmentObject(config)
case "default.nowplaying":
NowPlayingWidget()
.environmentObject(config)
+ case "default.weather":
+ WeatherWidget()
+ .environmentObject(config)
+
case "spacer":
Spacer().frame(minWidth: 50, maxWidth: .infinity)
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/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..10a09b7 100644
--- a/Barik/Widgets/Network/NetworkViewModel.swift
+++ b/Barik/Widgets/Network/NetworkViewModel.swift
@@ -19,9 +19,31 @@ 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
+ CLLocationManagerDelegate, CWEventDelegate
{
// States for Wi‑Fi and Ethernet obtained via NWPathMonitor.
@@ -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.
@@ -54,11 +81,13 @@ final class NetworkStatusViewModel: NSObject, ObservableObject,
private var timer: Timer?
private let locationManager = CLLocationManager()
+ private var wifiClient: CWWiFiClient?
override init() {
super.init()
locationManager.delegate = self
locationManager.requestWhenInUseAuthorization()
+ checkWiFiPowerState()
startNetworkMonitoring()
startWiFiMonitoring()
}
@@ -125,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() {
@@ -170,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(
@@ -178,4 +269,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()
+ }
+ }
}
diff --git a/Barik/Widgets/NowPlaying/NowPlayingManager.swift b/Barik/Widgets/NowPlaying/NowPlayingManager.swift
index 61e1c61..7cbdb87 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
}
}
@@ -228,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 {
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/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..6efba17 100644
--- a/Barik/Widgets/Spaces/SpacesViewModel.swift
+++ b/Barik/Widgets/Spaces/SpacesViewModel.swift
@@ -4,8 +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 {
@@ -26,16 +28,120 @@ class SpacesViewModel: ObservableObject {
}
private func startMonitoring() {
- timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) {
- [weak self] _ in
+ if let provider = provider {
+ if provider.isEventBased {
+ startMonitoringEventBasedProvider()
+ } else {
+ startMonitoringWithWorkspaceNotifications()
+ }
+ }
+ }
+
+ private func stopMonitoring() {
+ if let provider = provider {
+ if provider.isEventBased {
+ stopMonitoringEventBasedProvider()
+ } else {
+ stopMonitoringWorkspaceNotifications()
+ }
+ }
+ }
+
+ 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 stopMonitoring() {
- timer?.invalidate()
- timer = nil
+ private func stopMonitoringWorkspaceNotifications() {
+ let notificationCenter = NSWorkspace.shared.notificationCenter
+ for observer in workspaceObservers {
+ notificationCenter.removeObserver(observer)
+ }
+ workspaceObservers.removeAll()
+ }
+
+ 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() {
diff --git a/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift b/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift
index 91d3e0e..2ef7bdf 100644
--- a/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift
+++ b/Barik/Widgets/Spaces/Yabai/YabaiProvider.swift
@@ -1,9 +1,171 @@
+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 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
+ 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 (SOCK_STREAM for nc -U compatibility)
+ socketFileDescriptor = socket(AF_UNIX, SOCK_STREAM, 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 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 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)
+ }
+ }
+ }
+
+ 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() {
+ 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))
+ }
+ }
+ }
+ }
+
+ // MARK: - Original Provider Methods
+
private func runYabaiCommand(arguments: [String]) -> Data? {
let process = Process()
process.executableURL = URL(fileURLWithPath: executablePath)
diff --git a/Barik/Widgets/Time+Calendar/CalendarManager.swift b/Barik/Widgets/Time+Calendar/CalendarManager.swift
index e7ea454..0d6b85b 100644
--- a/Barik/Widgets/Time+Calendar/CalendarManager.swift
+++ b/Barik/Widgets/Time+Calendar/CalendarManager.swift
@@ -4,56 +4,98 @@ 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()
- private var timer: Timer?
+ @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() {
- 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
+ )
+ 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() {
- 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?.fetchAllCalendars()
+ self?.fetchTodaysEvents()
+ self?.fetchTomorrowsEvents()
+ self?.fetchNextEvent()
+ }
}
private func requestAccess() {
eventStore.requestFullAccessToEvents { [weak self] granted, error in
if granted && error == nil {
+ self?.fetchAllCalendars()
self?.fetchTodaysEvents()
self?.fetchTomorrowsEvents()
self?.fetchNextEvent()
@@ -67,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 7918dbb..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,15 +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) }
+ horizontal: { CalendarHorizontalPopup(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"]?
@@ -315,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)
@@ -340,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 {
@@ -357,6 +380,970 @@ 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 = 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)
+
+ private var scrollableHeight: CGFloat {
+ CGFloat(visibleHours) * hourHeight
+ }
+
+ 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
+ }
+ }
+ )
+
+ // 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)
+ }
+ }
+}
+
+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"
+ 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: 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 (scrollable)
+ ScrollView {
+ 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 var eventsOverlay: some View {
+ let calendar = Calendar.current
+ let visibleEvents = timedEvents.filter { event in
+ let hour = calendar.component(.hour, from: event.startDate)
+ return hour >= 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
+
+ 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 (scrollable)
+ ScrollView {
+ 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)
+ }
+ }
+ }
+
+ 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 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)"
+ }
+}
+
+// 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
@@ -381,5 +1368,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 bb8ac60..e94e5d3 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"
@@ -17,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()
@@ -54,13 +59,18 @@ struct TimeWidget: View {
)
.experimentalConfiguration(cornerRadius: 15)
.frame(maxHeight: .infinity)
- .background(.black.opacity(0.001))
+ .contentShape(Rectangle())
.monospacedDigit()
.onTapGesture {
- MenuBarPopup.show(rect: rect, id: "calendar") {
- CalendarPopup(
- calendarManager: calendarManager,
- configProvider: configProvider)
+ switch clickAction {
+ case "notification-center":
+ SystemUIHelper.openNotificationCenter()
+ default:
+ MenuBarPopup.show(rect: rect, id: "calendar") {
+ CalendarPopup(
+ calendarManager: calendarManager,
+ configProvider: configProvider)
+ }
}
}
}
@@ -97,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)
}
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
new file mode 100644
index 0000000..f9dfcca
--- /dev/null
+++ b/Barik/Widgets/Weather/WeatherWidget.swift
@@ -0,0 +1,373 @@
+import SwiftUI
+import CoreLocation
+
+/// Weather widget that displays current weather using Open-Meteo API
+struct WeatherWidget: View {
+ @EnvironmentObject var configProvider: ConfigProvider
+ @ObservedObject private var weatherManager = WeatherManager.shared
+
+ @State private var widgetFrame: CGRect = .zero
+
+ 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))
+ .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: "weather") {
+ WeatherPopup()
+ }
+ }
+ .onAppear {
+ weatherManager.startUpdating()
+ }
+ }
+}
+
+// MARK: - Weather Data Models
+
+struct CurrentWeather {
+ let temperature: String
+ let symbolName: String
+ 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
+ }
+}
+
+struct OpenMeteoCurrentWeather: Codable {
+ let temperature: Double
+ 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
+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?
+
+ 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
+
+ // 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&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
+ return
+ }
+
+ 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)
+
+ self.currentWeather = CurrentWeather(
+ temperature: "\(temp)°F",
+ 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)")
+ }
+ isLoading = false
+ }
+ }
+
+ /// Maps Open-Meteo weather codes to SF Symbols
+ 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
+ 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()
+ }
+ }
+}
+
+// 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 {
+ WeatherWidget()
+ }.frame(width: 100, height: 50)
+ }
+}
diff --git a/README.md b/README.md
index 491a885..e4dfebb 100644
--- a/README.md
+++ b/README.md
@@ -1,25 +1,22 @@
-**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-but-better
+
+A fork of [barik](https://github.com/mocki-toki/barik) with active improvements and new features.
+
+## Improvements over barik
+
+- **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
+
+---
+
**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.