feat: Powerful Mac menu bar with rich view and session tracking (#176)

This commit is contained in:
Peter Steinberger 2025-07-01 14:54:30 +01:00 committed by GitHub
parent acf91e228d
commit a7d5648c78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 2110 additions and 252 deletions

View file

@ -136,12 +136,6 @@ jobs:
fi
done
- name: Download web build artifacts
uses: actions/download-artifact@v4
with:
name: web-build-${{ github.sha }}
path: web/
- name: Resolve Dependencies (once)
run: |
cd ios

View file

@ -124,12 +124,18 @@ final class SessionMonitor {
self.lastError = nil
self.lastFetch = Date()
logger.debug("Fetched \(sessionsArray.count) sessions, \(sessionsDict.values.filter { $0.isRunning }.count) running")
logger
.debug(
"Fetched \(sessionsArray.count) sessions, \(sessionsDict.values.count { $0.isRunning }) running"
)
// Debug: Log session details
for session in sessionsArray {
let pidStr = session.pid.map { String($0) } ?? "nil"
logger.debug("Session \(session.id): status=\(session.status), isRunning=\(session.isRunning), pid=\(pidStr)")
logger
.debug(
"Session \(session.id): status=\(session.status), isRunning=\(session.isRunning), pid=\(pidStr)"
)
}
// Update WindowTracker

View file

@ -57,11 +57,14 @@ final class WindowTracker {
) {
logger.info("Registering window for session: \(sessionID), terminal: \(terminalApp.rawValue)")
// Give the terminal some time to create the window
// For Terminal.app and iTerm2 with explicit window/tab info, register immediately
if (terminalApp == .terminal && tabReference != nil) ||
(terminalApp == .iTerm2 && tabID != nil)
{
// These terminals provide explicit window/tab IDs, so we can register immediately
Task {
try? await Task.sleep(for: .seconds(1.0))
try? await Task.sleep(for: .milliseconds(500))
// Find the most recently created window for this terminal
if let windowInfo = findWindow(
for: terminalApp,
sessionID: sessionID,
@ -71,11 +74,49 @@ final class WindowTracker {
mapLock.withLock {
sessionWindowMap[sessionID] = windowInfo
}
logger.info("Successfully registered window \(windowInfo.windowID) for session \(sessionID)")
} else {
logger.warning("Could not find window for session \(sessionID)")
logger
.info(
"Successfully registered window \(windowInfo.windowID) for session \(sessionID) with explicit ID"
)
}
}
return
}
// For other terminals, use progressive delays to find the window
Task {
// Try multiple times with increasing delays
let delays: [Double] = [0.5, 1.0, 2.0, 3.0]
for (index, delay) in delays.enumerated() {
try? await Task.sleep(for: .seconds(delay))
// Try to find the window
if let windowInfo = findWindow(
for: terminalApp,
sessionID: sessionID,
tabReference: tabReference,
tabID: tabID
) {
mapLock.withLock {
sessionWindowMap[sessionID] = windowInfo
}
logger
.info(
"Successfully registered window \(windowInfo.windowID) for session \(sessionID) after \(index + 1) attempts"
)
return
}
logger
.debug("Window registration attempt \(index + 1) failed for session \(sessionID), trying again...")
}
logger.warning("Could not find window for session \(sessionID) after all attempts")
// Final fallback: try scanning
await scanForSession(sessionID)
}
}
/// Unregisters a window for a session.
@ -108,8 +149,14 @@ final class WindowTracker {
// Check if this is a terminal application
guard let terminal = Terminal.allCases.first(where: { term in
// Match by process name or app name
ownerName == term.processName || ownerName == term.rawValue
// Match by process name, app name, or bundle identifier parts
let processNameMatch = ownerName == term.processName ||
ownerName.lowercased() == term.processName.lowercased()
let appNameMatch = ownerName == term.rawValue
let bundleMatch = ownerName.contains(term.displayName) ||
term.bundleIdentifier.contains(ownerName)
return processNameMatch || appNameMatch || bundleMatch
}) else {
return nil
}
@ -157,46 +204,125 @@ final class WindowTracker {
// Filter windows for the specific terminal
let terminalWindows = allWindows.filter { $0.terminalApp == terminal }
// If we have specific tab information, try to match by title or other properties
// For now, return the most recently created window (highest window ID)
guard let latestWindow = terminalWindows.max(by: { $0.windowID < $1.windowID }) else {
// First try to find window by title containing session path or command
// Sessions typically show their working directory in the title
if let sessionInfo = getSessionInfo(for: sessionID) {
let workingDir = sessionInfo.workingDir
let dirName = (workingDir as NSString).lastPathComponent
// Look for windows whose title contains the directory name
if let matchingWindow = terminalWindows.first(where: { window in
if let title = window.title {
return title.contains(dirName) || title.contains(workingDir)
}
return false
}) {
logger.debug("Found window by directory match: \(dirName)")
return createWindowInfo(
from: matchingWindow,
sessionID: sessionID,
terminal: terminal,
tabReference: tabReference,
tabID: tabID
)
}
}
// For Terminal.app and iTerm2 with specific tab/window IDs, use those
if terminal == .terminal, let tabRef = tabReference {
// Extract window ID from tab reference (format: "tab id X of window id Y")
if let windowIDMatch = tabRef.firstMatch(of: /window id (\d+)/),
let windowID = CGWindowID(windowIDMatch.output.1)
{
if let matchingWindow = terminalWindows.first(where: { $0.windowID == windowID }) {
logger.debug("Found Terminal.app window by ID: \(windowID)")
return createWindowInfo(
from: matchingWindow,
sessionID: sessionID,
terminal: terminal,
tabReference: tabReference,
tabID: tabID
)
}
}
}
// If we have a window ID from launch result, use it
if let tabID, terminal == .iTerm2 {
// For iTerm2, tabID contains the window ID string
// Try to match by window title which often includes the window ID
if let matchingWindow = terminalWindows.first(where: { window in
if let title = window.title {
return title.contains(tabID)
}
return false
}) {
logger.debug("Found iTerm2 window by ID in title: \(tabID)")
return createWindowInfo(
from: matchingWindow,
sessionID: sessionID,
terminal: terminal,
tabReference: tabReference,
tabID: tabID
)
}
}
// Fallback: return the most recently created window (highest window ID)
// But only if it was created very recently (within 5 seconds)
if let latestWindow = terminalWindows.max(by: { $0.windowID < $1.windowID }) {
logger.debug("Using most recent window as fallback for session: \(sessionID)")
return createWindowInfo(
from: latestWindow,
sessionID: sessionID,
terminal: terminal,
tabReference: tabReference,
tabID: tabID
)
}
return nil
}
// Create a new WindowInfo with the session information
return WindowInfo(
windowID: latestWindow.windowID,
ownerPID: latestWindow.ownerPID,
/// Helper to create WindowInfo from a found window
private func createWindowInfo(
from window: WindowInfo,
sessionID: String,
terminal: Terminal,
tabReference: String?,
tabID: String?
)
-> WindowInfo
{
WindowInfo(
windowID: window.windowID,
ownerPID: window.ownerPID,
terminalApp: terminal,
sessionID: sessionID,
createdAt: Date(),
tabReference: tabReference,
tabID: tabID,
bounds: latestWindow.bounds,
title: latestWindow.title
bounds: window.bounds,
title: window.title
)
}
/// Get session info from SessionMonitor
private func getSessionInfo(for sessionID: String) -> ServerSessionInfo? {
// Access SessionMonitor to get session details
// This is safe because both are @MainActor
SessionMonitor.shared.sessions[sessionID]
}
// MARK: - Window Focus
/// Focuses the window associated with a session.
func focusWindow(for sessionID: String) {
mapLock.withLock {
guard let windowInfo = sessionWindowMap[sessionID] else {
logger.warning("No window found for session: \(sessionID)")
logger.debug("Available sessions: \(self.sessionWindowMap.keys.joined(separator: ", "))")
// Try to scan for the session one more time
Task {
await scanForSession(sessionID)
// Try focusing again after scan
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.focusWindow(for: sessionID)
}
}
return
}
// First check if we have the window info
let windowInfo = mapLock.withLock { sessionWindowMap[sessionID] }
if let windowInfo {
// We have window info, try to focus it
logger
.info(
"Focusing window for session: \(sessionID), terminal: \(windowInfo.terminalApp.rawValue), windowID: \(windowInfo.windowID)"
@ -211,7 +337,85 @@ final class WindowTracker {
// For other terminals, use standard window focus
focusWindowUsingAccessibility(windowInfo)
}
} else {
// No window info found, try to scan for it
logger.warning("No window found for session: \(sessionID), attempting to locate...")
// Get available sessions for debugging
let availableSessions = mapLock.withLock { Array(sessionWindowMap.keys) }
logger.debug("Currently tracked sessions: \(availableSessions.joined(separator: ", "))")
// Try to find the window immediately (synchronously)
if let sessionInfo = getSessionInfo(for: sessionID) {
// Try to find window using enhanced logic
if let foundWindow = findWindowForSession(sessionID, sessionInfo: sessionInfo) {
mapLock.withLock {
sessionWindowMap[sessionID] = foundWindow
}
logger.info("Found window for session \(sessionID) on demand")
// Recursively call to focus the now-found window
focusWindow(for: sessionID)
return
}
}
// If still not found, scan asynchronously
Task {
await scanForSession(sessionID)
// Try focusing again after scan
try? await Task.sleep(for: .milliseconds(500))
await MainActor.run {
self.focusWindow(for: sessionID)
}
}
}
}
/// Synchronously find a window for a session
private func findWindowForSession(_ sessionID: String, sessionInfo: ServerSessionInfo) -> WindowInfo? {
let allWindows = Self.getAllTerminalWindows()
let workingDir = sessionInfo.workingDir
let dirName = (workingDir as NSString).lastPathComponent
let expandedDir = (workingDir as NSString).expandingTildeInPath
// Look through all windows to find a match
for window in allWindows {
var matchFound = false
if let title = window.title {
// Check for directory name match
if title.contains(dirName) || title.contains(expandedDir) {
matchFound = true
}
// Check for VibeTunnel markers
else if title.contains("vt") || title.contains("vibetunnel") || title.contains("TTY_SESSION_ID") {
matchFound = true
}
// Check for command match
else if let command = sessionInfo.command.first,
!command.isEmpty && title.contains(command)
{
matchFound = true
}
}
if matchFound {
return WindowInfo(
windowID: window.windowID,
ownerPID: window.ownerPID,
terminalApp: window.terminalApp,
sessionID: sessionID,
createdAt: Date(),
tabReference: nil,
tabID: nil,
bounds: window.bounds,
title: window.title
)
}
}
return nil
}
/// Focuses a Terminal.app window/tab.
@ -354,17 +558,47 @@ final class WindowTracker {
private func scanForSession(_ sessionID: String) async {
logger.info("Scanning for window containing session: \(sessionID)")
// Get session info to match by working directory
guard let sessionInfo = getSessionInfo(for: sessionID) else {
logger.warning("No session info found for session: \(sessionID)")
return
}
// Get all terminal windows
let allWindows = Self.getAllTerminalWindows()
let workingDir = sessionInfo.workingDir
let dirName = (workingDir as NSString).lastPathComponent
let expandedDir = (workingDir as NSString).expandingTildeInPath
// Look for windows that might contain this session
// Sessions typically show their ID in the window title
for window in allWindows {
// Check if window title contains session ID
if let title = window.title,
title.contains(sessionID) || title.contains("vt") || title.contains("vibetunnel")
var matchFound = false
var matchReason = ""
// Check if window title contains working directory or session markers
if let title = window.title {
// Check for directory name match (most common)
if title.contains(dirName) || title.contains(expandedDir) {
matchFound = true
matchReason = "directory match: \(dirName)"
}
// Check for VibeTunnel-specific markers
else if title.contains("vt") || title.contains("vibetunnel") || title.contains("TTY_SESSION_ID") {
matchFound = true
matchReason = "VibeTunnel marker in title"
}
// Check if title contains the command being run
else if let command = sessionInfo.command.first,
!command.isEmpty && title.contains(command)
{
logger.info("Found potential window for session \(sessionID): \(title)")
matchFound = true
matchReason = "command match: \(command)"
}
}
if matchFound {
logger.info("Found window for session \(sessionID) by \(matchReason): \(window.title ?? "no title")")
// Create window info for this session
let windowInfo = WindowInfo(
@ -388,7 +622,11 @@ final class WindowTracker {
}
}
logger.debug("Could not find window for session \(sessionID) in \(allWindows.count) terminal windows")
// If no match found, log window titles for debugging
logger.debug("Could not find window for session \(sessionID) (workingDir: \(workingDir))")
for (index, window) in allWindows.enumerated() {
logger.debug("Window \(index): \(window.terminalApp.rawValue) - '\(window.title ?? "no title")'")
}
}
// MARK: - Session Monitoring

View file

@ -0,0 +1,311 @@
import AppKit
import SwiftUI
/// Custom borderless window that appears below the menu bar icon.
///
/// Provides a dropdown-style window for the menu bar application
/// without the standard macOS popover arrow. Handles automatic positioning below
/// the status item, click-outside dismissal, and proper window management.
@MainActor
final class CustomMenuWindow: NSPanel {
private var eventMonitor: Any?
private let hostingController: NSHostingController<AnyView>
private var retainedContentView: AnyView?
private var isEventMonitoringActive = false
/// Closure to be called when window hides
var onHide: (() -> Void)?
init(contentView: some View) {
// Store the content view to prevent deallocation in Release builds
let wrappedView = AnyView(contentView)
self.retainedContentView = wrappedView
// Create content view controller with the wrapped view
hostingController = NSHostingController(rootView: wrappedView)
// Initialize window with appropriate style
super.init(
contentRect: NSRect(x: 0, y: 0, width: 384, height: 400),
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
backing: .buffered,
defer: false
)
// Configure window appearance
isOpaque = false
backgroundColor = .clear
hasShadow = true
level = .popUpMenu
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
isMovableByWindowBackground = false
hidesOnDeactivate = false
isReleasedWhenClosed = false
// Set content view controller
contentViewController = hostingController
// Force the view to load immediately
_ = hostingController.view
// Add visual effect background with rounded corners
if let contentView = contentViewController?.view {
contentView.wantsLayer = true
contentView.layer?.cornerRadius = 12
contentView.layer?.masksToBounds = true
// Add subtle shadow
contentView.shadow = NSShadow()
contentView.shadow?.shadowOffset = NSSize(width: 0, height: -1)
contentView.shadow?.shadowBlurRadius = 12
contentView.shadow?.shadowColor = NSColor.black.withAlphaComponent(0.3)
}
}
func show(relativeTo statusItemButton: NSStatusBarButton) {
// First, make sure the SwiftUI hierarchy has laid itself out
hostingController.view.layoutSubtreeIfNeeded()
// Determine the preferred size based on the content's intrinsic size
let fittingSize = hostingController.view.fittingSize
let preferredSize = NSSize(width: fittingSize.width, height: fittingSize.height)
// Update the panel's content size
setContentSize(preferredSize)
// Get status item frame in screen coordinates
if let statusWindow = statusItemButton.window {
let buttonBounds = statusItemButton.bounds
let buttonFrameInWindow = statusItemButton.convert(buttonBounds, to: nil)
let buttonFrameInScreen = statusWindow.convertToScreen(buttonFrameInWindow)
// Check if the button frame is valid and visible
if buttonFrameInScreen.width > 0, buttonFrameInScreen.height > 0 {
// Calculate optimal position relative to the status bar icon
let targetFrame = calculateOptimalFrame(
relativeTo: buttonFrameInScreen,
preferredSize: preferredSize
)
setFrame(targetFrame, display: false)
} else {
// Fallback: Position at top right of screen
showAtTopRightFallback(withSize: preferredSize)
}
} else {
// Fallback case
showAtTopRightFallback(withSize: preferredSize)
}
// Ensure the hosting controller's view is loaded
_ = hostingController.view
// Display window safely
displayWindowSafely()
}
private func displayWindowSafely() {
alphaValue = 0
// Ensure app is active
NSApp.activate(ignoringOtherApps: true)
// Make the window first responder to enable keyboard navigation
// but don't focus any specific element
makeFirstResponder(self)
// Small delay to ensure window is fully displayed before animation
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(10))
if self.isVisible {
self.animateWindowIn()
self.setupEventMonitoring()
} else {
await self.displayWindowFallback()
}
}
}
private func displayWindowFallback() async {
NSApp.activate(ignoringOtherApps: true)
self.makeKeyAndOrderFront(nil)
try? await Task.sleep(for: .milliseconds(50))
if self.isVisible {
self.animateWindowIn()
self.setupEventMonitoring()
} else {
self.orderFrontRegardless()
self.alphaValue = 1.0
self.setupEventMonitoring()
}
}
private func animateWindowIn() {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.25
context.timingFunction = CAMediaTimingFunction(controlPoints: 0.2, 0.0, 0.2, 1.0)
context.allowsImplicitAnimation = true
self.animator().alphaValue = 1
}
}
private func calculateOptimalFrame(relativeTo statusFrame: NSRect, preferredSize: NSSize) -> NSRect {
guard let screen = NSScreen.main else {
let defaultScreenWidth: CGFloat = 1_920
let defaultScreenHeight: CGFloat = 1_080
let rightMargin: CGFloat = 10
let menuBarHeight: CGFloat = 25
let gap: CGFloat = 5
let x = defaultScreenWidth - preferredSize.width - rightMargin
let y = defaultScreenHeight - menuBarHeight - preferredSize.height - gap
return NSRect(origin: NSPoint(x: x, y: y), size: preferredSize)
}
let screenFrame = screen.visibleFrame
let gap: CGFloat = 5
// Check if the status frame appears to be invalid
if statusFrame.midX < 100, statusFrame.midY < 100 {
// Fall back to top-right positioning
let rightMargin: CGFloat = 10
let x = screenFrame.maxX - preferredSize.width - rightMargin
let y = screenFrame.maxY - preferredSize.height - gap
return NSRect(origin: NSPoint(x: x, y: y), size: preferredSize)
}
// Start with centered position below status item
var x = statusFrame.midX - preferredSize.width / 2
let y = statusFrame.minY - preferredSize.height - gap
// Ensure window stays within screen bounds
let minX = screenFrame.minX + 10
let maxX = screenFrame.maxX - preferredSize.width - 10
x = max(minX, min(maxX, x))
// Ensure window doesn't go below screen
let finalY = max(screenFrame.minY + 10, y)
return NSRect(
origin: NSPoint(x: x, y: finalY),
size: preferredSize
)
}
private func showAtTopRightFallback(withSize preferredSize: NSSize) {
guard let screen = NSScreen.main else { return }
let screenFrame = screen.visibleFrame
let rightMargin: CGFloat = 10
let gap: CGFloat = 5
let x = screenFrame.maxX - preferredSize.width - rightMargin
let y = screenFrame.maxY - preferredSize.height - gap
let fallbackFrame = NSRect(
origin: NSPoint(x: x, y: y),
size: preferredSize
)
setFrame(fallbackFrame, display: false)
}
func hide() {
orderOut(nil)
teardownEventMonitoring()
onHide?()
}
override func orderOut(_ sender: Any?) {
super.orderOut(sender)
if isVisible == false {
onHide?()
}
}
private func setupEventMonitoring() {
teardownEventMonitoring()
guard isVisible else { return }
eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
guard let self, self.isVisible else { return }
let mouseLocation = NSEvent.mouseLocation
if !self.frame.contains(mouseLocation) {
self.hide()
}
}
isEventMonitoringActive = true
}
private func teardownEventMonitoring() {
if let monitor = eventMonitor {
NSEvent.removeMonitor(monitor)
eventMonitor = nil
isEventMonitoringActive = false
}
}
override func resignKey() {
super.resignKey()
hide()
}
override var canBecomeKey: Bool {
true
}
override func makeKey() {
super.makeKey()
// Set the window itself as first responder to prevent auto-focus
makeFirstResponder(self)
}
override var canBecomeMain: Bool {
false
}
deinit {
MainActor.assumeIsolated {
teardownEventMonitoring()
}
}
}
/// A wrapper view that applies modern SwiftUI material background to menu content.
struct CustomMenuContainer<Content: View>: View {
@ViewBuilder
let content: Content
@Environment(\.colorScheme)
private var colorScheme
var body: some View {
content
.fixedSize()
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(borderColor, lineWidth: 1)
)
}
private var borderColor: Color {
switch colorScheme {
case .dark:
Color.white.opacity(0.1)
case .light:
Color.white.opacity(0.8)
@unknown default:
Color.white.opacity(0.5)
}
}
}

View file

@ -0,0 +1,123 @@
import Foundation
// MARK: - Visual Indicator Styles
extension StatusBarController {
enum IndicatorStyle {
case dots // 5 (current implementation)
case bars //
case compact // 25
case minimalist // 2|5
case meter // []
}
/// Format session counts with the specified visual style
func formatSessionIndicator(activeCount: Int, totalCount: Int, style: IndicatorStyle = .dots) -> String {
guard totalCount > 0 else { return "" }
switch style {
case .dots:
return formatDotsIndicator(activeCount: activeCount, totalCount: totalCount)
case .bars:
return formatBarsIndicator(activeCount: activeCount, totalCount: totalCount)
case .compact:
return formatCompactIndicator(activeCount: activeCount, totalCount: totalCount)
case .minimalist:
return formatMinimalistIndicator(activeCount: activeCount, totalCount: totalCount)
case .meter:
return formatMeterIndicator(activeCount: activeCount, totalCount: totalCount)
}
}
// MARK: - Indicator Implementations
private func formatDotsIndicator(activeCount: Int, totalCount: Int) -> String {
if activeCount == 0 {
// Only idle sessions, show simple count
return String(totalCount)
} else if activeCount > 0 {
// Show active sessions with dots
let dots = String(repeating: "", count: min(activeCount, 3))
let suffix = activeCount > 3 ? "+" : ""
if totalCount > activeCount {
// Show active dots with total count
return "\(dots)\(suffix) \(totalCount)"
} else {
// Only active sessions, just show dots
return dots + suffix
}
}
return ""
}
private func formatBarsIndicator(activeCount: Int, totalCount: Int) -> String {
let maxBars = 5
let displayCount = min(totalCount, maxBars)
let displayActive = min(activeCount, displayCount)
let activeBars = String(repeating: "▪︎", count: displayActive)
let idleBars = String(repeating: "▫︎", count: displayCount - displayActive)
if totalCount > maxBars {
return "\(activeBars)\(idleBars)+"
}
return activeBars + idleBars
}
private func formatCompactIndicator(activeCount: Int, totalCount: Int) -> String {
if activeCount == 0 {
"\(totalCount)"
} else if activeCount == totalCount {
"\(activeCount)"
} else {
"\(activeCount)\(totalCount)"
}
}
private func formatMinimalistIndicator(activeCount: Int, totalCount: Int) -> String {
if activeCount == 0 {
String(totalCount)
} else if activeCount == totalCount {
"\(activeCount)"
} else {
"\(activeCount) | \(totalCount)"
}
}
private func formatMeterIndicator(activeCount: Int, totalCount: Int) -> String {
let maxSegments = 5
let segmentCount = min(totalCount, maxSegments)
if segmentCount == 0 { return "" }
let activeSegments = Int(round(Double(activeCount) / Double(totalCount) * Double(segmentCount)))
let filled = String(repeating: "", count: activeSegments)
let empty = String(repeating: "", count: segmentCount - activeSegments)
return "[\(filled)\(empty)]"
}
}
// MARK: - Alternative Unicode Characters
// Other visual indicators we could use:
//
// Dots and Circles:
//
//
// Squares and Blocks:
//
//
// Bars and Progress:
//
//
// Arrows and Triangles:
//
//
// Special Characters:
//

View file

@ -0,0 +1,251 @@
import AppKit
import Combine
import Network
import SwiftUI
/// Manages the macOS status bar item with custom left-click view and right-click menu.
@MainActor
final class StatusBarController: NSObject {
// MARK: - Core Properties
private var statusItem: NSStatusItem?
private let menuManager: StatusBarMenuManager
// MARK: - Dependencies
private let sessionMonitor: SessionMonitor
private let serverManager: ServerManager
private let ngrokService: NgrokService
private let tailscaleService: TailscaleService
private let terminalLauncher: TerminalLauncher
// MARK: - State Tracking
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
init(
sessionMonitor: SessionMonitor,
serverManager: ServerManager,
ngrokService: NgrokService,
tailscaleService: TailscaleService,
terminalLauncher: TerminalLauncher
) {
self.sessionMonitor = sessionMonitor
self.serverManager = serverManager
self.ngrokService = ngrokService
self.tailscaleService = tailscaleService
self.terminalLauncher = terminalLauncher
self.menuManager = StatusBarMenuManager()
super.init()
setupStatusItem()
setupMenuManager()
setupObservers()
startNetworkMonitoring()
}
// MARK: - Setup
private func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button {
button.imagePosition = .imageLeading
button.action = #selector(handleClick(_:))
button.target = self
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
// Accessibility
button.setAccessibilityTitle("VibeTunnel")
button.setAccessibilityRole(.button)
button.setAccessibilityHelp("Shows terminal sessions and server information")
updateStatusItemDisplay()
}
}
private func setupMenuManager() {
let configuration = StatusBarMenuManager.Configuration(
sessionMonitor: sessionMonitor,
serverManager: serverManager,
ngrokService: ngrokService,
tailscaleService: tailscaleService,
terminalLauncher: terminalLauncher
)
menuManager.setup(with: configuration)
}
private func setupObservers() {
// Create a timer to periodically update the display
// since SessionMonitor doesn't have a publisher
updateTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
Task { @MainActor in
_ = await self?.sessionMonitor.getSessions()
self?.updateStatusItemDisplay()
}
}
}
private func startNetworkMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor in
self?.hasNetworkAccess = path.status == .satisfied
self?.updateStatusItemDisplay()
}
}
monitor.start(queue: monitorQueue)
}
// MARK: - Display Updates
func updateStatusItemDisplay() {
guard let button = statusItem?.button else { return }
// Update icon based on server and network status
let iconName = (serverManager.isRunning && hasNetworkAccess) ? "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 && hasNetworkAccess) ? 1.0 : 0.5
}
}
// 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
// Format the title with visual indicators
// Try different styles by changing this:
// .dots (default): 5
// .bars:
// .compact: 25
// .minimalist: 2|5
// .meter: []
let indicatorStyle: IndicatorStyle = .minimalist
button.title = formatSessionIndicator(activeCount: activeCount, totalCount: totalCount, style: indicatorStyle)
// 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
@objc
private func handleClick(_ sender: NSStatusBarButton) {
guard let currentEvent = NSApp.currentEvent else {
handleLeftClick(sender)
return
}
switch currentEvent.type {
case .leftMouseUp:
handleLeftClick(sender)
case .rightMouseUp:
handleRightClick(sender)
default:
handleLeftClick(sender)
}
}
private func handleLeftClick(_ button: NSStatusBarButton) {
menuManager.toggleCustomWindow(relativeTo: button)
}
private func handleRightClick(_ button: NSStatusBarButton) {
guard let statusItem else { return }
menuManager.showContextMenu(for: button, statusItem: statusItem)
}
// MARK: - Public Methods
func showCustomWindow() {
guard let button = statusItem?.button else { return }
menuManager.showCustomWindow(relativeTo: button)
}
// MARK: - Cleanup
deinit {
MainActor.assumeIsolated {
updateTimer?.invalidate()
}
monitor.cancel()
}
}

View file

@ -0,0 +1,269 @@
import AppKit
import SwiftUI
/// Manages status bar menu behavior, providing left-click custom view and right-click context menu functionality.
@MainActor
final class StatusBarMenuManager {
// MARK: - Private Properties
private var sessionMonitor: SessionMonitor?
private var serverManager: ServerManager?
private var ngrokService: NgrokService?
private var tailscaleService: TailscaleService?
private var terminalLauncher: TerminalLauncher?
// Custom window management
private var customWindow: CustomMenuWindow?
private weak var statusBarButton: NSStatusBarButton?
// MARK: - Initialization
init() {}
// MARK: - Configuration
struct Configuration {
let sessionMonitor: SessionMonitor
let serverManager: ServerManager
let ngrokService: NgrokService
let tailscaleService: TailscaleService
let terminalLauncher: TerminalLauncher
}
// MARK: - Setup
func setup(with configuration: Configuration) {
self.sessionMonitor = configuration.sessionMonitor
self.serverManager = configuration.serverManager
self.ngrokService = configuration.ngrokService
self.tailscaleService = configuration.tailscaleService
self.terminalLauncher = configuration.terminalLauncher
}
// MARK: - Left-Click Custom Window Management
func toggleCustomWindow(relativeTo button: NSStatusBarButton) {
if let window = customWindow, window.isVisible {
hideCustomWindow()
} else {
showCustomWindow(relativeTo: button)
}
}
func showCustomWindow(relativeTo button: NSStatusBarButton) {
guard let sessionMonitor,
let serverManager,
let ngrokService,
let tailscaleService,
let terminalLauncher else { return }
// Store button reference
self.statusBarButton = button
// Highlight the button immediately to show active state
button.highlight(true)
// Create the main view with all dependencies
let mainView = VibeTunnelMenuView()
.environment(sessionMonitor)
.environment(serverManager)
.environment(ngrokService)
.environment(tailscaleService)
.environment(terminalLauncher)
// Wrap in custom container for proper styling
let containerView = CustomMenuContainer {
mainView
}
// Create custom window if needed
if customWindow == nil {
customWindow = CustomMenuWindow(contentView: containerView)
// Set up callback to unhighlight button when window hides
customWindow?.onHide = { [weak self] in
// Ensure button is unhighlighted on main thread
Task { @MainActor in
self?.statusBarButton?.highlight(false)
}
}
} else {
// Hide and cleanup old window before creating new one
customWindow?.hide()
customWindow = nil
// Create new window with updated content
customWindow = CustomMenuWindow(contentView: containerView)
customWindow?.onHide = { [weak self] in
Task { @MainActor in
self?.statusBarButton?.highlight(false)
}
}
}
// Show the custom window
customWindow?.show(relativeTo: button)
}
func hideCustomWindow() {
customWindow?.hide()
}
var isCustomWindowVisible: Bool {
customWindow?.isVisible ?? false
}
// MARK: - Menu State Management
func hideAllMenus() {
hideCustomWindow()
}
var isAnyMenuVisible: Bool {
isCustomWindowVisible
}
// MARK: - Right-Click Context Menu
func showContextMenu(for button: NSStatusBarButton, statusItem: NSStatusItem) {
// Hide custom window first if it's visible
hideCustomWindow()
let menu = NSMenu()
// Server status
if let serverManager {
let statusText = serverManager.isRunning ? "Server running" : "Server stopped"
let statusItem = NSMenuItem(title: statusText, action: nil, keyEquivalent: "")
statusItem.isEnabled = false
menu.addItem(statusItem)
menu.addItem(NSMenuItem.separator())
}
// Open Dashboard
if let serverManager, serverManager.isRunning {
let dashboardItem = NSMenuItem(title: "Open Dashboard", action: #selector(openDashboard), keyEquivalent: "")
dashboardItem.target = self
menu.addItem(dashboardItem)
menu.addItem(NSMenuItem.separator())
}
// Help submenu
let helpMenu = NSMenu()
let tutorialItem = NSMenuItem(title: "Show Tutorial", action: #selector(showTutorial), keyEquivalent: "")
tutorialItem.target = self
helpMenu.addItem(tutorialItem)
helpMenu.addItem(NSMenuItem.separator())
let websiteItem = NSMenuItem(title: "Website", action: #selector(openWebsite), keyEquivalent: "")
websiteItem.target = self
helpMenu.addItem(websiteItem)
let issueItem = NSMenuItem(title: "Report Issue", action: #selector(reportIssue), keyEquivalent: "")
issueItem.target = self
helpMenu.addItem(issueItem)
helpMenu.addItem(NSMenuItem.separator())
let updateItem = NSMenuItem(title: "Check for Updates…", action: #selector(checkForUpdates), keyEquivalent: "")
updateItem.target = self
helpMenu.addItem(updateItem)
let versionItem = NSMenuItem(title: "Version \(appVersion)", action: nil, keyEquivalent: "")
versionItem.isEnabled = false
helpMenu.addItem(versionItem)
helpMenu.addItem(NSMenuItem.separator())
let aboutItem = NSMenuItem(title: "About VibeTunnel", action: #selector(showAbout), keyEquivalent: "")
aboutItem.target = self
helpMenu.addItem(aboutItem)
let helpMenuItem = NSMenuItem(title: "Help", action: nil, keyEquivalent: "")
helpMenuItem.submenu = helpMenu
menu.addItem(helpMenuItem)
// Settings
let settingsItem = NSMenuItem(title: "Settings...", action: #selector(openSettings), keyEquivalent: ",")
settingsItem.target = self
menu.addItem(settingsItem)
menu.addItem(NSMenuItem.separator())
// Quit
let quitItem = NSMenuItem(title: "Quit VibeTunnel", action: #selector(quitApp), keyEquivalent: "q")
quitItem.target = self
menu.addItem(quitItem)
// Show the context menu
statusItem.menu = menu
button.performClick(nil)
statusItem.menu = nil
}
// MARK: - Context Menu Actions
@objc
private func openDashboard() {
guard let serverManager else { return }
if let url = URL(string: "http://127.0.0.1:\(serverManager.port)") {
NSWorkspace.shared.open(url)
}
}
@objc
private func showTutorial() {
#if !SWIFT_PACKAGE
AppDelegate.showWelcomeScreen()
#endif
}
@objc
private func openWebsite() {
if let url = URL(string: "http://vibetunnel.sh") {
NSWorkspace.shared.open(url)
}
}
@objc
private func reportIssue() {
if let url = URL(string: "https://github.com/amantus-ai/vibetunnel/issues") {
NSWorkspace.shared.open(url)
}
}
@objc
private func checkForUpdates() {
SparkleUpdaterManager.shared.checkForUpdates()
}
@objc
private func showAbout() {
SettingsOpener.openSettings()
Task {
try? await Task.sleep(for: .milliseconds(100))
NotificationCenter.default.post(
name: .openSettingsTab,
object: SettingsTab.about
)
}
}
@objc
private func openSettings() {
SettingsOpener.openSettings()
}
@objc
private func quitApp() {
NSApplication.shared.terminate(nil)
}
private var appVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
}
}

View file

@ -0,0 +1,583 @@
import AppKit
import SwiftUI
/// Main menu view displayed when left-clicking the status bar item.
/// Shows server status, session list, and quick actions in a rich interface.
struct VibeTunnelMenuView: View {
@Environment(SessionMonitor.self)
var sessionMonitor
@Environment(ServerManager.self)
var serverManager
@Environment(NgrokService.self)
var ngrokService
@Environment(TailscaleService.self)
var tailscaleService
@Environment(\.openWindow)
private var openWindow
@State private var hoveredSessionId: String?
@State private var hasStartedKeyboardNavigation = false
@FocusState private var focusedField: FocusField?
enum FocusField: Hashable {
case sessionRow(String)
case settingsButton
case quitButton
}
var body: some View {
VStack(spacing: 0) {
// Header with server info
ServerInfoHeader()
.padding()
.background(
LinearGradient(
colors: [
Color(NSColor.controlBackgroundColor).opacity(0.6),
Color(NSColor.controlBackgroundColor).opacity(0.3)
],
startPoint: .top,
endPoint: .bottom
)
)
Divider()
// Session list
ScrollView {
VStack(spacing: 1) {
if activeSessions.isEmpty && idleSessions.isEmpty {
EmptySessionsView()
.padding()
.transition(.opacity.combined(with: .scale(scale: 0.95)))
} else {
// Active sessions section
if !activeSessions.isEmpty {
SessionSectionHeader(title: "Active", count: activeSessions.count)
.transition(.opacity)
ForEach(activeSessions, id: \.key) { session in
SessionRow(
session: session,
isHovered: hoveredSessionId == session.key,
isActive: true,
isFocused: focusedField == .sessionRow(session.key) && hasStartedKeyboardNavigation
)
.onHover { hovering in
hoveredSessionId = hovering ? session.key : nil
}
.focused($focusedField, equals: .sessionRow(session.key))
.transition(.asymmetric(
insertion: .opacity.combined(with: .move(edge: .top)),
removal: .opacity.combined(with: .move(edge: .bottom))
))
}
}
// Idle sessions section
if !idleSessions.isEmpty {
if !activeSessions.isEmpty {
Divider()
.padding(.vertical, 4)
.transition(.opacity)
}
SessionSectionHeader(title: "Idle", count: idleSessions.count)
.transition(.opacity)
ForEach(idleSessions, id: \.key) { session in
SessionRow(
session: session,
isHovered: hoveredSessionId == session.key,
isActive: false,
isFocused: focusedField == .sessionRow(session.key) && hasStartedKeyboardNavigation
)
.onHover { hovering in
hoveredSessionId = hovering ? session.key : nil
}
.focused($focusedField, equals: .sessionRow(session.key))
.transition(.asymmetric(
insertion: .opacity.combined(with: .move(edge: .bottom)),
removal: .opacity.combined(with: .move(edge: .top))
))
}
}
}
}
.padding(.vertical, 4)
.animation(.easeInOut(duration: 0.3), value: activeSessions.map(\.key))
.animation(.easeInOut(duration: 0.3), value: idleSessions.map(\.key))
}
.frame(maxHeight: 400)
Divider()
// Bottom actions
HStack {
Button(action: {
SettingsOpener.openSettings()
}) {
Label("Settings", systemImage: "gear")
.font(.system(size: 12))
}
.buttonStyle(.plain)
.foregroundColor(.secondary)
.focusable()
.focused($focusedField, equals: .settingsButton)
.overlay(
RoundedRectangle(cornerRadius: 4)
.strokeBorder(
focusedField == .settingsButton && hasStartedKeyboardNavigation ? Color.accentColor
.opacity(0.3) : Color.clear,
lineWidth: 1
)
.animation(.easeInOut(duration: 0.15), value: focusedField)
)
Spacer()
Button(action: {
NSApplication.shared.terminate(nil)
}) {
Label("Quit", systemImage: "power")
.font(.system(size: 12))
}
.buttonStyle(.plain)
.foregroundColor(.secondary)
.focusable()
.focused($focusedField, equals: .quitButton)
.overlay(
RoundedRectangle(cornerRadius: 4)
.strokeBorder(
focusedField == .quitButton && hasStartedKeyboardNavigation ? Color.accentColor
.opacity(0.3) : Color.clear,
lineWidth: 1
)
.animation(.easeInOut(duration: 0.15), value: focusedField)
)
}
.padding()
}
.frame(width: 384)
.background(Color.clear)
.onAppear {
// Clear any initial focus after a short delay
Task {
try? await Task.sleep(for: .milliseconds(50))
await MainActor.run {
focusedField = nil
}
}
}
.onKeyPress { keyPress in
if keyPress.key == .tab && !hasStartedKeyboardNavigation {
hasStartedKeyboardNavigation = true
// Let the system handle the Tab to actually move focus
return .ignored
}
return .ignored
}
}
private var activeSessions: [(key: String, value: ServerSessionInfo)] {
sessionMonitor.sessions
.filter { $0.value.isRunning && hasActivity($0.value) }
.sorted { $0.value.startedAt > $1.value.startedAt }
}
private var idleSessions: [(key: String, value: ServerSessionInfo)] {
sessionMonitor.sessions
.filter { $0.value.isRunning && !hasActivity($0.value) }
.sorted { $0.value.startedAt > $1.value.startedAt }
}
private func hasActivity(_ session: ServerSessionInfo) -> Bool {
if let activityStatus = session.activityStatus?.specificStatus?.status {
return !activityStatus.isEmpty
}
return false
}
}
// MARK: - Server Info Header
struct ServerInfoHeader: View {
@Environment(ServerManager.self)
var serverManager
@Environment(NgrokService.self)
var ngrokService
@Environment(TailscaleService.self)
var tailscaleService
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Title and status
HStack {
HStack(spacing: 8) {
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 24, height: 24)
.cornerRadius(4)
Text("VibeTunnel")
.font(.system(size: 14, weight: .semibold))
}
Spacer()
ServerStatusBadge(isRunning: serverManager.isRunning)
}
// Server address
if serverManager.isRunning {
VStack(alignment: .leading, spacing: 4) {
ServerAddressRow()
if ngrokService.isActive, let publicURL = ngrokService.publicUrl {
HStack(spacing: 4) {
Image(systemName: "network")
.font(.system(size: 10))
.foregroundColor(.purple)
Text("ngrok:")
.font(.system(size: 11))
.foregroundColor(.secondary)
Text(publicURL)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.purple)
.lineLimit(1)
.truncationMode(.middle)
}
}
if tailscaleService.isRunning, let hostname = tailscaleService.tailscaleHostname {
HStack(spacing: 4) {
Image(systemName: "shield")
.font(.system(size: 10))
.foregroundColor(.blue)
Text("Tailscale:")
.font(.system(size: 11))
.foregroundColor(.secondary)
Button(action: {
if let url = URL(string: "http://\(hostname)") {
NSWorkspace.shared.open(url)
}
}) {
Text(hostname)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.blue)
.underline()
}
.buttonStyle(.plain)
.pointingHandCursor()
}
}
}
}
}
}
}
struct ServerAddressRow: View {
@Environment(ServerManager.self)
var serverManager
var body: some View {
HStack(spacing: 4) {
Image(systemName: "server.rack")
.font(.system(size: 10))
.foregroundColor(.green)
Text("Local:")
.font(.system(size: 11))
.foregroundColor(.secondary)
Button(action: {
if let url = URL(string: "http://\(serverAddress)") {
NSWorkspace.shared.open(url)
}
}) {
Text(serverAddress)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.accentColor)
.underline()
}
.buttonStyle(.plain)
.pointingHandCursor()
}
}
private var serverAddress: String {
let bindAddress = serverManager.bindAddress
if bindAddress == "127.0.0.1" {
return "127.0.0.1:\(serverManager.port)"
} else if let localIP = NetworkUtility.getLocalIPAddress() {
return "\(localIP):\(serverManager.port)"
} else {
return "0.0.0.0:\(serverManager.port)"
}
}
}
struct ServerStatusBadge: View {
let isRunning: Bool
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(isRunning ? Color.green : Color.red)
.frame(width: 6, height: 6)
Text(isRunning ? "Running" : "Stopped")
.font(.system(size: 10, weight: .medium))
.foregroundColor(isRunning ? .green : .red)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule()
.fill(isRunning ? Color.green.opacity(0.1) : Color.red.opacity(0.1))
.overlay(
Capsule()
.stroke(isRunning ? Color.green.opacity(0.3) : Color.red.opacity(0.3), lineWidth: 0.5)
)
)
}
}
// MARK: - Session Components
struct SessionSectionHeader: View {
let title: String
let count: Int
var body: some View {
HStack {
Text(title)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.secondary)
Text("(\(count))")
.font(.system(size: 11))
.foregroundColor(Color.secondary.opacity(0.6))
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
}
}
struct SessionRow: View {
let session: (key: String, value: ServerSessionInfo)
let isHovered: Bool
let isActive: Bool
let isFocused: Bool
@Environment(\.openWindow)
private var openWindow
var body: some View {
Button(action: {
WindowTracker.shared.focusWindow(for: session.key)
}) {
HStack(spacing: 8) {
// Activity indicator with subtle glow
ZStack {
Circle()
.fill(activityColor.opacity(0.3))
.frame(width: 8, height: 8)
.blur(radius: 2)
.animation(.easeInOut(duration: 0.4), value: activityColor)
Circle()
.fill(activityColor)
.frame(width: 4, height: 4)
.animation(.easeInOut(duration: 0.4), value: activityColor)
}
// Session info
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(sessionName)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
if hasWindow {
Image(systemName: "macwindow")
.font(.system(size: 10))
.foregroundColor(.secondary)
}
}
if let activityStatus = session.value.activityStatus?.specificStatus?.status {
HStack(spacing: 4) {
Text(activityStatus)
.font(.system(size: 10))
.foregroundColor(.orange)
Spacer()
Text(compactPath)
.font(.system(size: 10))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
} else {
Text(compactPath)
.font(.system(size: 10))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
// Duration
Text(duration)
.font(.system(size: 10))
.foregroundColor(Color.secondary.opacity(0.6))
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isHovered ? Color.accentColor.opacity(0.08) : Color.clear)
.animation(.easeInOut(duration: 0.15), value: isHovered)
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(
isFocused ? Color.accentColor.opacity(0.3) : Color.clear,
lineWidth: 1
)
.animation(.easeInOut(duration: 0.15), value: isFocused)
)
.focusable()
.contextMenu {
if hasWindow {
Button("Focus Terminal Window") {
WindowTracker.shared.focusWindow(for: session.key)
}
}
Button("View Session Details") {
openWindow(id: "session-detail", value: session.key)
}
Divider()
Button("Copy Session ID") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(session.key, forType: .string)
}
}
}
private var sessionName: String {
let workingDir = session.value.workingDir
return (workingDir as NSString).lastPathComponent
}
private var compactPath: String {
let path = session.value.workingDir
let homeDir = NSHomeDirectory()
if path.hasPrefix(homeDir) {
let relativePath = String(path.dropFirst(homeDir.count))
return "~" + relativePath
}
let components = (path as NSString).pathComponents
if components.count > 2 {
let lastTwo = components.suffix(2).joined(separator: "/")
return ".../" + lastTwo
}
return path
}
private var activityColor: Color {
if isActive {
.orange
} else {
.green
}
}
private var hasWindow: Bool {
// Check if WindowTracker has a window registered for this session
WindowTracker.shared.windowInfo(for: session.key) != nil
}
private var duration: String {
// Parse ISO8601 date string with fractional seconds
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let startDate = formatter.date(from: session.value.startedAt) else {
// Fallback: try without fractional seconds
formatter.formatOptions = [.withInternetDateTime]
guard let startDate = formatter.date(from: session.value.startedAt) else {
return "" // Return empty string instead of "unknown"
}
return formatDuration(from: startDate)
}
return formatDuration(from: startDate)
}
private func formatDuration(from startDate: Date) -> String {
let elapsed = Date().timeIntervalSince(startDate)
if elapsed < 60 {
return "just now"
} else if elapsed < 3_600 {
let minutes = Int(elapsed / 60)
return "\(minutes)m"
} else if elapsed < 86_400 {
let hours = Int(elapsed / 3_600)
return "\(hours)h"
} else {
let days = Int(elapsed / 86_400)
return "\(days)d"
}
}
}
struct EmptySessionsView: View {
@Environment(ServerManager.self)
var serverManager
@State private var isAnimating = false
var body: some View {
VStack(spacing: 12) {
Image(systemName: "terminal")
.font(.system(size: 32))
.foregroundStyle(
LinearGradient(
colors: [Color.secondary, Color.secondary.opacity(0.6)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.scaleEffect(isAnimating ? 1.05 : 1.0)
.animation(.easeInOut(duration: 2).repeatForever(autoreverses: true), value: isAnimating)
.onAppear { isAnimating = true }
Text("No active sessions")
.font(.system(size: 12))
.foregroundColor(.secondary)
if serverManager.isRunning {
Button("Open Dashboard") {
if let url = URL(string: "http://127.0.0.1:\(serverManager.port)") {
NSWorkspace.shared.open(url)
}
}
.buttonStyle(.link)
.font(.system(size: 11))
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
}
}

View file

@ -0,0 +1,72 @@
# Visual Indicator Styles for VibeTunnel Menu Bar
## Current Implementation
The menu bar now shows session status using visual indicators instead of cryptic numbers. Here are the available styles:
### 1. **Dots Style** (Default)
```
No sessions: [empty]
Only idle: 3
Only active: ●●●
Mixed (2/5): ●● 5
Many active: ●●●+ 8
```
- Filled dots (●) represent active sessions
- Shows up to 3 dots, then adds "+"
- Total count shown only when idle sessions exist
### 2. **Bars Style**
```
No sessions: [empty]
Only idle: ▫︎▫︎▫︎
Only active: ▪︎▪︎▪︎
Mixed (2/5): ▪︎▪︎▫︎▫︎▫︎
Many (3/7): ▪︎▪︎▪︎▫︎▫︎+
```
- Filled squares (▪︎) for active sessions
- Empty squares (▫︎) for idle sessions
- Shows up to 5 bars total
### 3. **Compact Style**
```
No sessions: [empty]
Only idle: ◯3
Only active: ◆2
Mixed (2/5): 2◆5
```
- Diamond (◆) as separator/indicator
- Most space-efficient option
### 4. **Minimalist Style**
```
No sessions: [empty]
Only idle: 3
Only active: ●2
Mixed (2/5): 2|5
```
- Simple vertical bar separator
- Dot prefix for active-only
### 5. **Meter Style**
```
No sessions: [empty]
Only idle: [□□□□□]
Only active: [■■■■■]
Mixed (2/5): [■■□□□]
Mixed (1/3): [■■□□□]
```
- Progress bar visualization
- Shows active/total ratio
## Changing Styles
To change the indicator style, modify line 144 in `StatusBarController.swift`:
```swift
let indicatorStyle: IndicatorStyle = .dots // Change to .bars, .compact, etc.
```
## Button Highlighting
The menu bar button now properly highlights when the dropdown is open, providing clear visual feedback that the menu is active.

View file

@ -18,7 +18,7 @@ struct AboutView: View {
return "\(version) (\(build))"
}
// Special thanks contributors sorted by contribution count
/// Special thanks contributors sorted by contribution count
private let specialContributors = [
"Helmut Januschka",
"Manuel Maly",
@ -187,9 +187,9 @@ struct HoverableLink: View {
// MARK: - Array Extension
private extension Array {
func chunked(into size: Int) -> [[Element]] {
return stride(from: 0, to: count, by: size).map {
extension Array {
fileprivate func chunked(into size: Int) -> [[Element]] {
stride(from: 0, to: count, by: size).map {
Array(self[$0..<Swift.min($0 + size, count)])
}
}

View file

@ -345,7 +345,7 @@ struct SessionRowView: View {
Text(" ")
.font(.system(size: 11))
if let activityStatus = activityStatus {
if let activityStatus {
Text(activityStatus)
.font(.system(size: 11))
.foregroundColor(.orange)

View file

@ -89,18 +89,7 @@ struct VibeTunnelApp: App {
}
}
MenuBarExtra {
MenuBarView()
.environment(sessionMonitor)
.environment(serverManager)
.environment(ngrokService)
.environment(tailscaleService)
.environment(permissionManager)
.environment(terminalLauncher)
} label: {
Image("menubar")
.renderingMode(.template)
}
// MenuBarExtra is replaced by custom StatusBarController in AppDelegate
#endif
}
}
@ -113,6 +102,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
private(set) var sparkleUpdaterManager: SparkleUpdaterManager?
var app: VibeTunnelApp?
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "AppDelegate")
private var statusBarController: StatusBarController?
/// Distributed notification name used to ask an existing instance to show the Settings window.
private static let showSettingsNotification = Notification.Name("sh.vibetunnel.vibetunnel.showSettings")
@ -215,6 +205,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
logger.error("Server start error: \(error.localizedDescription)")
}
}
// Initialize status bar controller after services are ready
if let sessionMonitor = app?.sessionMonitor,
let serverManager = app?.serverManager,
let ngrokService = app?.ngrokService,
let tailscaleService = app?.tailscaleService,
let terminalLauncher = app?.terminalLauncher
{
statusBarController = StatusBarController(
sessionMonitor: sessionMonitor,
serverManager: serverManager,
ngrokService: ngrokService,
tailscaleService: tailscaleService,
terminalLauncher: terminalLauncher
)
}
}
}

View file

@ -4,7 +4,6 @@ import Testing
@Suite("AppleScript Executor Tests", .tags(.integration))
struct AppleScriptExecutorTests {
@Test("Execute simple AppleScript")
@MainActor
func executeSimpleScript() throws {
@ -87,7 +86,7 @@ struct AppleScriptExecutorTests {
}
@Test("Test async execution", .disabled("Slow test - 3.5 seconds"))
func testAsyncExecution() async throws {
func asyncExecution() async throws {
// Test the async method
let hasPermission = await AppleScriptExecutor.shared.checkPermission()
#expect(hasPermission == true || hasPermission == false)

View file

@ -1,11 +1,10 @@
import AppKit
import Foundation
import Testing
import AppKit
@testable import VibeTunnel
@Suite("Dock Icon Manager Tests")
struct DockIconManagerTests {
@Test("Singleton instance")
@MainActor
func singletonInstance() {

View file

@ -5,7 +5,7 @@ import Testing
@Suite("Ngrok Service Tests", .tags(.networking))
struct NgrokServiceTests {
let testAuthToken = "test_auth_token_123"
let testPort = 8888
let testPort = 8_888
@Test("Singleton instance")
@MainActor

View file

@ -1,12 +1,11 @@
import Testing
import Foundation
import Testing
@testable import VibeTunnel
/// Tests for PowerManagementService that work reliably in CI environments
@Suite("Power Management Service")
@MainActor
struct PowerManagementServiceTests {
// Since PowerManagementService has a private init, we can only test through the shared instance
// We need to ensure proper cleanup between tests
@ -16,7 +15,7 @@ struct PowerManagementServiceTests {
let currentValue = UserDefaults.standard.object(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
defer {
// Restore original value
if let currentValue = currentValue {
if let currentValue {
UserDefaults.standard.set(currentValue, forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
} else {
UserDefaults.standard.removeObject(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
@ -134,7 +133,6 @@ struct PowerManagementServiceTests {
@Suite("Power Management Edge Cases")
@MainActor
struct PowerManagementEdgeCaseTests {
@Test("Rapid state changes handle correctly")
func rapidStateChanges() async {
let service = PowerManagementService.shared

View file

@ -27,7 +27,7 @@ final class ServerManagerTests {
await manager.start()
// Give server time to attempt start
try await Task.sleep(for: .milliseconds(2000))
try await Task.sleep(for: .milliseconds(2_000))
// In test environment, server binary won't be found, so we expect failure
// Check that lastError indicates the binary wasn't found
@ -54,7 +54,7 @@ final class ServerManagerTests {
// First attempt to start
await manager.start()
try await Task.sleep(for: .milliseconds(1000))
try await Task.sleep(for: .milliseconds(1_000))
let firstServer = manager.bunServer
let firstError = manager.lastError
@ -67,7 +67,8 @@ final class ServerManagerTests {
// Error should be consistent
if let error1 = firstError as? BunServerError,
let error2 = manager.lastError as? BunServerError {
let error2 = manager.lastError as? BunServerError
{
#expect(error1 == error2)
}

View file

@ -53,7 +53,7 @@ final class SessionMonitorTests {
#expect(session.exitCode == nil)
#expect(session.startedAt == "2025-01-01T10:00:00.000Z")
#expect(session.lastModified == "2025-01-01T10:05:00.000Z")
#expect(session.pid == 12345)
#expect(session.pid == 12_345)
#expect(session.initialCols == 80)
#expect(session.initialRows == 24)
#expect(session.activityStatus?.isActive == true)
@ -177,7 +177,7 @@ final class SessionMonitorTests {
#expect(sessions[0].id == "session-1")
#expect(sessions[0].command == ["bash"])
#expect(sessions[0].isRunning == true)
#expect(sessions[0].pid == 1001)
#expect(sessions[0].pid == 1_001)
// Verify second session
#expect(sessions[1].id == "session-2")
@ -288,8 +288,10 @@ final class SessionMonitorTests {
let data = json.data(using: .utf8)!
let session = try JSONDecoder().decode(ServerSessionInfo.self, from: data)
#expect(session.isRunning == expectedRunning,
"Status '\(status)' should result in isRunning=\(expectedRunning)")
#expect(
session.isRunning == expectedRunning,
"Status '\(status)' should result in isRunning=\(expectedRunning)"
)
}
}
@ -430,7 +432,7 @@ final class SessionMonitorTests {
let devSession = sessions[1]
#expect(devSession.command == ["pnpm", "run", "dev"])
#expect(devSession.isRunning == true)
#expect(devSession.pid == 34567)
#expect(devSession.pid == 34_567)
// Verify exited session
let gitSession = sessions[2]

View file

@ -1,11 +1,10 @@
import Foundation
import Testing
import ServiceManagement
import Testing
@testable import VibeTunnel
@Suite("Startup Manager Tests")
struct StartupManagerTests {
@Test("Create instance")
@MainActor
func createInstance() {
@ -60,7 +59,7 @@ struct StartupManagerTests {
// In test environment, bundle identifier might be nil
let bundleId = Bundle.main.bundleIdentifier
if let bundleId = bundleId {
if let bundleId {
#expect(!bundleId.isEmpty)
// In test environment, bundle ID can vary widely
// Just verify it's a valid identifier format (contains a dot for reverse domain notation)

View file

@ -12,7 +12,8 @@ enum TestFixtures {
processID: Int32? = nil,
isActive: Bool = true
)
-> TunnelSession {
-> TunnelSession
{
var session = TunnelSession(
id: UUID(uuidString: id) ?? UUID(),
processID: processID
@ -34,7 +35,8 @@ enum TestFixtures {
static func createSessionRequest(
clientInfo: TunnelSession.ClientInfo? = nil
)
-> TunnelSession.CreateRequest {
-> TunnelSession.CreateRequest
{
TunnelSession.CreateRequest(clientInfo: clientInfo ?? defaultClientInfo())
}
@ -42,7 +44,8 @@ enum TestFixtures {
id: String = "00000000-0000-0000-0000-000000000123",
session: TunnelSession? = nil
)
-> TunnelSession.CreateResponse {
-> TunnelSession.CreateResponse
{
TunnelSession.CreateResponse(
id: id,
session: session ?? createSession(id: id)
@ -57,7 +60,8 @@ enum TestFixtures {
environment: [String: String]? = nil,
workingDirectory: String? = nil
)
-> TunnelSession.ExecuteCommandRequest {
-> TunnelSession.ExecuteCommandRequest
{
TunnelSession.ExecuteCommandRequest(
sessionId: sessionId,
command: command,
@ -71,7 +75,8 @@ enum TestFixtures {
stdout: String = "test output",
stderr: String = ""
)
-> TunnelSession.ExecuteCommandResponse {
-> TunnelSession.ExecuteCommandResponse
{
TunnelSession.ExecuteCommandResponse(
exitCode: exitCode,
stdout: stdout,
@ -85,7 +90,8 @@ enum TestFixtures {
error: String = "Test error",
code: String? = "TEST_ERROR"
)
-> TunnelSession.ErrorResponse {
-> TunnelSession.ErrorResponse
{
TunnelSession.ErrorResponse(error: error, code: code)
}
@ -116,7 +122,8 @@ extension TestFixtures {
timeout: TimeInterval = 1.0,
interval: TimeInterval = 0.1
)
async throws {
async throws
{
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {