mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-24 14:47:39 +00:00
- 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.
365 lines
11 KiB
Swift
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
|
|
}
|
|
}
|