mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
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:
parent
920d96207b
commit
42021bb514
10 changed files with 166 additions and 137 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ final class WindowTracker {
|
|||
}
|
||||
|
||||
// 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)")
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
@ -673,7 +676,10 @@ 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) {
|
||||
|
|
@ -703,7 +709,7 @@ final class WindowTracker {
|
|||
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
|
||||
|
|
@ -770,31 +776,38 @@ final class WindowTracker {
|
|||
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -874,27 +887,37 @@ final class WindowTracker {
|
|||
// 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1165,7 +1188,7 @@ final class WindowTracker {
|
|||
|
||||
/// 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
|
||||
|
|
@ -1208,7 +1231,7 @@ final class WindowTracker {
|
|||
|
||||
/// 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)
|
||||
|
|
|
|||
|
|
@ -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)?
|
||||
|
|
@ -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<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
|
|
@ -395,9 +406,9 @@ struct CustomMenuContainer<Content: View>: 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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,8 +66,9 @@ final class CLIInstaller {
|
|||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue