From 42021bb51415d2fb2fa2a70f91ec50db95cac5ef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 2 Jul 2025 00:00:19 +0100 Subject: [PATCH] Fix inconsistent button state management - Remove all uses of deprecated highlight() method in CustomMenuWindow - Consistently use state property for NSStatusBarButton management - Update StatusBarMenuManager to reset button state when menu state is .none - Fix concurrency issues in CustomMenuWindow frame observer - Ensure button state is properly managed throughout menu lifecycle This fixes the issue where the button could display inconsistent visual states or get stuck due to conflicting approaches between highlight() and state. --- mac/CHANGELOG.md | 2 +- .../Core/Services/WindowTracker.swift | 179 ++++++++++-------- .../Components/CustomMenuWindow.swift | 51 +++-- .../Components/NewSessionForm.swift | 6 +- .../Components/StatusBarController.swift | 4 +- .../Components/StatusBarMenuManager.swift | 20 +- .../Components/VibeTunnelMenuView.swift | 13 +- .../Shapes/SideRoundedRectangle.swift | 8 +- .../Presentation/Views/MenuBarView.swift | 13 +- mac/VibeTunnel/Utilities/CLIInstaller.swift | 7 +- 10 files changed, 166 insertions(+), 137 deletions(-) diff --git a/mac/CHANGELOG.md b/mac/CHANGELOG.md index 7a71342f..3e1f6265 100644 --- a/mac/CHANGELOG.md +++ b/mac/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [1.0.0-beta.6] - 2025-01-01 +## [1.0.0-beta.6] - 2025-07-02 ### ✨ New Features - **Sleep Prevention** - Mac now stays awake when VibeTunnel is running terminal sessions diff --git a/mac/VibeTunnel/Core/Services/WindowTracker.swift b/mac/VibeTunnel/Core/Services/WindowTracker.swift index 5b99ebe9..016088c4 100644 --- a/mac/VibeTunnel/Core/Services/WindowTracker.swift +++ b/mac/VibeTunnel/Core/Services/WindowTracker.swift @@ -138,7 +138,7 @@ final class WindowTracker { guard let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { return [] } - + let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "WindowTracker") return windowList.compactMap { windowDict in @@ -149,9 +149,9 @@ final class WindowTracker { else { return nil } - + // Log suspicious window IDs for debugging - if windowID < 1000 && windowID == CGWindowID(ownerPID) { + if windowID < 1_000 && windowID == CGWindowID(ownerPID) { logger.warning("Suspicious window ID \(windowID) matches PID for \(ownerName)") } @@ -215,7 +215,7 @@ final class WindowTracker { // First try to find window by process PID traversal if let sessionInfo = getSessionInfo(for: sessionID), let sessionPID = sessionInfo.pid { logger.debug("Attempting to find window by process PID: \(sessionPID)") - + // For debugging: log the process tree logProcessTree(for: pid_t(sessionPID)) @@ -248,7 +248,10 @@ final class WindowTracker { if let matchingWindow = terminalWindows.first(where: { window in window.ownerPID == grandParentPID }) { - logger.info("Found window by ancestor process match: PID \(grandParentPID) at depth \(depth + 2)") + logger + .info( + "Found window by ancestor process match: PID \(grandParentPID) at depth \(depth + 2)" + ) return createWindowInfo( from: matchingWindow, sessionID: sessionID, @@ -391,7 +394,7 @@ final class WindowTracker { return nil } - + /// Get process info including name private func getProcessInfo(for pid: pid_t) -> (name: String, ppid: pid_t)? { var info = kinfo_proc() @@ -409,13 +412,13 @@ final class WindowTracker { return nil } - + /// Log the process tree for debugging private func logProcessTree(for pid: pid_t) { var currentPID = pid var depth = 0 var processPath: [String] = [] - + while depth < 15 { if let processInfo = getProcessInfo(for: currentPID) { processPath.append("\(processInfo.name):\(currentPID)") @@ -428,7 +431,7 @@ final class WindowTracker { break } } - + logger.debug("Process tree: \(processPath.joined(separator: " <- "))") } @@ -673,8 +676,11 @@ final class WindowTracker { /// Focuses a Ghostty window with macOS standard tabs. private func focusGhosttyWindow(_ windowInfo: WindowInfo) { - logger.info("Attempting to focus Ghostty window - windowID: \(windowInfo.windowID), ownerPID: \(windowInfo.ownerPID), sessionID: \(windowInfo.sessionID)") - + logger + .info( + "Attempting to focus Ghostty window - windowID: \(windowInfo.windowID), ownerPID: \(windowInfo.ownerPID), sessionID: \(windowInfo.sessionID)" + ) + // First bring the application to front if let app = NSRunningApplication(processIdentifier: windowInfo.ownerPID) { app.activate() @@ -683,13 +689,13 @@ final class WindowTracker { // Ghostty uses macOS standard tabs, so we need to: // 1. Focus the window // 2. Find and select the correct tab - + // Use Accessibility API to handle tab selection let axApp = AXUIElementCreateApplication(windowInfo.ownerPID) - + var windowsValue: CFTypeRef? let result = AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &windowsValue) - + guard result == .success, let windows = windowsValue as? [AXUIElement], !windows.isEmpty @@ -698,17 +704,17 @@ final class WindowTracker { focusWindowUsingAccessibility(windowInfo) return } - + // Find the matching window logger.debug("Looking for Ghostty window with ID \(windowInfo.windowID) among \(windows.count) windows") - + // If we have a very low window ID that matches PID, it's likely wrong - if windowInfo.windowID < 1000 && windowInfo.windowID == CGWindowID(windowInfo.ownerPID) { + if windowInfo.windowID < 1_000 && windowInfo.windowID == CGWindowID(windowInfo.ownerPID) { logger.warning("Window ID \(windowInfo.windowID) suspiciously matches PID, will try alternative matching") - + // In this case, we need to find the correct window by tab content let sessionInfo = getSessionInfo(for: windowInfo.sessionID) - + for (windowIndex, window) in windows.enumerated() { // Check tabs in this window var tabsValue: CFTypeRef? @@ -721,40 +727,40 @@ final class WindowTracker { // Found the window with matching tab AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef) AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef) - + // Select the correct tab selectGhosttyTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo) - + logger.info("Focused Ghostty window \(windowIndex) by tab content match") return } } } - + // If no matching tab found, use first window as fallback if !windows.isEmpty { let window = windows[0] AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef) AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef) - + logger.info("Focused first Ghostty window as final fallback") return } } - + // Try matching by window ID, but also prepare for tab-based matching var windowWithMatchingTab: (window: AXUIElement, tabs: [AXUIElement])? let sessionInfo = getSessionInfo(for: windowInfo.sessionID) - + for (windowIndex, window) in windows.enumerated() { var windowIDValue: CFTypeRef? let windowIDResult = AXUIElementCopyAttributeValue(window, kAXWindowAttribute as CFString, &windowIDValue) - + // Also get window title for debugging var titleValue: CFTypeRef? let titleResult = AXUIElementCopyAttributeValue(window, kAXTitleAttribute as CFString, &titleValue) let windowTitle = titleResult == .success ? (titleValue as? String ?? "no title") : "failed to get title" - + // Check tabs in this window for content matching var tabsValue: CFTypeRef? if AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue) == .success, @@ -767,59 +773,66 @@ final class WindowTracker { logger.debug("Window \(windowIndex) has matching tab for session \(windowInfo.sessionID)") } } - + if windowIDResult == .success { if let windowNumber = windowIDValue as? Int { - logger.debug("Window \(windowIndex): AX window ID = \(windowNumber), title = '\(windowTitle)', looking for \(windowInfo.windowID)") - + logger + .debug( + "Window \(windowIndex): AX window ID = \(windowNumber), title = '\(windowTitle)', looking for \(windowInfo.windowID)" + ) + if windowNumber == windowInfo.windowID { // Found the window by ID, make it main and focused AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef) AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef) - - // Now select the correct tab - var tabsValue2: CFTypeRef? - if AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue2) == .success, - let tabs = tabsValue2 as? [AXUIElement], - !tabs.isEmpty - { - // Use the helper method to select the correct tab - selectGhosttyTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo) + + // Now select the correct tab + var tabsValue2: CFTypeRef? + if AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue2) == .success, + let tabs = tabsValue2 as? [AXUIElement], + !tabs.isEmpty + { + // Use the helper method to select the correct tab + selectGhosttyTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo) + } + + logger.info("Focused Ghostty window by ID match") + return } - - logger.info("Focused Ghostty window by ID match") - return - } } else { - logger.debug("Window \(windowIndex): AX window ID value is not an Int: \(String(describing: windowIDValue))") + logger + .debug( + "Window \(windowIndex): AX window ID value is not an Int: \(String(describing: windowIDValue))" + ) } } else { - logger.debug("Window \(windowIndex): Failed to get AX window ID, error code: \(windowIDResult.rawValue)") + logger + .debug("Window \(windowIndex): Failed to get AX window ID, error code: \(windowIDResult.rawValue)") } } - + // If we couldn't find by window ID but found a window with matching tab content, use that if let matchingWindow = windowWithMatchingTab { AXUIElementSetAttributeValue(matchingWindow.window, kAXMainAttribute as CFString, true as CFTypeRef) AXUIElementSetAttributeValue(matchingWindow.window, kAXFocusedAttribute as CFString, true as CFTypeRef) - + // Select the correct tab selectGhosttyTab(tabs: matchingWindow.tabs, windowInfo: windowInfo, sessionInfo: sessionInfo) - + logger.info("Focused Ghostty window by tab content match (no window ID match)") return } - + // Fallback if we couldn't find the specific window logger.warning("Could not find matching Ghostty window with ID \(windowInfo.windowID), using fallback") - + // Log additional debugging info if windows.isEmpty { logger.error("No Ghostty windows found at all") } else { logger.debug("Ghostty windows found but none matched ID \(windowInfo.windowID)") } - + focusWindowUsingAccessibility(windowInfo) } @@ -855,7 +868,7 @@ final class WindowTracker { // Found the matching window, make it main and focused AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef) AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef) - + // For terminals that use macOS standard tabs, try to select the correct tab var tabsValue: CFTypeRef? if AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue) == .success, @@ -863,45 +876,55 @@ final class WindowTracker { !tabs.isEmpty { logger.info("Terminal has \(tabs.count) tabs, attempting to find correct one") - + // Try to find the tab with matching session info if let sessionInfo = getSessionInfo(for: windowInfo.sessionID) { let workingDir = sessionInfo.workingDir let dirName = (workingDir as NSString).lastPathComponent let sessionID = windowInfo.sessionID let activityStatus = sessionInfo.activityStatus?.specificStatus?.status - + // Try multiple matching strategies for (index, tab) in tabs.enumerated() { var titleValue: CFTypeRef? - if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success, - let title = titleValue as? String + if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == + .success, + let title = titleValue as? String { // Check for session ID match first (most precise) if title.contains(sessionID) || title.contains("TTY_SESSION_ID=\(sessionID)") { AXUIElementPerformAction(tab, kAXPressAction as CFString) - logger.info("Selected tab \(index) by session ID for terminal \(windowInfo.terminalApp.rawValue)") + logger + .info( + "Selected tab \(index) by session ID for terminal \(windowInfo.terminalApp.rawValue)" + ) return } - + // Check for activity status match (unique for dynamic activities) if let activity = activityStatus, !activity.isEmpty, title.contains(activity) { AXUIElementPerformAction(tab, kAXPressAction as CFString) - logger.info("Selected tab \(index) by activity '\(activity)' for terminal \(windowInfo.terminalApp.rawValue)") + logger + .info( + "Selected tab \(index) by activity '\(activity)' for terminal \(windowInfo.terminalApp.rawValue)" + ) return } - + // Check for directory match if title.contains(dirName) || title.contains(workingDir) { AXUIElementPerformAction(tab, kAXPressAction as CFString) - logger.info("Selected tab \(index) by directory for terminal \(windowInfo.terminalApp.rawValue)") + logger + .info( + "Selected tab \(index) by directory for terminal \(windowInfo.terminalApp.rawValue)" + ) return } } } } } - + logger.info("Focused window using Accessibility API") return } @@ -1162,16 +1185,16 @@ final class WindowTracker { SystemPermissionManager.shared.requestPermission(.accessibility) } } - + /// Find a tab that matches the session private func findMatchingTab(tabs: [AXUIElement], sessionInfo: ServerSessionInfo?) -> AXUIElement? { - guard let sessionInfo = sessionInfo else { return nil } - + guard let sessionInfo else { return nil } + let workingDir = sessionInfo.workingDir let dirName = (workingDir as NSString).lastPathComponent let sessionID = sessionInfo.id let activityStatus = sessionInfo.activityStatus?.specificStatus?.status - + for tab in tabs { var titleValue: CFTypeRef? if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success, @@ -1181,34 +1204,34 @@ final class WindowTracker { if title.contains(sessionID) || title.contains("TTY_SESSION_ID=\(sessionID)") { return tab } - + // Priority 2: Activity status with directory if let activity = activityStatus, !activity.isEmpty, title.contains(activity) { if title.contains(dirName) || title.contains(workingDir) { return tab } } - + // Priority 3: Command with directory if let command = sessionInfo.command.first, !command.isEmpty, title.contains(command) { if title.contains(dirName) || title.contains(workingDir) { return tab } } - + // Priority 4: Directory only if title.contains(dirName) || title.contains(workingDir) { return tab } } } - + return nil } - + /// Helper to select the correct Ghostty tab private func selectGhosttyTab(tabs: [AXUIElement], windowInfo: WindowInfo, sessionInfo: ServerSessionInfo?) { - guard let sessionInfo = sessionInfo else { + guard let sessionInfo else { // No session info, select last tab as fallback if let lastTab = tabs.last { AXUIElementPerformAction(lastTab, kAXPressAction as CFString) @@ -1216,12 +1239,12 @@ final class WindowTracker { } return } - + let workingDir = sessionInfo.workingDir let dirName = (workingDir as NSString).lastPathComponent let sessionID = windowInfo.sessionID let activityStatus = sessionInfo.activityStatus?.specificStatus?.status - + // Try multiple matching strategies as in the main method for (index, tab) in tabs.enumerated() { var titleValue: CFTypeRef? @@ -1234,7 +1257,7 @@ final class WindowTracker { logger.info("Selected Ghostty tab \(index) by session ID") return } - + // Priority 2: Activity status if let activity = activityStatus, !activity.isEmpty, title.contains(activity) { if title.contains(dirName) || title.contains(workingDir) { @@ -1243,7 +1266,7 @@ final class WindowTracker { return } } - + // Priority 3: Command if let command = sessionInfo.command.first, !command.isEmpty, title.contains(command) { if title.contains(dirName) || title.contains(workingDir) { @@ -1252,7 +1275,7 @@ final class WindowTracker { return } } - + // Priority 4: Directory only if title.contains(dirName) || title.contains(workingDir) { AXUIElementPerformAction(tab, kAXPressAction as CFString) @@ -1261,7 +1284,7 @@ final class WindowTracker { } } } - + // Fallback: select last tab if let lastTab = tabs.last { AXUIElementPerformAction(lastTab, kAXPressAction as CFString) diff --git a/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift b/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift index e4e3333b..93aacbec 100644 --- a/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift +++ b/mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift @@ -6,6 +6,10 @@ import SwiftUI /// 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. +private enum DesignConstants { + static let menuCornerRadius: CGFloat = 12 +} + @MainActor final class CustomMenuWindow: NSPanel { private var eventMonitor: Any? @@ -15,6 +19,8 @@ final class CustomMenuWindow: NSPanel { private var targetFrame: NSRect? private weak var statusBarButton: NSStatusBarButton? private var _isWindowVisible = false + private var frameObserver: Any? + private var lastBounds: CGRect = .zero /// Closure to be called when window hides var onHide: (() -> Void)? @@ -49,7 +55,7 @@ final class CustomMenuWindow: NSPanel { isMovableByWindowBackground = false hidesOnDeactivate = false isReleasedWhenClosed = false - + // Allow the window to become key but not main // This helps maintain button highlight state acceptsMouseMovedEvents = false @@ -66,19 +72,29 @@ final class CustomMenuWindow: NSPanel { // Create a custom mask layer for side-rounded corners let maskLayer = CAShapeLayer() - maskLayer.path = createSideRoundedPath(in: contentView.bounds, cornerRadius: 12) + maskLayer.path = createSideRoundedPath( + in: contentView.bounds, + cornerRadius: DesignConstants.menuCornerRadius + ) contentView.layer?.mask = maskLayer + lastBounds = contentView.bounds // Update mask when bounds change contentView.postsFrameChangedNotifications = true - NotificationCenter.default.addObserver( + self.frameObserver = NotificationCenter.default.addObserver( forName: NSView.frameDidChangeNotification, object: contentView, queue: .main ) { [weak self, weak contentView] _ in - guard let self = self, let contentView = contentView else { return } Task { @MainActor in - maskLayer.path = self.createSideRoundedPath(in: contentView.bounds, cornerRadius: 12) + guard let self, let contentView else { return } + let currentBounds = contentView.bounds + guard currentBounds != self.lastBounds else { return } + self.lastBounds = currentBounds + maskLayer.path = self.createSideRoundedPath( + in: currentBounds, + cornerRadius: DesignConstants.menuCornerRadius + ) } } @@ -91,9 +107,8 @@ final class CustomMenuWindow: NSPanel { } func show(relativeTo statusItemButton: NSStatusBarButton) { - // Store button reference and ensure it stays highlighted + // Store button reference (state should already be set by StatusBarMenuManager) self.statusBarButton = statusItemButton - statusItemButton.highlight(true) // First, make sure the SwiftUI hierarchy has laid itself out hostingController.view.layoutSubtreeIfNeeded() @@ -154,18 +169,14 @@ final class CustomMenuWindow: NSPanel { // Set all visual properties at once alphaValue = 1.0 - // Ensure button remains highlighted - statusBarButton?.highlight(true) + // Button state is managed by StatusBarMenuManager, don't change it here // Show window without activating the app aggressively // This helps maintain the button's highlight state orderFront(nil) makeKey() - // Force button highlight update again after window is shown - DispatchQueue.main.async { [weak self] in - self?.statusBarButton?.highlight(true) - } + // Button state is managed by StatusBarMenuManager // Set first responder after window is visible makeFirstResponder(self) @@ -259,8 +270,7 @@ final class CustomMenuWindow: NSPanel { // Mark window as not visible _isWindowVisible = false - // Reset button highlight when hiding - statusBarButton?.highlight(false) + // Button state will be reset by StatusBarMenuManager via onHide callback orderOut(nil) teardownEventMonitoring() onHide?() @@ -272,8 +282,7 @@ final class CustomMenuWindow: NSPanel { // Mark window as not visible _isWindowVisible = false - // Reset button highlight when window is ordered out - statusBarButton?.highlight(false) + // Button state will be reset by StatusBarMenuManager via onHide callback onHide?() } @@ -325,6 +334,9 @@ final class CustomMenuWindow: NSPanel { deinit { MainActor.assumeIsolated { teardownEventMonitoring() + if let observer = frameObserver { + NotificationCenter.default.removeObserver(observer) + } } } @@ -384,7 +396,6 @@ final class CustomMenuWindow: NSPanel { } } - /// A wrapper view that applies modern SwiftUI material background to menu content. struct CustomMenuContainer: View { @ViewBuilder let content: Content @@ -395,9 +406,9 @@ struct CustomMenuContainer: View { var body: some View { content .fixedSize() - .background(backgroundMaterial, in: SideRoundedRectangle(cornerRadius: 12)) + .background(backgroundMaterial, in: SideRoundedRectangle(cornerRadius: DesignConstants.menuCornerRadius)) .overlay( - SideRoundedRectangle(cornerRadius: 12) + SideRoundedRectangle(cornerRadius: DesignConstants.menuCornerRadius) .stroke(borderColor, lineWidth: 1) ) } diff --git a/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift b/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift index 77e86179..dfc32aad 100644 --- a/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift +++ b/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift @@ -292,7 +292,11 @@ struct NewSessionForm: View { .foregroundColor(.white) .background( RoundedRectangle(cornerRadius: 6) - .fill(command.isEmpty || workingDirectory.isEmpty ? Color.gray.opacity(0.4) : Color(red: 0.2, green: 0.6, blue: 0.3)) + .fill(command.isEmpty || workingDirectory.isEmpty ? Color.gray.opacity(0.4) : Color( + red: 0.2, + green: 0.6, + blue: 0.3 + )) ) .disabled(isCreating || command.isEmpty || workingDirectory.isEmpty) } diff --git a/mac/VibeTunnel/Presentation/Components/StatusBarController.swift b/mac/VibeTunnel/Presentation/Components/StatusBarController.swift index c6ee4839..13ea467b 100644 --- a/mac/VibeTunnel/Presentation/Components/StatusBarController.swift +++ b/mac/VibeTunnel/Presentation/Components/StatusBarController.swift @@ -63,10 +63,10 @@ final class StatusBarController: NSObject { button.action = #selector(handleClick(_:)) button.target = self button.sendAction(on: [.leftMouseUp, .rightMouseUp]) - + // Use pushOnPushOff for proper state management button.setButtonType(.pushOnPushOff) - + // Accessibility button.setAccessibilityTitle("VibeTunnel") button.setAccessibilityRole(.button) diff --git a/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift b/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift index 3fb53825..95e4feca 100644 --- a/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift +++ b/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift @@ -69,7 +69,10 @@ final class StatusBarMenuManager: NSObject { statusBarButton = button } - // No need to manage highlight here since we're using button state + // Reset button state when no menu is active + if newState == .none { + statusBarButton?.state = .off + } } // MARK: - Left-Click Custom Window Management @@ -144,20 +147,11 @@ final class StatusBarMenuManager: NSObject { // Show the custom window customWindow?.show(relativeTo: button) - - // Force immediate button highlight update after showing window - // This ensures the button stays highlighted even if there's a timing issue - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(10)) - button.highlight(true) - } } func hideCustomWindow() { customWindow?.hide() - // Reset button state - statusBarButton?.state = .off - // Note: state will be reset by the onHide callback + // Button state will be reset by updateMenuState(.none) in the onHide callback } var isCustomWindowVisible: Bool { @@ -189,7 +183,7 @@ final class StatusBarMenuManager: NSObject { // Store status item reference currentStatusItem = statusItem - + // Set the button's state to on for context menu button.state = .on @@ -356,7 +350,7 @@ extension StatusBarMenuManager: NSMenuDelegate { func menuDidClose(_ menu: NSMenu) { // Reset button state statusBarButton?.state = .off - + // Reset menu state when context menu closes updateMenuState(.none) diff --git a/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift b/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift index a8831e5d..0d2e480d 100644 --- a/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift +++ b/mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift @@ -469,13 +469,13 @@ struct SessionRow: View { .foregroundColor(.primary) .lineLimit(1) .truncationMode(.tail) - + // Show session name if available if let name = session.value.name, !name.isEmpty { Text("–") .font(.system(size: 12)) .foregroundColor(.secondary.opacity(0.6)) - + Text(name) .font(.system(size: 12)) .foregroundColor(.secondary) @@ -657,10 +657,10 @@ struct SessionRow: View { guard let firstCommand = session.value.command.first else { return "Unknown" } - + // Extract just the executable name from the path let executableName = (firstCommand as NSString).lastPathComponent - + // Special handling for common commands switch executableName { case "zsh", "bash", "sh": @@ -668,7 +668,8 @@ struct SessionRow: View { if session.value.command.count > 2, session.value.command.contains("-c"), let cIndex = session.value.command.firstIndex(of: "-c"), - cIndex + 1 < session.value.command.count { + cIndex + 1 < session.value.command.count + { let actualCommand = session.value.command[cIndex + 1] return (actualCommand as NSString).lastPathComponent } @@ -677,7 +678,7 @@ struct SessionRow: View { return executableName } } - + private var sessionName: String { // Use the session name if available, otherwise fall back to directory name if let name = session.value.name, !name.isEmpty { diff --git a/mac/VibeTunnel/Presentation/Shapes/SideRoundedRectangle.swift b/mac/VibeTunnel/Presentation/Shapes/SideRoundedRectangle.swift index 3d87e8c6..dee9d735 100644 --- a/mac/VibeTunnel/Presentation/Shapes/SideRoundedRectangle.swift +++ b/mac/VibeTunnel/Presentation/Shapes/SideRoundedRectangle.swift @@ -15,9 +15,6 @@ struct SideRoundedRectangle: Shape { // Top edge (flat) path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) - // Top-right corner (flat) - path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) - // Right edge with rounded corners path.addArc( center: CGPoint(x: rect.maxX - cornerRadius, y: rect.minY + cornerRadius), @@ -40,9 +37,6 @@ struct SideRoundedRectangle: Shape { // Bottom edge (flat) path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) - // Bottom-left corner (flat) - path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) - // Left edge with rounded corners path.addArc( center: CGPoint(x: rect.minX + cornerRadius, y: rect.maxY - cornerRadius), @@ -66,4 +60,4 @@ struct SideRoundedRectangle: Shape { return path } -} \ No newline at end of file +} diff --git a/mac/VibeTunnel/Presentation/Views/MenuBarView.swift b/mac/VibeTunnel/Presentation/Views/MenuBarView.swift index 2c60df7e..c1951bdc 100644 --- a/mac/VibeTunnel/Presentation/Views/MenuBarView.swift +++ b/mac/VibeTunnel/Presentation/Views/MenuBarView.swift @@ -349,13 +349,13 @@ struct SessionRowView: View { .foregroundColor(.primary) .lineLimit(1) .truncationMode(.tail) - + // Show session name if available if let name = session.value.name, !name.isEmpty { Text("–") .font(.system(size: 12)) .foregroundColor(.secondary.opacity(0.6)) - + Text(name) .font(.system(size: 12)) .foregroundColor(.secondary) @@ -435,10 +435,10 @@ struct SessionRowView: View { guard let firstCommand = session.value.command.first else { return "Unknown" } - + // Extract just the executable name from the path let executableName = (firstCommand as NSString).lastPathComponent - + // Special handling for common commands switch executableName { case "zsh", "bash", "sh": @@ -446,7 +446,8 @@ struct SessionRowView: View { if session.value.command.count > 2, session.value.command.contains("-c"), let cIndex = session.value.command.firstIndex(of: "-c"), - cIndex + 1 < session.value.command.count { + cIndex + 1 < session.value.command.count + { let actualCommand = session.value.command[cIndex + 1] return (actualCommand as NSString).lastPathComponent } @@ -455,7 +456,7 @@ struct SessionRowView: View { return executableName } } - + private var sessionName: String { // Extract the working directory name as the session name let workingDir = session.value.workingDir diff --git a/mac/VibeTunnel/Utilities/CLIInstaller.swift b/mac/VibeTunnel/Utilities/CLIInstaller.swift index 905fb384..7fbd14cb 100644 --- a/mac/VibeTunnel/Utilities/CLIInstaller.swift +++ b/mac/VibeTunnel/Utilities/CLIInstaller.swift @@ -59,15 +59,16 @@ final class CLIInstaller { vtTargetPath, "/opt/homebrew/bin/vt" ] - + for path in pathsToCheck { if FileManager.default.fileExists(atPath: path) { // Check if it contains the correct app path reference if let content = try? String(contentsOfFile: path, encoding: .utf8) { // Verify it's our wrapper script with all expected components if content.contains("VibeTunnel CLI wrapper") && - content.contains("$TRY_PATH/Contents/Resources/vibetunnel") && - content.contains("exec \"$VIBETUNNEL_BIN\" fwd") { + content.contains("$TRY_PATH/Contents/Resources/vibetunnel") && + content.contains("exec \"$VIBETUNNEL_BIN\" fwd") + { isCorrectlyInstalled = true logger.info("CLIInstaller: Found valid vt script at \(path)") break