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

@ -135,12 +135,6 @@ jobs:
sleep 5
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: |

View file

@ -46,8 +46,8 @@ struct WelcomeView: View {
HStack(spacing: 8) {
ForEach(0..<5) { index in
Circle()
.fill(index == currentPage ?
Theme.Colors.primaryAccent :
.fill(index == currentPage ?
Theme.Colors.primaryAccent :
Theme.Colors.secondaryText.opacity(0.3))
.frame(width: 8, height: 8)
.animation(.easeInOut, value: currentPage)

View file

@ -11,13 +11,13 @@ enum AppConstants {
static let welcomeVersion = "welcomeVersion"
static let preventSleepWhenRunning = "preventSleepWhenRunning"
}
/// Default values for UserDefaults
enum Defaults {
/// Sleep prevention is enabled by default for better user experience
static let preventSleepWhenRunning = true
}
/// Helper to get boolean value with proper default
static func boolValue(for key: String) -> Bool {
// If the key doesn't exist in UserDefaults, return our default

View file

@ -12,28 +12,28 @@ import Observation
@MainActor
final class PowerManagementService {
static let shared = PowerManagementService()
private(set) var isSleepPrevented = false
private var assertionID: IOPMAssertionID = 0
private var isAssertionActive = false
private init() {}
/// Prevents the system from sleeping
func preventSleep() {
guard !isAssertionActive else { return }
let reason = "VibeTunnel is running terminal sessions" as CFString
let assertionType = kIOPMAssertionTypeNoIdleSleep as CFString
let success = IOPMAssertionCreateWithName(
assertionType,
IOPMAssertionLevel(kIOPMAssertionLevelOn),
reason,
&assertionID
)
if success == kIOReturnSuccess {
isAssertionActive = true
isSleepPrevented = true
@ -42,13 +42,13 @@ final class PowerManagementService {
print("Failed to prevent sleep: \(success)")
}
}
/// Allows the system to sleep normally
func allowSleep() {
guard isAssertionActive else { return }
let success = IOPMAssertionRelease(assertionID)
if success == kIOReturnSuccess {
isAssertionActive = false
isSleepPrevented = false
@ -58,7 +58,7 @@ final class PowerManagementService {
print("Failed to release sleep assertion: \(success)")
}
}
/// Updates sleep prevention based on user preference and server state
func updateSleepPrevention(enabled: Bool, serverRunning: Bool) {
if enabled && serverRunning {
@ -67,10 +67,10 @@ final class PowerManagementService {
allowSleep()
}
}
deinit {
// Deinit runs on arbitrary thread, but we need to check MainActor state
// Since we can't access MainActor properties directly in deinit,
// we handle cleanup in allowSleep() which is called when server stops
}
}
}

View file

@ -128,11 +128,11 @@ class ServerManager {
Task { @MainActor in
// Only update sleep prevention if server is running
guard isRunning else { return }
// Check if preventSleepWhenRunning setting changed
let preventSleep = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
powerManager.updateSleepPrevention(enabled: preventSleep, serverRunning: true)
logger.info("Updated sleep prevention setting: \(preventSleep ? "enabled" : "disabled")")
}
}
@ -226,7 +226,7 @@ class ServerManager {
// This prevents a race condition where the server could crash after setting isRunning = true
let preventSleep = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
powerManager.updateSleepPrevention(enabled: preventSleep, serverRunning: true)
// Now update state
isRunning = true
lastError = nil
@ -276,7 +276,7 @@ class ServerManager {
// Clear the auth token from SessionMonitor
SessionMonitor.shared.setLocalAuthToken(nil)
// Allow sleep when server is stopped
powerManager.updateSleepPrevention(enabled: false, serverRunning: false)
@ -396,7 +396,7 @@ class ServerManager {
// Update state immediately
isRunning = false
bunServer = nil
// Allow sleep when server crashes
powerManager.updateSleepPrevention(enabled: false, serverRunning: false)
@ -516,7 +516,7 @@ class ServerManager {
let preventSleep = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
powerManager.updateSleepPrevention(enabled: preventSleep, serverRunning: true)
}
while true {
try? await Task.sleep(for: .seconds(30))

View file

@ -5,18 +5,18 @@ import os.log
/// Server session information returned by the API
struct ServerSessionInfo: Codable {
let id: String
let command: [String] // Changed from String to [String] to match server
let name: String? // Added missing field
let command: [String] // Changed from String to [String] to match server
let name: String? // Added missing field
let workingDir: String
let status: String
let exitCode: Int?
let startedAt: String
let lastModified: String
let pid: Int? // Made optional since it might not exist for all sessions
let initialCols: Int? // Added missing field
let initialRows: Int? // Added missing field
let pid: Int? // Made optional since it might not exist for all sessions
let initialCols: Int? // Added missing field
let initialRows: Int? // Added missing field
let activityStatus: ActivityStatus?
let source: String? // Added for HQ mode
let source: String? // Added for HQ mode
var isRunning: Bool {
status == "running"
@ -123,13 +123,19 @@ final class SessionMonitor {
self.sessions = sessionsDict
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,24 +57,65 @@ final class WindowTracker {
) {
logger.info("Registering window for session: \(sessionID), terminal: \(terminalApp.rawValue)")
// Give the terminal some time to create the window
Task {
try? await Task.sleep(for: .seconds(1.0))
// 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: .milliseconds(500))
// Find the most recently created window for this terminal
if let windowInfo = findWindow(
for: terminalApp,
sessionID: sessionID,
tabReference: tabReference,
tabID: tabID
) {
mapLock.withLock {
sessionWindowMap[sessionID] = windowInfo
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) with explicit ID"
)
}
logger.info("Successfully registered window \(windowInfo.windowID) for session \(sessionID)")
} else {
logger.warning("Could not find window for session \(sessionID)")
}
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)
}
}
@ -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 {
return nil
// 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
)
}
}
// Create a new WindowInfo with the session information
return WindowInfo(
windowID: latestWindow.windowID,
ownerPID: latestWindow.ownerPID,
// 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
}
/// 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,9 +337,87 @@ 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.
private func focusTerminalAppWindow(_ windowInfo: WindowInfo) {
if let tabRef = windowInfo.tabReference {
@ -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")
{
logger.info("Found potential window for session \(sessionID): \(title)")
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)
{
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

@ -181,13 +181,13 @@ struct MenuBarView: View {
while !serverManager.isRunning {
try? await Task.sleep(for: .milliseconds(500))
}
// Give the server a moment to fully initialize after starting
try? await Task.sleep(for: .milliseconds(100))
// Force initial refresh
await sessionMonitor.refresh()
// Update sessions periodically while view is visible
while true {
_ = await sessionMonitor.getSessions()
@ -339,28 +339,28 @@ struct SessionRowView: View {
.foregroundColor(.secondary)
}
}
// Activity status and path row
HStack(spacing: 4) {
Text(" ")
.font(.system(size: 11))
if let activityStatus = activityStatus {
if let activityStatus {
Text(activityStatus)
.font(.system(size: 11))
.foregroundColor(.orange)
Text("·")
.font(.system(size: 11))
.foregroundColor(.secondary.opacity(0.5))
}
Text(compactPath)
.font(.system(size: 11))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
}
}
@ -407,31 +407,31 @@ struct SessionRowView: View {
}
return name
}
private var activityStatus: String? {
if let specificStatus = session.value.activityStatus?.specificStatus {
return specificStatus.status
}
return nil
}
private var compactPath: String {
let path = session.value.workingDir
let homeDir = NSHomeDirectory()
// Replace home directory with ~
if path.hasPrefix(homeDir) {
let relativePath = String(path.dropFirst(homeDir.count))
return "~" + relativePath
}
// For other paths, show last two components
let components = (path as NSString).pathComponents
if components.count > 2 {
let lastTwo = components.suffix(2).joined(separator: "/")
return ".../" + lastTwo
}
return path
}
}

View file

@ -40,7 +40,7 @@ struct SessionDetailView: View {
DetailRow(label: "Status", value: session.status.capitalized)
DetailRow(label: "Started At", value: formatDate(session.startedAt))
DetailRow(label: "Last Modified", value: formatDate(session.lastModified))
if let pid = session.pid {
DetailRow(label: "Process ID", value: "\(pid)")
}

View file

@ -30,7 +30,7 @@ struct GeneralSettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
// Prevent Sleep
VStack(alignment: .leading, spacing: 4) {
Toggle("Prevent Sleep When Running", isOn: $preventSleepWhenRunning)

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,36 +4,35 @@ import Testing
@Suite("AppleScript Executor Tests", .tags(.integration))
struct AppleScriptExecutorTests {
@Test("Execute simple AppleScript")
@MainActor
func executeSimpleScript() throws {
let script = """
return "Hello from AppleScript"
"""
let result = try AppleScriptExecutor.shared.executeWithResult(script)
#expect(result == "Hello from AppleScript")
}
@Test("Execute script with math")
@MainActor
func executeScriptWithMath() throws {
let script = """
return 2 + 2
"""
let result = try AppleScriptExecutor.shared.executeWithResult(script)
#expect(result == "4")
}
@Test("Handle script error")
@MainActor
func handleScriptError() throws {
let script = """
error "This is a test error"
"""
do {
_ = try AppleScriptExecutor.shared.executeWithResult(script)
Issue.record("Expected error to be thrown")
@ -41,14 +40,14 @@ struct AppleScriptExecutorTests {
#expect(error.localizedDescription.contains("test error"))
}
}
@Test("Handle invalid syntax")
@MainActor
func handleInvalidSyntax() throws {
let script = """
this is not valid applescript syntax
"""
do {
_ = try AppleScriptExecutor.shared.executeWithResult(script)
Issue.record("Expected error to be thrown")
@ -57,12 +56,12 @@ struct AppleScriptExecutorTests {
#expect(error is AppleScriptError)
}
}
@Test("Execute empty script")
@MainActor
func executeEmptyScript() throws {
let script = ""
do {
let result = try AppleScriptExecutor.shared.executeWithResult(script)
#expect(result.isEmpty || result == "missing value")
@ -71,7 +70,7 @@ struct AppleScriptExecutorTests {
#expect(error is AppleScriptError)
}
}
@Test("Check Terminal application", .disabled("Slow test - 0.44 seconds"))
@MainActor
func checkTerminalApplication() throws {
@ -80,19 +79,19 @@ struct AppleScriptExecutorTests {
return exists application process "Terminal"
end tell
"""
let result = try AppleScriptExecutor.shared.executeWithResult(script)
// Result will be "true" or "false" as a string
#expect(result == "true" || result == "false")
}
@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)
}
@Test("Singleton instance")
@MainActor
func singletonInstance() {
@ -100,4 +99,4 @@ struct AppleScriptExecutorTests {
let instance2 = AppleScriptExecutor.shared
#expect(instance1 === instance2)
}
}
}

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() {
@ -13,21 +12,21 @@ struct DockIconManagerTests {
let instance2 = DockIconManager.shared
#expect(instance1 === instance2)
}
@Test("Update dock visibility based on windows")
@MainActor
func updateDockVisibilityBasedOnWindows() {
let manager = DockIconManager.shared
// Save original preference
let originalPref = UserDefaults.standard.bool(forKey: "showInDock")
// Set preference to hide dock
UserDefaults.standard.set(false, forKey: "showInDock")
// Update visibility - with no windows, dock should be hidden
manager.updateDockVisibility()
// The policy depends on whether there are windows open
// In test environment, NSApp might be nil
if let app = NSApp {
@ -36,19 +35,19 @@ struct DockIconManagerTests {
// In test environment without NSApp, just verify no crash
#expect(true)
}
// Restore original preference
UserDefaults.standard.set(originalPref, forKey: "showInDock")
}
@Test("Temporarily show dock")
@MainActor
func temporarilyShowDock() {
let manager = DockIconManager.shared
// Call temporarilyShowDock
manager.temporarilyShowDock()
// In CI environment, NSApp might behave differently
if let app = NSApp {
// Accept either regular or accessory since CI environment differs
@ -58,13 +57,13 @@ struct DockIconManagerTests {
#expect(true)
}
}
@Test("Dock visibility with user preference")
@MainActor
@MainActor
func dockVisibilityWithUserPreference() {
let manager = DockIconManager.shared
let originalPref = UserDefaults.standard.bool(forKey: "showInDock")
// Test with showInDock = true (user wants dock visible)
UserDefaults.standard.set(true, forKey: "showInDock")
manager.updateDockVisibility()
@ -75,7 +74,7 @@ struct DockIconManagerTests {
// In test environment without NSApp, just verify no crash
#expect(true)
}
// Test with showInDock = false (user wants dock hidden)
UserDefaults.standard.set(false, forKey: "showInDock")
manager.updateDockVisibility()
@ -87,7 +86,7 @@ struct DockIconManagerTests {
// In test environment without NSApp, just verify no crash
#expect(true)
}
// Restore
UserDefaults.standard.set(originalPref, forKey: "showInDock")
}

View file

@ -5,8 +5,8 @@ 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
func singletonInstance() {
@ -14,7 +14,7 @@ struct NgrokServiceTests {
let instance2 = NgrokService.shared
#expect(instance1 === instance2)
}
@Test("Initial state")
@MainActor
func initialState() {
@ -23,40 +23,40 @@ struct NgrokServiceTests {
#expect(service.publicUrl == nil)
#expect(service.tunnelStatus == nil)
}
@Test("Auth token management")
@MainActor
func authTokenManagement() {
let service = NgrokService.shared
// Save original token
let originalToken = service.authToken
// Set test token
service.authToken = testAuthToken
#expect(service.authToken == testAuthToken)
#expect(service.hasAuthToken == true)
// Clear token
service.authToken = nil
#expect(service.authToken == nil)
#expect(service.hasAuthToken == false)
// Restore original token
service.authToken = originalToken
}
@Test("Start without auth token fails")
@MainActor
func startWithoutAuthToken() async throws {
let service = NgrokService.shared
// Save original token
let originalToken = service.authToken
// Clear token
service.authToken = nil
do {
_ = try await service.start(port: testPort)
Issue.record("Expected error to be thrown")
@ -65,51 +65,51 @@ struct NgrokServiceTests {
} catch {
Issue.record("Expected NgrokError.authTokenMissing")
}
// Restore original token
service.authToken = originalToken
}
@Test("Stop when not running")
@MainActor
func stopWhenNotRunning() async throws {
let service = NgrokService.shared
// Ensure not running
if service.isActive {
try await service.stop()
}
// Stop again should be safe
try await service.stop()
#expect(service.isActive == false)
#expect(service.publicUrl == nil)
}
@Test("Is running check")
@MainActor
func isRunningCheck() async {
let service = NgrokService.shared
let running = await service.isRunning()
#expect(running == service.isActive)
}
@Test("Get status when inactive")
@MainActor
func getStatusWhenInactive() async {
let service = NgrokService.shared
// Ensure not running
if service.isActive {
try? await service.stop()
}
let status = await service.getStatus()
#expect(status == nil)
}
@Test("NgrokError descriptions")
func ngrokErrorDescriptions() {
let errors: [NgrokError] = [
@ -119,13 +119,13 @@ struct NgrokServiceTests {
.invalidConfiguration,
.networkError("connection failed")
]
for error in errors {
#expect(error.errorDescription != nil)
#expect(!error.errorDescription!.isEmpty)
}
}
@Test("NgrokError equality")
func ngrokErrorEquality() {
#expect(NgrokError.notInstalled == NgrokError.notInstalled)
@ -133,4 +133,4 @@ struct NgrokServiceTests {
#expect(NgrokError.tunnelCreationFailed("a") == NgrokError.tunnelCreationFailed("a"))
#expect(NgrokError.tunnelCreationFailed("a") != NgrokError.tunnelCreationFailed("b"))
}
}
}

View file

@ -1,129 +1,128 @@
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
@Test("Sleep prevention defaults to true when key doesn't exist")
func sleepPreventionDefaultValue() async {
// Save current value
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)
}
}
// Remove the key to simulate first launch
UserDefaults.standard.removeObject(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
// Test our helper method returns true for non-existent key
let defaultValue = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
#expect(defaultValue == true, "Sleep prevention should default to true when key doesn't exist")
// Verify UserDefaults.standard.bool returns false (the bug we're fixing)
let standardDefault = UserDefaults.standard.bool(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
#expect(standardDefault == false, "UserDefaults.standard.bool returns false for non-existent keys")
}
@Test("Update sleep prevention logic with all combinations")
func updateSleepPreventionLogic() async {
let service = PowerManagementService.shared
// Ensure clean state
service.allowSleep()
// Test Case 1: Both enabled and server running should prevent sleep
service.updateSleepPrevention(enabled: true, serverRunning: true)
#expect(service.isSleepPrevented)
// Test Case 2: Disabled setting should allow sleep
service.updateSleepPrevention(enabled: false, serverRunning: true)
#expect(!service.isSleepPrevented)
// Test Case 3: Server not running should allow sleep
service.updateSleepPrevention(enabled: true, serverRunning: false)
#expect(!service.isSleepPrevented)
// Test Case 4: Both false should allow sleep
service.updateSleepPrevention(enabled: false, serverRunning: false)
#expect(!service.isSleepPrevented)
// Cleanup
service.allowSleep()
}
@Test("Multiple prevent sleep calls are idempotent")
func preventSleepIdempotency() async {
let service = PowerManagementService.shared
// Ensure clean state
service.allowSleep()
// Call preventSleep multiple times
service.preventSleep()
let firstState = service.isSleepPrevented
service.preventSleep()
service.preventSleep()
// State should remain the same
#expect(service.isSleepPrevented == firstState)
// Cleanup
service.allowSleep()
}
@Test("Multiple allow sleep calls are idempotent")
func allowSleepIdempotency() async {
let service = PowerManagementService.shared
// Set up initial state
service.preventSleep()
// Call allowSleep multiple times
service.allowSleep()
#expect(!service.isSleepPrevented)
service.allowSleep()
service.allowSleep()
// State should remain false
#expect(!service.isSleepPrevented)
}
@Test("State transitions work correctly")
func stateTransitions() async {
let service = PowerManagementService.shared
// Ensure clean state
service.allowSleep()
#expect(!service.isSleepPrevented)
// Prevent sleep
service.preventSleep()
#expect(service.isSleepPrevented)
// Allow sleep again
service.allowSleep()
#expect(!service.isSleepPrevented)
// Use updateSleepPrevention
service.updateSleepPrevention(enabled: true, serverRunning: true)
#expect(service.isSleepPrevented)
service.updateSleepPrevention(enabled: false, serverRunning: false)
#expect(!service.isSleepPrevented)
// Cleanup
service.allowSleep()
}
@ -134,33 +133,32 @@ struct PowerManagementServiceTests {
@Suite("Power Management Edge Cases")
@MainActor
struct PowerManagementEdgeCaseTests {
@Test("Rapid state changes handle correctly")
func rapidStateChanges() async {
let service = PowerManagementService.shared
// Ensure clean state
service.allowSleep()
// Rapidly toggle state
for _ in 0..<10 {
service.preventSleep()
service.allowSleep()
}
// Final state should be sleep allowed
#expect(!service.isSleepPrevented)
// Now rapidly toggle with updateSleepPrevention
for i in 0..<10 {
let enabled = i % 2 == 0
service.updateSleepPrevention(enabled: enabled, serverRunning: true)
}
// Final state should match last call (i=9, odd, so enabled=false)
#expect(!service.isSleepPrevented)
// Cleanup
service.allowSleep()
}
}
}

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]
@ -485,4 +487,4 @@ final class SessionMonitorTests {
// Cached access should be very fast
#expect(elapsed < 0.1, "Cached access took too long: \(elapsed)s for 100 calls")
}
}
}

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() {
@ -13,54 +12,54 @@ struct StartupManagerTests {
// Just verify we can create an instance
#expect(manager.isLaunchAtLoginEnabled == true || manager.isLaunchAtLoginEnabled == false)
}
@Test("Initial launch at login state")
@MainActor
func initialLaunchAtLoginState() {
let manager = StartupManager()
// The initial state depends on system configuration
// We just verify it returns a boolean
let state = manager.isLaunchAtLoginEnabled
#expect(state == true || state == false)
}
@Test("Set launch at login")
@MainActor
func setLaunchAtLogin() {
let manager = StartupManager()
// Try to enable (may fail in test environment)
manager.setLaunchAtLogin(enabled: true)
// Try to disable (may fail in test environment)
manager.setLaunchAtLogin(enabled: false)
// We can't verify the actual state change in tests
// Just ensure the methods don't crash
#expect(true)
}
@Test("Service management availability")
@available(macOS 13.0, *)
func serviceManagementAvailability() {
// Test that we can at least query the service status
let service = SMAppService.mainApp
// Status should be queryable
let status = service.status
// We just verify that we can get a status without crashing
// The actual value depends on the test environment
#expect(status.rawValue >= 0)
}
@Test("App bundle identifier")
func appBundleIdentifier() {
// 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)
@ -70,18 +69,18 @@ struct StartupManagerTests {
#expect(bundleId == nil)
}
}
@Test("Multiple operations")
@MainActor
func multipleOperations() {
let manager = StartupManager()
// Perform multiple operations
manager.setLaunchAtLogin(enabled: true)
manager.setLaunchAtLogin(enabled: false)
manager.setLaunchAtLogin(enabled: true)
// Just ensure no crashes
#expect(true)
}
}
}

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 {