mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-18 13:25:52 +00:00
refactor: split StatusBarController into smaller components (#445)
This commit is contained in:
parent
a2acfce159
commit
2b6df96689
6 changed files with 384 additions and 123 deletions
126
mac/VibeTunnel/Core/Services/NetworkMonitor.swift
Normal file
126
mac/VibeTunnel/Core/Services/NetworkMonitor.swift
Normal 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")
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
77
mac/VibeTunnel/Presentation/Components/TooltipProvider.swift
Normal file
77
mac/VibeTunnel/Presentation/Components/TooltipProvider.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue