mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +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 {
|
enum NetworkUtility {
|
||||||
/// Get the primary IPv4 address of the local machine
|
/// Get the primary IPv4 address of the local machine
|
||||||
static func getLocalIPAddress() -> String? {
|
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?
|
var address: String?
|
||||||
|
|
||||||
// Create a socket to determine the local IP address
|
|
||||||
var ifaddr: UnsafeMutablePointer<ifaddrs>?
|
var ifaddr: UnsafeMutablePointer<ifaddrs>?
|
||||||
|
|
||||||
guard getifaddrs(&ifaddr) == 0 else { return nil }
|
guard getifaddrs(&ifaddr) == 0 else { return nil }
|
||||||
|
|
@ -32,8 +95,7 @@ enum NetworkUtility {
|
||||||
// Get interface name
|
// Get interface name
|
||||||
let name = String(cString: interface.ifa_name)
|
let name = String(cString: interface.ifa_name)
|
||||||
|
|
||||||
// Prefer en0 (typically Wi-Fi on Mac) or en1 (sometimes Ethernet)
|
// Accept any non-loopback IPv4 address from "en" interfaces
|
||||||
// But accept any non-loopback IPv4 address
|
|
||||||
if name.hasPrefix("en") {
|
if name.hasPrefix("en") {
|
||||||
var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||||
if getnameinfo(
|
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 AppKit
|
||||||
import Combine
|
import Combine
|
||||||
import Network
|
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|
@ -15,6 +14,7 @@ final class StatusBarController: NSObject {
|
||||||
|
|
||||||
private var statusItem: NSStatusItem?
|
private var statusItem: NSStatusItem?
|
||||||
let menuManager: StatusBarMenuManager
|
let menuManager: StatusBarMenuManager
|
||||||
|
private var iconController: StatusBarIconController?
|
||||||
|
|
||||||
// MARK: - Dependencies
|
// MARK: - Dependencies
|
||||||
|
|
||||||
|
|
@ -30,8 +30,6 @@ final class StatusBarController: NSObject {
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var updateTimer: Timer?
|
private var updateTimer: Timer?
|
||||||
private let monitor = NWPathMonitor()
|
|
||||||
private let monitorQueue = DispatchQueue(label: "vibetunnel.network.monitor")
|
|
||||||
private var hasNetworkAccess = true
|
private var hasNetworkAccess = true
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
@ -60,7 +58,7 @@ final class StatusBarController: NSObject {
|
||||||
setupStatusItem()
|
setupStatusItem()
|
||||||
setupMenuManager()
|
setupMenuManager()
|
||||||
setupObservers()
|
setupObservers()
|
||||||
startNetworkMonitoring()
|
setupNetworkMonitoring()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Setup
|
// MARK: - Setup
|
||||||
|
|
@ -82,6 +80,9 @@ final class StatusBarController: NSObject {
|
||||||
button.setAccessibilityRole(.button)
|
button.setAccessibilityRole(.button)
|
||||||
button.setAccessibilityHelp("Shows terminal sessions and server information")
|
button.setAccessibilityHelp("Shows terminal sessions and server information")
|
||||||
|
|
||||||
|
// Initialize the icon controller
|
||||||
|
iconController = StatusBarIconController(button: button)
|
||||||
|
|
||||||
updateStatusItemDisplay()
|
updateStatusItemDisplay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -125,14 +126,26 @@ final class StatusBarController: NSObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startNetworkMonitoring() {
|
private func setupNetworkMonitoring() {
|
||||||
monitor.pathUpdateHandler = { [weak self] path in
|
// Start the network monitor
|
||||||
Task { @MainActor in
|
NetworkMonitor.shared.startMonitoring()
|
||||||
self?.hasNetworkAccess = path.status == .satisfied
|
|
||||||
self?.updateStatusItemDisplay()
|
// Listen for network status changes
|
||||||
}
|
NotificationCenter.default.addObserver(
|
||||||
}
|
self,
|
||||||
monitor.start(queue: monitorQueue)
|
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
|
// MARK: - Display Updates
|
||||||
|
|
@ -143,95 +156,16 @@ final class StatusBarController: NSObject {
|
||||||
// Update accessibility title (might have changed due to debug/dev server state)
|
// Update accessibility title (might have changed due to debug/dev server state)
|
||||||
button.setAccessibilityTitle(getAppDisplayName())
|
button.setAccessibilityTitle(getAppDisplayName())
|
||||||
|
|
||||||
// Update icon based on server status only
|
// Update icon and title using the dedicated controller
|
||||||
let iconName = serverManager.isRunning ? "menubar" : "menubar.inactive"
|
iconController?.update(serverManager: serverManager, sessionMonitor: sessionMonitor)
|
||||||
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 session count display
|
// Update tooltip using the dedicated provider
|
||||||
let sessions = sessionMonitor.sessions.values.filter(\.isRunning)
|
button.toolTip = TooltipProvider.generateTooltip(
|
||||||
let activeSessions = sessions.filter { session in
|
serverManager: serverManager,
|
||||||
// Check if session has recent activity (Claude Code or other custom actions)
|
ngrokService: ngrokService,
|
||||||
if let activityStatus = session.activityStatus?.specificStatus?.status {
|
tailscaleService: tailscaleService,
|
||||||
return !activityStatus.isEmpty
|
sessionMonitor: sessionMonitor
|
||||||
}
|
)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Click Handling
|
// MARK: - Click Handling
|
||||||
|
|
@ -291,7 +225,7 @@ final class StatusBarController: NSObject {
|
||||||
deinit {
|
deinit {
|
||||||
MainActor.assumeIsolated {
|
MainActor.assumeIsolated {
|
||||||
updateTimer?.invalidate()
|
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