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.
This commit is contained in:
Peter Steinberger 2025-07-02 00:00:19 +01:00
parent 920d96207b
commit 42021bb514
10 changed files with 166 additions and 137 deletions

View file

@ -1,6 +1,6 @@
# Changelog # Changelog
## [1.0.0-beta.6] - 2025-01-01 ## [1.0.0-beta.6] - 2025-07-02
### ✨ New Features ### ✨ New Features
- **Sleep Prevention** - Mac now stays awake when VibeTunnel is running terminal sessions - **Sleep Prevention** - Mac now stays awake when VibeTunnel is running terminal sessions

View file

@ -151,7 +151,7 @@ final class WindowTracker {
} }
// Log suspicious window IDs for debugging // 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)") logger.warning("Suspicious window ID \(windowID) matches PID for \(ownerName)")
} }
@ -248,7 +248,10 @@ final class WindowTracker {
if let matchingWindow = terminalWindows.first(where: { window in if let matchingWindow = terminalWindows.first(where: { window in
window.ownerPID == grandParentPID 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( return createWindowInfo(
from: matchingWindow, from: matchingWindow,
sessionID: sessionID, sessionID: sessionID,
@ -673,7 +676,10 @@ final class WindowTracker {
/// Focuses a Ghostty window with macOS standard tabs. /// Focuses a Ghostty window with macOS standard tabs.
private func focusGhosttyWindow(_ windowInfo: WindowInfo) { 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 // First bring the application to front
if let app = NSRunningApplication(processIdentifier: windowInfo.ownerPID) { if let app = NSRunningApplication(processIdentifier: windowInfo.ownerPID) {
@ -703,7 +709,7 @@ final class WindowTracker {
logger.debug("Looking for Ghostty window with ID \(windowInfo.windowID) among \(windows.count) windows") 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 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") 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 // In this case, we need to find the correct window by tab content
@ -770,7 +776,10 @@ final class WindowTracker {
if windowIDResult == .success { if windowIDResult == .success {
if let windowNumber = windowIDValue as? Int { 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 { if windowNumber == windowInfo.windowID {
// Found the window by ID, make it main and focused // Found the window by ID, make it main and focused
@ -791,10 +800,14 @@ final class WindowTracker {
return return
} }
} else { } 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 { } 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)")
} }
} }
@ -874,27 +887,37 @@ final class WindowTracker {
// Try multiple matching strategies // Try multiple matching strategies
for (index, tab) in tabs.enumerated() { for (index, tab) in tabs.enumerated() {
var titleValue: CFTypeRef? var titleValue: CFTypeRef?
if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success, if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) ==
.success,
let title = titleValue as? String let title = titleValue as? String
{ {
// Check for session ID match first (most precise) // Check for session ID match first (most precise)
if title.contains(sessionID) || title.contains("TTY_SESSION_ID=\(sessionID)") { if title.contains(sessionID) || title.contains("TTY_SESSION_ID=\(sessionID)") {
AXUIElementPerformAction(tab, kAXPressAction as CFString) 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 return
} }
// Check for activity status match (unique for dynamic activities) // Check for activity status match (unique for dynamic activities)
if let activity = activityStatus, !activity.isEmpty, title.contains(activity) { if let activity = activityStatus, !activity.isEmpty, title.contains(activity) {
AXUIElementPerformAction(tab, kAXPressAction as CFString) 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 return
} }
// Check for directory match // Check for directory match
if title.contains(dirName) || title.contains(workingDir) { if title.contains(dirName) || title.contains(workingDir) {
AXUIElementPerformAction(tab, kAXPressAction as CFString) 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 return
} }
} }
@ -1165,7 +1188,7 @@ final class WindowTracker {
/// Find a tab that matches the session /// Find a tab that matches the session
private func findMatchingTab(tabs: [AXUIElement], sessionInfo: ServerSessionInfo?) -> AXUIElement? { 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 workingDir = sessionInfo.workingDir
let dirName = (workingDir as NSString).lastPathComponent let dirName = (workingDir as NSString).lastPathComponent
@ -1208,7 +1231,7 @@ final class WindowTracker {
/// Helper to select the correct Ghostty tab /// Helper to select the correct Ghostty tab
private func selectGhosttyTab(tabs: [AXUIElement], windowInfo: WindowInfo, sessionInfo: ServerSessionInfo?) { 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 // No session info, select last tab as fallback
if let lastTab = tabs.last { if let lastTab = tabs.last {
AXUIElementPerformAction(lastTab, kAXPressAction as CFString) AXUIElementPerformAction(lastTab, kAXPressAction as CFString)

View file

@ -6,6 +6,10 @@ import SwiftUI
/// Provides a dropdown-style window for the menu bar application /// Provides a dropdown-style window for the menu bar application
/// without the standard macOS popover arrow. Handles automatic positioning below /// without the standard macOS popover arrow. Handles automatic positioning below
/// the status item, click-outside dismissal, and proper window management. /// the status item, click-outside dismissal, and proper window management.
private enum DesignConstants {
static let menuCornerRadius: CGFloat = 12
}
@MainActor @MainActor
final class CustomMenuWindow: NSPanel { final class CustomMenuWindow: NSPanel {
private var eventMonitor: Any? private var eventMonitor: Any?
@ -15,6 +19,8 @@ final class CustomMenuWindow: NSPanel {
private var targetFrame: NSRect? private var targetFrame: NSRect?
private weak var statusBarButton: NSStatusBarButton? private weak var statusBarButton: NSStatusBarButton?
private var _isWindowVisible = false private var _isWindowVisible = false
private var frameObserver: Any?
private var lastBounds: CGRect = .zero
/// Closure to be called when window hides /// Closure to be called when window hides
var onHide: (() -> Void)? var onHide: (() -> Void)?
@ -66,19 +72,29 @@ final class CustomMenuWindow: NSPanel {
// Create a custom mask layer for side-rounded corners // Create a custom mask layer for side-rounded corners
let maskLayer = CAShapeLayer() let maskLayer = CAShapeLayer()
maskLayer.path = createSideRoundedPath(in: contentView.bounds, cornerRadius: 12) maskLayer.path = createSideRoundedPath(
in: contentView.bounds,
cornerRadius: DesignConstants.menuCornerRadius
)
contentView.layer?.mask = maskLayer contentView.layer?.mask = maskLayer
lastBounds = contentView.bounds
// Update mask when bounds change // Update mask when bounds change
contentView.postsFrameChangedNotifications = true contentView.postsFrameChangedNotifications = true
NotificationCenter.default.addObserver( self.frameObserver = NotificationCenter.default.addObserver(
forName: NSView.frameDidChangeNotification, forName: NSView.frameDidChangeNotification,
object: contentView, object: contentView,
queue: .main queue: .main
) { [weak self, weak contentView] _ in ) { [weak self, weak contentView] _ in
guard let self = self, let contentView = contentView else { return }
Task { @MainActor in 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) { 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 self.statusBarButton = statusItemButton
statusItemButton.highlight(true)
// First, make sure the SwiftUI hierarchy has laid itself out // First, make sure the SwiftUI hierarchy has laid itself out
hostingController.view.layoutSubtreeIfNeeded() hostingController.view.layoutSubtreeIfNeeded()
@ -154,18 +169,14 @@ final class CustomMenuWindow: NSPanel {
// Set all visual properties at once // Set all visual properties at once
alphaValue = 1.0 alphaValue = 1.0
// Ensure button remains highlighted // Button state is managed by StatusBarMenuManager, don't change it here
statusBarButton?.highlight(true)
// Show window without activating the app aggressively // Show window without activating the app aggressively
// This helps maintain the button's highlight state // This helps maintain the button's highlight state
orderFront(nil) orderFront(nil)
makeKey() makeKey()
// Force button highlight update again after window is shown // Button state is managed by StatusBarMenuManager
DispatchQueue.main.async { [weak self] in
self?.statusBarButton?.highlight(true)
}
// Set first responder after window is visible // Set first responder after window is visible
makeFirstResponder(self) makeFirstResponder(self)
@ -259,8 +270,7 @@ final class CustomMenuWindow: NSPanel {
// Mark window as not visible // Mark window as not visible
_isWindowVisible = false _isWindowVisible = false
// Reset button highlight when hiding // Button state will be reset by StatusBarMenuManager via onHide callback
statusBarButton?.highlight(false)
orderOut(nil) orderOut(nil)
teardownEventMonitoring() teardownEventMonitoring()
onHide?() onHide?()
@ -272,8 +282,7 @@ final class CustomMenuWindow: NSPanel {
// Mark window as not visible // Mark window as not visible
_isWindowVisible = false _isWindowVisible = false
// Reset button highlight when window is ordered out // Button state will be reset by StatusBarMenuManager via onHide callback
statusBarButton?.highlight(false)
onHide?() onHide?()
} }
@ -325,6 +334,9 @@ final class CustomMenuWindow: NSPanel {
deinit { deinit {
MainActor.assumeIsolated { MainActor.assumeIsolated {
teardownEventMonitoring() 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. /// A wrapper view that applies modern SwiftUI material background to menu content.
struct CustomMenuContainer<Content: View>: View { struct CustomMenuContainer<Content: View>: View {
@ViewBuilder let content: Content @ViewBuilder let content: Content
@ -395,9 +406,9 @@ struct CustomMenuContainer<Content: View>: View {
var body: some View { var body: some View {
content content
.fixedSize() .fixedSize()
.background(backgroundMaterial, in: SideRoundedRectangle(cornerRadius: 12)) .background(backgroundMaterial, in: SideRoundedRectangle(cornerRadius: DesignConstants.menuCornerRadius))
.overlay( .overlay(
SideRoundedRectangle(cornerRadius: 12) SideRoundedRectangle(cornerRadius: DesignConstants.menuCornerRadius)
.stroke(borderColor, lineWidth: 1) .stroke(borderColor, lineWidth: 1)
) )
} }

View file

@ -292,7 +292,11 @@ struct NewSessionForm: View {
.foregroundColor(.white) .foregroundColor(.white)
.background( .background(
RoundedRectangle(cornerRadius: 6) 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) .disabled(isCreating || command.isEmpty || workingDirectory.isEmpty)
} }

View file

@ -69,7 +69,10 @@ final class StatusBarMenuManager: NSObject {
statusBarButton = button 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 // MARK: - Left-Click Custom Window Management
@ -144,20 +147,11 @@ final class StatusBarMenuManager: NSObject {
// Show the custom window // Show the custom window
customWindow?.show(relativeTo: button) 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() { func hideCustomWindow() {
customWindow?.hide() customWindow?.hide()
// Reset button state // Button state will be reset by updateMenuState(.none) in the onHide callback
statusBarButton?.state = .off
// Note: state will be reset by the onHide callback
} }
var isCustomWindowVisible: Bool { var isCustomWindowVisible: Bool {

View file

@ -668,7 +668,8 @@ struct SessionRow: View {
if session.value.command.count > 2, if session.value.command.count > 2,
session.value.command.contains("-c"), session.value.command.contains("-c"),
let cIndex = session.value.command.firstIndex(of: "-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] let actualCommand = session.value.command[cIndex + 1]
return (actualCommand as NSString).lastPathComponent return (actualCommand as NSString).lastPathComponent
} }

View file

@ -15,9 +15,6 @@ struct SideRoundedRectangle: Shape {
// Top edge (flat) // Top edge (flat)
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) 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 // Right edge with rounded corners
path.addArc( path.addArc(
center: CGPoint(x: rect.maxX - cornerRadius, y: rect.minY + cornerRadius), center: CGPoint(x: rect.maxX - cornerRadius, y: rect.minY + cornerRadius),
@ -40,9 +37,6 @@ struct SideRoundedRectangle: Shape {
// Bottom edge (flat) // Bottom edge (flat)
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) 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 // Left edge with rounded corners
path.addArc( path.addArc(
center: CGPoint(x: rect.minX + cornerRadius, y: rect.maxY - cornerRadius), center: CGPoint(x: rect.minX + cornerRadius, y: rect.maxY - cornerRadius),

View file

@ -446,7 +446,8 @@ struct SessionRowView: View {
if session.value.command.count > 2, if session.value.command.count > 2,
session.value.command.contains("-c"), session.value.command.contains("-c"),
let cIndex = session.value.command.firstIndex(of: "-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] let actualCommand = session.value.command[cIndex + 1]
return (actualCommand as NSString).lastPathComponent return (actualCommand as NSString).lastPathComponent
} }

View file

@ -67,7 +67,8 @@ final class CLIInstaller {
// Verify it's our wrapper script with all expected components // Verify it's our wrapper script with all expected components
if content.contains("VibeTunnel CLI wrapper") && if content.contains("VibeTunnel CLI wrapper") &&
content.contains("$TRY_PATH/Contents/Resources/vibetunnel") && content.contains("$TRY_PATH/Contents/Resources/vibetunnel") &&
content.contains("exec \"$VIBETUNNEL_BIN\" fwd") { content.contains("exec \"$VIBETUNNEL_BIN\" fwd")
{
isCorrectlyInstalled = true isCorrectlyInstalled = true
logger.info("CLIInstaller: Found valid vt script at \(path)") logger.info("CLIInstaller: Found valid vt script at \(path)")
break break