diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 9dbd790c..aadc8606 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -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: | diff --git a/ios/VibeTunnel/Views/Welcome/WelcomeView.swift b/ios/VibeTunnel/Views/Welcome/WelcomeView.swift index ad637436..c715056c 100644 --- a/ios/VibeTunnel/Views/Welcome/WelcomeView.swift +++ b/ios/VibeTunnel/Views/Welcome/WelcomeView.swift @@ -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) diff --git a/mac/VibeTunnel/Core/Models/AppConstants.swift b/mac/VibeTunnel/Core/Models/AppConstants.swift index 77e0ca4e..b35c6c1f 100644 --- a/mac/VibeTunnel/Core/Models/AppConstants.swift +++ b/mac/VibeTunnel/Core/Models/AppConstants.swift @@ -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 diff --git a/mac/VibeTunnel/Core/Services/PowerManagementService.swift b/mac/VibeTunnel/Core/Services/PowerManagementService.swift index 8a6ac85d..12687d33 100644 --- a/mac/VibeTunnel/Core/Services/PowerManagementService.swift +++ b/mac/VibeTunnel/Core/Services/PowerManagementService.swift @@ -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 } -} \ No newline at end of file +} diff --git a/mac/VibeTunnel/Core/Services/ServerManager.swift b/mac/VibeTunnel/Core/Services/ServerManager.swift index 7f2c767f..6fc02daa 100644 --- a/mac/VibeTunnel/Core/Services/ServerManager.swift +++ b/mac/VibeTunnel/Core/Services/ServerManager.swift @@ -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)) diff --git a/mac/VibeTunnel/Core/Services/SessionMonitor.swift b/mac/VibeTunnel/Core/Services/SessionMonitor.swift index a6bd3798..05081869 100644 --- a/mac/VibeTunnel/Core/Services/SessionMonitor.swift +++ b/mac/VibeTunnel/Core/Services/SessionMonitor.swift @@ -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 diff --git a/mac/VibeTunnel/Core/Services/WindowTracker.swift b/mac/VibeTunnel/Core/Services/WindowTracker.swift index 04864670..238fe987 100644 --- a/mac/VibeTunnel/Core/Services/WindowTracker.swift +++ b/mac/VibeTunnel/Core/Services/WindowTracker.swift @@ -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 diff --git a/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift b/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift new file mode 100644 index 00000000..2149028f --- /dev/null +++ b/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift @@ -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 + 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: 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) + } + } +} diff --git a/mac/VibeTunnel/Presentation/Components/StatusBarController+VisualIndicators.swift b/mac/VibeTunnel/Presentation/Components/StatusBarController+VisualIndicators.swift new file mode 100644 index 00000000..82b821a4 --- /dev/null +++ b/mac/VibeTunnel/Presentation/Components/StatusBarController+VisualIndicators.swift @@ -0,0 +1,123 @@ +import Foundation + +// MARK: - Visual Indicator Styles + +extension StatusBarController { + enum IndicatorStyle { + case dots // ●●● 5 (current implementation) + case bars // ▪︎▪︎▫︎▫︎▫︎ + case compact // 2◆5 + 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: +// ◆ ◇ ♦ ♢ ★ ☆ ✦ ✧ diff --git a/mac/VibeTunnel/Presentation/Components/StatusBarController.swift b/mac/VibeTunnel/Presentation/Components/StatusBarController.swift new file mode 100644 index 00000000..9fcffa94 --- /dev/null +++ b/mac/VibeTunnel/Presentation/Components/StatusBarController.swift @@ -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() + 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: 2◆5 + // .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() + } +} diff --git a/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift b/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift new file mode 100644 index 00000000..69dd157e --- /dev/null +++ b/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift @@ -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" + } +} diff --git a/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift b/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift new file mode 100644 index 00000000..ce171378 --- /dev/null +++ b/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift @@ -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) + } +} diff --git a/mac/VibeTunnel/Presentation/Components/VisualIndicatorExamples.md b/mac/VibeTunnel/Presentation/Components/VisualIndicatorExamples.md new file mode 100644 index 00000000..3cb28d61 --- /dev/null +++ b/mac/VibeTunnel/Presentation/Components/VisualIndicatorExamples.md @@ -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. \ No newline at end of file diff --git a/mac/VibeTunnel/Presentation/Views/AboutView.swift b/mac/VibeTunnel/Presentation/Views/AboutView.swift index 3a042ad5..b229dfb6 100644 --- a/mac/VibeTunnel/Presentation/Views/AboutView.swift +++ b/mac/VibeTunnel/Presentation/Views/AboutView.swift @@ -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.. 2 { let lastTwo = components.suffix(2).joined(separator: "/") return ".../" + lastTwo } - + return path } } diff --git a/mac/VibeTunnel/Presentation/Views/SessionDetailView.swift b/mac/VibeTunnel/Presentation/Views/SessionDetailView.swift index c7860b9f..d0ca584f 100644 --- a/mac/VibeTunnel/Presentation/Views/SessionDetailView.swift +++ b/mac/VibeTunnel/Presentation/Views/SessionDetailView.swift @@ -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)") } diff --git a/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift index 7a2d1e13..8c9badcb 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift @@ -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) diff --git a/mac/VibeTunnel/VibeTunnelApp.swift b/mac/VibeTunnel/VibeTunnelApp.swift index 0c17a330..853cad23 100644 --- a/mac/VibeTunnel/VibeTunnelApp.swift +++ b/mac/VibeTunnel/VibeTunnelApp.swift @@ -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 + ) + } } } diff --git a/mac/VibeTunnelTests/AppleScriptExecutorTests.swift b/mac/VibeTunnelTests/AppleScriptExecutorTests.swift index a5d6e5db..24356307 100644 --- a/mac/VibeTunnelTests/AppleScriptExecutorTests.swift +++ b/mac/VibeTunnelTests/AppleScriptExecutorTests.swift @@ -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) } -} \ No newline at end of file +} diff --git a/mac/VibeTunnelTests/DockIconManagerTests.swift b/mac/VibeTunnelTests/DockIconManagerTests.swift index 3f1ca0c2..96482fa4 100644 --- a/mac/VibeTunnelTests/DockIconManagerTests.swift +++ b/mac/VibeTunnelTests/DockIconManagerTests.swift @@ -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") } diff --git a/mac/VibeTunnelTests/NgrokServiceTests.swift b/mac/VibeTunnelTests/NgrokServiceTests.swift index 7e594664..1779cf25 100644 --- a/mac/VibeTunnelTests/NgrokServiceTests.swift +++ b/mac/VibeTunnelTests/NgrokServiceTests.swift @@ -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")) } -} \ No newline at end of file +} diff --git a/mac/VibeTunnelTests/PowerManagementServiceTests.swift b/mac/VibeTunnelTests/PowerManagementServiceTests.swift index f3a9947c..9f12a841 100644 --- a/mac/VibeTunnelTests/PowerManagementServiceTests.swift +++ b/mac/VibeTunnelTests/PowerManagementServiceTests.swift @@ -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() } -} \ No newline at end of file +} diff --git a/mac/VibeTunnelTests/ServerManagerTests.swift b/mac/VibeTunnelTests/ServerManagerTests.swift index 3b75bebc..8ee9cc80 100644 --- a/mac/VibeTunnelTests/ServerManagerTests.swift +++ b/mac/VibeTunnelTests/ServerManagerTests.swift @@ -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) } diff --git a/mac/VibeTunnelTests/SessionMonitorTests.swift b/mac/VibeTunnelTests/SessionMonitorTests.swift index a8a30262..d8e7eb7d 100644 --- a/mac/VibeTunnelTests/SessionMonitorTests.swift +++ b/mac/VibeTunnelTests/SessionMonitorTests.swift @@ -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") } -} \ No newline at end of file +} diff --git a/mac/VibeTunnelTests/StartupManagerTests.swift b/mac/VibeTunnelTests/StartupManagerTests.swift index 927248c6..8479a5ed 100644 --- a/mac/VibeTunnelTests/StartupManagerTests.swift +++ b/mac/VibeTunnelTests/StartupManagerTests.swift @@ -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) } -} \ No newline at end of file +} diff --git a/mac/VibeTunnelTests/Utilities/TestFixtures.swift b/mac/VibeTunnelTests/Utilities/TestFixtures.swift index d2b0ccf1..9c57abc2 100644 --- a/mac/VibeTunnelTests/Utilities/TestFixtures.swift +++ b/mac/VibeTunnelTests/Utilities/TestFixtures.swift @@ -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 {