vibetunnel/mac/VibeTunnel/Presentation/Components/StatusBarMenuManager.swift
Peter Steinberger 42021bb514 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.
2025-07-02 00:00:53 +01:00

365 lines
11 KiB
Swift

import AppKit
import SwiftUI
/// Manages status bar menu behavior, providing left-click custom view and right-click context menu functionality.
@MainActor
final class StatusBarMenuManager: NSObject {
// MARK: - Menu State Management
private enum MenuState {
case none
case customWindow
case contextMenu
}
// 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?
private weak var currentStatusItem: NSStatusItem?
// State management
private var menuState: MenuState = .none
private var highlightTask: Task<Void, Never>?
// MARK: - Initialization
override init() {
super.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: - State Management
private func updateMenuState(_ newState: MenuState, button: NSStatusBarButton? = nil) {
// Cancel any pending highlight task
highlightTask?.cancel()
// Update state
menuState = newState
// Update button reference if provided
if let button {
statusBarButton = button
}
// Reset button state when no menu is active
if newState == .none {
statusBarButton?.state = .off
}
}
// 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 }
// Update menu state to custom window FIRST before any async operations
updateMenuState(.customWindow, button: button)
// Ensure button state is set immediately and persistently
button.state = .on
// Force another button state update to ensure it sticks
DispatchQueue.main.async {
button.state = .on
}
// Create SessionService instance
let sessionService = SessionService(serverManager: serverManager, sessionMonitor: sessionMonitor)
// Create the main view with all dependencies
let mainView = VibeTunnelMenuView()
.environment(sessionMonitor)
.environment(serverManager)
.environment(ngrokService)
.environment(tailscaleService)
.environment(terminalLauncher)
.environment(sessionService)
// 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 reset state when window hides
customWindow?.onHide = { [weak self] in
// Ensure state is reset on main thread
Task { @MainActor in
self?.updateMenuState(.none)
}
}
} 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?.updateMenuState(.none)
}
}
}
// Show the custom window
customWindow?.show(relativeTo: button)
}
func hideCustomWindow() {
customWindow?.hide()
// Button state will be reset by updateMenuState(.none) in the onHide callback
}
var isCustomWindowVisible: Bool {
customWindow?.isWindowVisible ?? false
}
// MARK: - Menu State Management
func hideAllMenus() {
hideCustomWindow()
// If there's a context menu showing, dismiss it
if menuState == .contextMenu, let statusItem = currentStatusItem {
statusItem.menu = nil
}
// Reset state to none
updateMenuState(.none)
}
var isAnyMenuVisible: Bool {
// Check both the menu state and the actual window visibility
menuState != .none || (customWindow?.isWindowVisible ?? false)
}
// MARK: - Right-Click Context Menu
func showContextMenu(for button: NSStatusBarButton, statusItem: NSStatusItem) {
// Hide custom window first if it's visible
hideCustomWindow()
// Store status item reference
currentStatusItem = statusItem
// Set the button's state to on for context menu
button.state = .on
// Update menu state to context menu
updateMenuState(.contextMenu, button: button)
let menu = NSMenu()
menu.delegate = self
// 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())
// Restart server
let restartItem = NSMenuItem(title: "Restart", action: #selector(restartServer), keyEquivalent: "")
restartItem.target = self
menu.addItem(restartItem)
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
// Use popUpMenu for proper context menu display that doesn't interfere with button highlighting
menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 5), in: button)
}
// 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 restartServer() {
guard let serverManager else { return }
Task {
await serverManager.restart()
}
}
@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"
}
}
// MARK: - NSMenuDelegate
extension StatusBarMenuManager: NSMenuDelegate {
func menuDidClose(_ menu: NSMenu) {
// Reset button state
statusBarButton?.state = .off
// Reset menu state when context menu closes
updateMenuState(.none)
// Clean up the menu from status item
if let statusItem = currentStatusItem {
statusItem.menu = nil
}
// Clear the stored reference
currentStatusItem = nil
}
}