refactor: split StatusBarController into smaller components (#445)

This commit is contained in:
Peter Steinberger 2025-07-21 14:53:32 +02:00 committed by GitHub
parent a2acfce159
commit 2b6df96689
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 384 additions and 123 deletions

View file

@ -0,0 +1,126 @@
import Foundation
import Network
import OSLog
/// Monitors network connectivity and provides notifications about network status changes.
///
/// This service wraps Apple's Network framework to provide a simplified interface
/// for monitoring network reachability and connectivity status.
@MainActor
@Observable
final class NetworkMonitor {
// MARK: - Properties
/// Shared instance for network monitoring
static let shared = NetworkMonitor()
/// Current network connection status
private(set) var isConnected = true
/// Whether the current connection is expensive (e.g., cellular)
private(set) var isExpensive = false
/// Whether the current connection is constrained (e.g., Low Data Mode)
private(set) var isConstrained = false
/// The type of interface used for the current connection
private(set) var connectionType: NWInterface.InterfaceType?
// MARK: - Private Properties
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "sh.vibetunnel.NetworkMonitor")
private let logger = Logger(subsystem: BundleIdentifiers.main, category: "NetworkMonitor")
// MARK: - Initialization
private init() {
setupMonitor()
}
// MARK: - Public Methods
/// Starts monitoring network connectivity
func startMonitoring() {
monitor.start(queue: queue)
logger.info("Network monitoring started")
}
/// Stops monitoring network connectivity
func stopMonitoring() {
monitor.cancel()
logger.info("Network monitoring stopped")
}
// MARK: - Private Methods
private func setupMonitor() {
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor in
self?.updateNetworkStatus(path)
}
}
}
@MainActor
private func updateNetworkStatus(_ path: NWPath) {
let wasConnected = isConnected
isConnected = path.status == .satisfied
isExpensive = path.isExpensive
isConstrained = path.isConstrained
// Determine connection type
if path.usesInterfaceType(.wifi) {
connectionType = .wifi
} else if path.usesInterfaceType(.cellular) {
connectionType = .cellular
} else if path.usesInterfaceType(.wiredEthernet) {
connectionType = .wiredEthernet
} else {
connectionType = nil
}
// Log status changes
if wasConnected != isConnected {
if isConnected {
logger.info("Network connected via \(self.connectionTypeString)")
} else {
logger.warning("Network disconnected")
}
}
// Post notification for interested observers
NotificationCenter.default.post(
name: .networkStatusChanged,
object: self,
userInfo: ["isConnected": isConnected]
)
}
/// Human-readable description of the connection type
var connectionTypeString: String {
switch connectionType {
case .wifi:
return "Wi-Fi"
case .cellular:
return "Cellular"
case .wiredEthernet:
return "Ethernet"
case .loopback:
return "Loopback"
case .other:
return "Other"
case nil:
return "Unknown"
@unknown default:
return "Unknown"
}
}
}
// MARK: - Notification Names
extension Notification.Name {
static let networkStatusChanged = Notification.Name("sh.vibetunnel.networkStatusChanged")
}

View file

@ -9,9 +9,72 @@ import Network
enum NetworkUtility {
/// Get the primary IPv4 address of the local machine
static func getLocalIPAddress() -> String? {
// Check common network interfaces in priority order
let preferredInterfaces = ["en0", "en1", "en2", "en3", "en4", "en5"]
for interfaceName in preferredInterfaces {
if let address = getIPAddress(for: interfaceName) {
return address
}
}
// Fallback: check any "en" interface
return getIPAddressForAnyInterface()
}
/// Get IP address for a specific interface
private static func getIPAddress(for interfaceName: String) -> String? {
var ifaddr: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&ifaddr) == 0 else { return nil }
defer { freeifaddrs(ifaddr) }
var ptr = ifaddr
while ptr != nil {
defer { ptr = ptr?.pointee.ifa_next }
guard let interface = ptr?.pointee else { continue }
// Skip loopback addresses
if interface.ifa_flags & UInt32(IFF_LOOPBACK) != 0 { continue }
// Check for IPv4 interface
let addrFamily = interface.ifa_addr.pointee.sa_family
if addrFamily == UInt8(AF_INET) {
// Get interface name
let name = String(cString: interface.ifa_name)
if name == interfaceName {
var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
if getnameinfo(
interface.ifa_addr,
socklen_t(interface.ifa_addr.pointee.sa_len),
&hostname,
socklen_t(hostname.count),
nil,
0,
NI_NUMERICHOST
) == 0 {
let ipAddress = String(cString: &hostname)
// Prefer addresses that look like local network addresses
if ipAddress.hasPrefix("192.168.") ||
ipAddress.hasPrefix("10.") ||
ipAddress.hasPrefix("172.") {
return ipAddress
}
}
}
}
}
return nil
}
/// Get IP address for any available interface
private static func getIPAddressForAnyInterface() -> String? {
var address: String?
// Create a socket to determine the local IP address
var ifaddr: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&ifaddr) == 0 else { return nil }
@ -32,8 +95,7 @@ enum NetworkUtility {
// Get interface name
let name = String(cString: interface.ifa_name)
// Prefer en0 (typically Wi-Fi on Mac) or en1 (sometimes Ethernet)
// But accept any non-loopback IPv4 address
// Accept any non-loopback IPv4 address from "en" interfaces
if name.hasPrefix("en") {
var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
if getnameinfo(

View file

@ -1,19 +0,0 @@
import Foundation
// MARK: - Status Bar Visual Indicators
extension StatusBarController {
/// Format session counts with minimalist style
func formatSessionIndicator(activeCount: Int, idleCount: Int) -> String {
let totalCount = activeCount + idleCount
guard totalCount > 0 else { return "" }
if activeCount == 0 {
return String(totalCount)
} else if activeCount == totalCount {
return "\(activeCount)"
} else {
return "\(activeCount) | \(idleCount)"
}
}
}

View file

@ -1,6 +1,5 @@
import AppKit
import Combine
import Network
import Observation
import SwiftUI
@ -15,6 +14,7 @@ final class StatusBarController: NSObject {
private var statusItem: NSStatusItem?
let menuManager: StatusBarMenuManager
private var iconController: StatusBarIconController?
// MARK: - Dependencies
@ -30,8 +30,6 @@ final class StatusBarController: NSObject {
private var cancellables = Set<AnyCancellable>()
private var updateTimer: Timer?
private let monitor = NWPathMonitor()
private let monitorQueue = DispatchQueue(label: "vibetunnel.network.monitor")
private var hasNetworkAccess = true
// MARK: - Initialization
@ -60,7 +58,7 @@ final class StatusBarController: NSObject {
setupStatusItem()
setupMenuManager()
setupObservers()
startNetworkMonitoring()
setupNetworkMonitoring()
}
// MARK: - Setup
@ -82,6 +80,9 @@ final class StatusBarController: NSObject {
button.setAccessibilityRole(.button)
button.setAccessibilityHelp("Shows terminal sessions and server information")
// Initialize the icon controller
iconController = StatusBarIconController(button: button)
updateStatusItemDisplay()
}
}
@ -125,14 +126,26 @@ final class StatusBarController: NSObject {
}
}
private func startNetworkMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor in
self?.hasNetworkAccess = path.status == .satisfied
self?.updateStatusItemDisplay()
}
}
monitor.start(queue: monitorQueue)
private func setupNetworkMonitoring() {
// Start the network monitor
NetworkMonitor.shared.startMonitoring()
// Listen for network status changes
NotificationCenter.default.addObserver(
self,
selector: #selector(networkStatusChanged(_:)),
name: .networkStatusChanged,
object: nil
)
// Set initial state
hasNetworkAccess = NetworkMonitor.shared.isConnected
}
@objc
private func networkStatusChanged(_ notification: Notification) {
hasNetworkAccess = NetworkMonitor.shared.isConnected
updateStatusItemDisplay()
}
// MARK: - Display Updates
@ -143,95 +156,16 @@ final class StatusBarController: NSObject {
// Update accessibility title (might have changed due to debug/dev server state)
button.setAccessibilityTitle(getAppDisplayName())
// Update icon based on server status only
let iconName = serverManager.isRunning ? "menubar" : "menubar.inactive"
if let image = NSImage(named: iconName) {
image.isTemplate = true
button.image = image
} else {
// Fallback to regular icon
if let image = NSImage(named: "menubar") {
image.isTemplate = true
button.image = image
button.alphaValue = serverManager.isRunning ? 1.0 : 0.5
}
}
// Update icon and title using the dedicated controller
iconController?.update(serverManager: serverManager, sessionMonitor: sessionMonitor)
// Update session count display
let sessions = sessionMonitor.sessions.values.filter(\.isRunning)
let activeSessions = sessions.filter { session in
// Check if session has recent activity (Claude Code or other custom actions)
if let activityStatus = session.activityStatus?.specificStatus?.status {
return !activityStatus.isEmpty
}
return false
}
let activeCount = activeSessions.count
let totalCount = sessions.count
let idleCount = totalCount - activeCount
// Format the title with minimalist indicator
let indicator = formatSessionIndicator(activeCount: activeCount, idleCount: idleCount)
button.title = indicator.isEmpty ? "" : " " + indicator
// Update tooltip
updateTooltip()
}
private func updateTooltip() {
guard let button = statusItem?.button else { return }
var tooltipParts: [String] = []
// Server status
if serverManager.isRunning {
let bindAddress = serverManager.bindAddress
if bindAddress == "127.0.0.1" {
tooltipParts.append("Server: 127.0.0.1:\(serverManager.port)")
} else if let localIP = NetworkUtility.getLocalIPAddress() {
tooltipParts.append("Server: \(localIP):\(serverManager.port)")
}
// ngrok status
if ngrokService.isActive, let publicURL = ngrokService.publicUrl {
tooltipParts.append("ngrok: \(publicURL)")
}
// Tailscale status
if tailscaleService.isRunning, let hostname = tailscaleService.tailscaleHostname {
tooltipParts.append("Tailscale: \(hostname)")
}
} else {
tooltipParts.append("Server stopped")
}
// Session info
let sessions = sessionMonitor.sessions.values.filter(\.isRunning)
if !sessions.isEmpty {
let activeSessions = sessions.filter { session in
if let activityStatus = session.activityStatus?.specificStatus?.status {
return !activityStatus.isEmpty
}
return false
}
let idleCount = sessions.count - activeSessions.count
if !activeSessions.isEmpty {
if idleCount > 0 {
tooltipParts
.append(
"\(activeSessions.count) active, \(idleCount) idle session\(sessions.count == 1 ? "" : "s")"
)
} else {
tooltipParts.append("\(activeSessions.count) active session\(activeSessions.count == 1 ? "" : "s")")
}
} else {
tooltipParts.append("\(sessions.count) idle session\(sessions.count == 1 ? "" : "s")")
}
}
button.toolTip = tooltipParts.joined(separator: "\n")
// Update tooltip using the dedicated provider
button.toolTip = TooltipProvider.generateTooltip(
serverManager: serverManager,
ngrokService: ngrokService,
tailscaleService: tailscaleService,
sessionMonitor: sessionMonitor
)
}
// MARK: - Click Handling
@ -291,7 +225,7 @@ final class StatusBarController: NSObject {
deinit {
MainActor.assumeIsolated {
updateTimer?.invalidate()
NotificationCenter.default.removeObserver(self)
}
monitor.cancel()
}
}

View file

@ -0,0 +1,81 @@
import AppKit
import Foundation
/// Manages the visual appearance of the status bar item's button.
///
/// This class is responsible for updating the icon and title of the status bar button
/// based on the application's state, such as server status and active sessions.
@MainActor
final class StatusBarIconController {
private weak var button: NSStatusBarButton?
/// Initializes the icon controller with the status bar button.
/// - Parameter button: The `NSStatusBarButton` to manage.
init(button: NSStatusBarButton?) {
self.button = button
}
/// Updates the entire visual state of the status bar button.
///
/// - Parameters:
/// - serverManager: The manager for the VibeTunnel server.
/// - sessionMonitor: The monitor for active terminal sessions.
func update(serverManager: ServerManager, sessionMonitor: SessionMonitor) {
guard let button else { return }
// Update icon based on server status
updateIcon(isServerRunning: serverManager.isRunning)
// Update session count display
let sessions = sessionMonitor.sessions.values.filter(\.isRunning)
let activeSessions = sessions.filter { session in
if let activityStatus = session.activityStatus?.specificStatus?.status {
return !activityStatus.isEmpty
}
return false
}
let activeCount = activeSessions.count
let totalCount = sessions.count
let idleCount = totalCount - activeCount
let indicator = formatSessionIndicator(activeCount: activeCount, idleCount: idleCount)
button.title = indicator.isEmpty ? "" : " " + indicator
}
/// Updates the icon of the status bar button based on the server's running state.
/// - Parameter isServerRunning: A boolean indicating if the server is running.
private func updateIcon(isServerRunning: Bool) {
guard let button else { return }
let iconName = isServerRunning ? "menubar" : "menubar.inactive"
if let image = NSImage(named: iconName) {
image.isTemplate = true
button.image = image
} else {
// Fallback to regular icon with alpha adjustment
if let image = NSImage(named: "menubar") {
image.isTemplate = true
button.image = image
button.alphaValue = isServerRunning ? 1.0 : 0.5
}
}
}
/// Formats the session count indicator with a minimalist style.
/// - Parameters:
/// - activeCount: The number of active sessions.
/// - idleCount: The number of idle sessions.
/// - Returns: A formatted string representing the session counts.
private func formatSessionIndicator(activeCount: Int, idleCount: Int) -> String {
let totalCount = activeCount + idleCount
guard totalCount > 0 else { return "" }
if activeCount == 0 {
return String(totalCount)
} else if activeCount == totalCount {
return "\(activeCount)"
} else {
return "\(activeCount) | \(idleCount)"
}
}
}

View file

@ -0,0 +1,77 @@
import Foundation
/// A provider for generating tooltip strings for the status bar item.
///
/// This component centralizes the logic for creating the detailed tooltip,
/// combining information from various services into a single, formatted string.
enum TooltipProvider {
/// Generates the tooltip string based on the current state of the application.
///
/// - Parameters:
/// - serverManager: The manager for the VibeTunnel server.
/// - ngrokService: The service for managing ngrok tunnels.
/// - tailscaleService: The service for managing Tailscale connectivity.
/// - sessionMonitor: The monitor for active terminal sessions.
/// - Returns: A formatted string to be used as the tooltip for the status bar item.
@MainActor
static func generateTooltip(
serverManager: ServerManager,
ngrokService: NgrokService,
tailscaleService: TailscaleService,
sessionMonitor: SessionMonitor
) -> String {
var tooltipParts: [String] = []
// Server status
if serverManager.isRunning {
let bindAddress = serverManager.bindAddress
if bindAddress == "127.0.0.1" {
tooltipParts.append("Server: 127.0.0.1:\(serverManager.port)")
} else if let localIP = NetworkUtility.getLocalIPAddress() {
tooltipParts.append("Server: \(localIP):\(serverManager.port)")
} else {
// Fallback when no local IP is found
tooltipParts.append("Server: 0.0.0.0:\(serverManager.port)")
}
// ngrok status
if ngrokService.isActive, let publicURL = ngrokService.publicUrl {
tooltipParts.append("ngrok: \(publicURL)")
}
// Tailscale status
if tailscaleService.isRunning, let hostname = tailscaleService.tailscaleHostname {
tooltipParts.append("Tailscale: \(hostname)")
}
} else {
tooltipParts.append("Server stopped")
}
// Session info
let sessions = sessionMonitor.sessions.values.filter(\.isRunning)
if !sessions.isEmpty {
let activeSessions = sessions.filter { session in
if let activityStatus = session.activityStatus?.specificStatus?.status {
return !activityStatus.isEmpty
}
return false
}
let idleCount = sessions.count - activeSessions.count
if !activeSessions.isEmpty {
if idleCount > 0 {
tooltipParts
.append(
"\(activeSessions.count) active, \(idleCount) idle session\(sessions.count == 1 ? "" : "s")"
)
} else {
tooltipParts.append("\(activeSessions.count) active session\(activeSessions.count == 1 ? "" : "s")")
}
} else {
tooltipParts.append("\(sessions.count) idle session\(sessions.count == 1 ? "" : "s")")
}
}
return tooltipParts.joined(separator: "\n")
}
}