mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
feat: Powerful Mac menu bar with rich view and session tracking (#176)
This commit is contained in:
parent
acf91e228d
commit
a7d5648c78
26 changed files with 2110 additions and 252 deletions
6
.github/workflows/ios.yml
vendored
6
.github/workflows/ios.yml
vendored
|
|
@ -135,12 +135,6 @@ jobs:
|
||||||
sleep 5
|
sleep 5
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Download web build artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: web-build-${{ github.sha }}
|
|
||||||
path: web/
|
|
||||||
|
|
||||||
- name: Resolve Dependencies (once)
|
- name: Resolve Dependencies (once)
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,8 @@ struct WelcomeView: View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(0..<5) { index in
|
ForEach(0..<5) { index in
|
||||||
Circle()
|
Circle()
|
||||||
.fill(index == currentPage ?
|
.fill(index == currentPage ?
|
||||||
Theme.Colors.primaryAccent :
|
Theme.Colors.primaryAccent :
|
||||||
Theme.Colors.secondaryText.opacity(0.3))
|
Theme.Colors.secondaryText.opacity(0.3))
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
.animation(.easeInOut, value: currentPage)
|
.animation(.easeInOut, value: currentPage)
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,13 @@ enum AppConstants {
|
||||||
static let welcomeVersion = "welcomeVersion"
|
static let welcomeVersion = "welcomeVersion"
|
||||||
static let preventSleepWhenRunning = "preventSleepWhenRunning"
|
static let preventSleepWhenRunning = "preventSleepWhenRunning"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default values for UserDefaults
|
/// Default values for UserDefaults
|
||||||
enum Defaults {
|
enum Defaults {
|
||||||
/// Sleep prevention is enabled by default for better user experience
|
/// Sleep prevention is enabled by default for better user experience
|
||||||
static let preventSleepWhenRunning = true
|
static let preventSleepWhenRunning = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to get boolean value with proper default
|
/// Helper to get boolean value with proper default
|
||||||
static func boolValue(for key: String) -> Bool {
|
static func boolValue(for key: String) -> Bool {
|
||||||
// If the key doesn't exist in UserDefaults, return our default
|
// If the key doesn't exist in UserDefaults, return our default
|
||||||
|
|
|
||||||
|
|
@ -12,28 +12,28 @@ import Observation
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PowerManagementService {
|
final class PowerManagementService {
|
||||||
static let shared = PowerManagementService()
|
static let shared = PowerManagementService()
|
||||||
|
|
||||||
private(set) var isSleepPrevented = false
|
private(set) var isSleepPrevented = false
|
||||||
|
|
||||||
private var assertionID: IOPMAssertionID = 0
|
private var assertionID: IOPMAssertionID = 0
|
||||||
private var isAssertionActive = false
|
private var isAssertionActive = false
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
/// Prevents the system from sleeping
|
/// Prevents the system from sleeping
|
||||||
func preventSleep() {
|
func preventSleep() {
|
||||||
guard !isAssertionActive else { return }
|
guard !isAssertionActive else { return }
|
||||||
|
|
||||||
let reason = "VibeTunnel is running terminal sessions" as CFString
|
let reason = "VibeTunnel is running terminal sessions" as CFString
|
||||||
let assertionType = kIOPMAssertionTypeNoIdleSleep as CFString
|
let assertionType = kIOPMAssertionTypeNoIdleSleep as CFString
|
||||||
|
|
||||||
let success = IOPMAssertionCreateWithName(
|
let success = IOPMAssertionCreateWithName(
|
||||||
assertionType,
|
assertionType,
|
||||||
IOPMAssertionLevel(kIOPMAssertionLevelOn),
|
IOPMAssertionLevel(kIOPMAssertionLevelOn),
|
||||||
reason,
|
reason,
|
||||||
&assertionID
|
&assertionID
|
||||||
)
|
)
|
||||||
|
|
||||||
if success == kIOReturnSuccess {
|
if success == kIOReturnSuccess {
|
||||||
isAssertionActive = true
|
isAssertionActive = true
|
||||||
isSleepPrevented = true
|
isSleepPrevented = true
|
||||||
|
|
@ -42,13 +42,13 @@ final class PowerManagementService {
|
||||||
print("Failed to prevent sleep: \(success)")
|
print("Failed to prevent sleep: \(success)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Allows the system to sleep normally
|
/// Allows the system to sleep normally
|
||||||
func allowSleep() {
|
func allowSleep() {
|
||||||
guard isAssertionActive else { return }
|
guard isAssertionActive else { return }
|
||||||
|
|
||||||
let success = IOPMAssertionRelease(assertionID)
|
let success = IOPMAssertionRelease(assertionID)
|
||||||
|
|
||||||
if success == kIOReturnSuccess {
|
if success == kIOReturnSuccess {
|
||||||
isAssertionActive = false
|
isAssertionActive = false
|
||||||
isSleepPrevented = false
|
isSleepPrevented = false
|
||||||
|
|
@ -58,7 +58,7 @@ final class PowerManagementService {
|
||||||
print("Failed to release sleep assertion: \(success)")
|
print("Failed to release sleep assertion: \(success)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates sleep prevention based on user preference and server state
|
/// Updates sleep prevention based on user preference and server state
|
||||||
func updateSleepPrevention(enabled: Bool, serverRunning: Bool) {
|
func updateSleepPrevention(enabled: Bool, serverRunning: Bool) {
|
||||||
if enabled && serverRunning {
|
if enabled && serverRunning {
|
||||||
|
|
@ -67,10 +67,10 @@ final class PowerManagementService {
|
||||||
allowSleep()
|
allowSleep()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
// Deinit runs on arbitrary thread, but we need to check MainActor state
|
// Deinit runs on arbitrary thread, but we need to check MainActor state
|
||||||
// Since we can't access MainActor properties directly in deinit,
|
// Since we can't access MainActor properties directly in deinit,
|
||||||
// we handle cleanup in allowSleep() which is called when server stops
|
// we handle cleanup in allowSleep() which is called when server stops
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,11 +128,11 @@ class ServerManager {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
// Only update sleep prevention if server is running
|
// Only update sleep prevention if server is running
|
||||||
guard isRunning else { return }
|
guard isRunning else { return }
|
||||||
|
|
||||||
// Check if preventSleepWhenRunning setting changed
|
// Check if preventSleepWhenRunning setting changed
|
||||||
let preventSleep = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
let preventSleep = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
||||||
powerManager.updateSleepPrevention(enabled: preventSleep, serverRunning: true)
|
powerManager.updateSleepPrevention(enabled: preventSleep, serverRunning: true)
|
||||||
|
|
||||||
logger.info("Updated sleep prevention setting: \(preventSleep ? "enabled" : "disabled")")
|
logger.info("Updated sleep prevention setting: \(preventSleep ? "enabled" : "disabled")")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -226,7 +226,7 @@ class ServerManager {
|
||||||
// This prevents a race condition where the server could crash after setting isRunning = true
|
// This prevents a race condition where the server could crash after setting isRunning = true
|
||||||
let preventSleep = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
let preventSleep = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
||||||
powerManager.updateSleepPrevention(enabled: preventSleep, serverRunning: true)
|
powerManager.updateSleepPrevention(enabled: preventSleep, serverRunning: true)
|
||||||
|
|
||||||
// Now update state
|
// Now update state
|
||||||
isRunning = true
|
isRunning = true
|
||||||
lastError = nil
|
lastError = nil
|
||||||
|
|
@ -276,7 +276,7 @@ class ServerManager {
|
||||||
|
|
||||||
// Clear the auth token from SessionMonitor
|
// Clear the auth token from SessionMonitor
|
||||||
SessionMonitor.shared.setLocalAuthToken(nil)
|
SessionMonitor.shared.setLocalAuthToken(nil)
|
||||||
|
|
||||||
// Allow sleep when server is stopped
|
// Allow sleep when server is stopped
|
||||||
powerManager.updateSleepPrevention(enabled: false, serverRunning: false)
|
powerManager.updateSleepPrevention(enabled: false, serverRunning: false)
|
||||||
|
|
||||||
|
|
@ -396,7 +396,7 @@ class ServerManager {
|
||||||
// Update state immediately
|
// Update state immediately
|
||||||
isRunning = false
|
isRunning = false
|
||||||
bunServer = nil
|
bunServer = nil
|
||||||
|
|
||||||
// Allow sleep when server crashes
|
// Allow sleep when server crashes
|
||||||
powerManager.updateSleepPrevention(enabled: false, serverRunning: false)
|
powerManager.updateSleepPrevention(enabled: false, serverRunning: false)
|
||||||
|
|
||||||
|
|
@ -516,7 +516,7 @@ class ServerManager {
|
||||||
let preventSleep = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
let preventSleep = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
||||||
powerManager.updateSleepPrevention(enabled: preventSleep, serverRunning: true)
|
powerManager.updateSleepPrevention(enabled: preventSleep, serverRunning: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
while true {
|
while true {
|
||||||
try? await Task.sleep(for: .seconds(30))
|
try? await Task.sleep(for: .seconds(30))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,18 @@ import os.log
|
||||||
/// Server session information returned by the API
|
/// Server session information returned by the API
|
||||||
struct ServerSessionInfo: Codable {
|
struct ServerSessionInfo: Codable {
|
||||||
let id: String
|
let id: String
|
||||||
let command: [String] // Changed from String to [String] to match server
|
let command: [String] // Changed from String to [String] to match server
|
||||||
let name: String? // Added missing field
|
let name: String? // Added missing field
|
||||||
let workingDir: String
|
let workingDir: String
|
||||||
let status: String
|
let status: String
|
||||||
let exitCode: Int?
|
let exitCode: Int?
|
||||||
let startedAt: String
|
let startedAt: String
|
||||||
let lastModified: String
|
let lastModified: String
|
||||||
let pid: Int? // Made optional since it might not exist for all sessions
|
let pid: Int? // Made optional since it might not exist for all sessions
|
||||||
let initialCols: Int? // Added missing field
|
let initialCols: Int? // Added missing field
|
||||||
let initialRows: Int? // Added missing field
|
let initialRows: Int? // Added missing field
|
||||||
let activityStatus: ActivityStatus?
|
let activityStatus: ActivityStatus?
|
||||||
let source: String? // Added for HQ mode
|
let source: String? // Added for HQ mode
|
||||||
|
|
||||||
var isRunning: Bool {
|
var isRunning: Bool {
|
||||||
status == "running"
|
status == "running"
|
||||||
|
|
@ -123,13 +123,19 @@ final class SessionMonitor {
|
||||||
self.sessions = sessionsDict
|
self.sessions = sessionsDict
|
||||||
self.lastError = nil
|
self.lastError = nil
|
||||||
self.lastFetch = Date()
|
self.lastFetch = Date()
|
||||||
|
|
||||||
logger.debug("Fetched \(sessionsArray.count) sessions, \(sessionsDict.values.filter { $0.isRunning }.count) running")
|
logger
|
||||||
|
.debug(
|
||||||
|
"Fetched \(sessionsArray.count) sessions, \(sessionsDict.values.count { $0.isRunning }) running"
|
||||||
|
)
|
||||||
|
|
||||||
// Debug: Log session details
|
// Debug: Log session details
|
||||||
for session in sessionsArray {
|
for session in sessionsArray {
|
||||||
let pidStr = session.pid.map { String($0) } ?? "nil"
|
let pidStr = session.pid.map { String($0) } ?? "nil"
|
||||||
logger.debug("Session \(session.id): status=\(session.status), isRunning=\(session.isRunning), pid=\(pidStr)")
|
logger
|
||||||
|
.debug(
|
||||||
|
"Session \(session.id): status=\(session.status), isRunning=\(session.isRunning), pid=\(pidStr)"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update WindowTracker
|
// Update WindowTracker
|
||||||
|
|
|
||||||
|
|
@ -57,24 +57,65 @@ final class WindowTracker {
|
||||||
) {
|
) {
|
||||||
logger.info("Registering window for session: \(sessionID), terminal: \(terminalApp.rawValue)")
|
logger.info("Registering window for session: \(sessionID), terminal: \(terminalApp.rawValue)")
|
||||||
|
|
||||||
// Give the terminal some time to create the window
|
// For Terminal.app and iTerm2 with explicit window/tab info, register immediately
|
||||||
Task {
|
if (terminalApp == .terminal && tabReference != nil) ||
|
||||||
try? await Task.sleep(for: .seconds(1.0))
|
(terminalApp == .iTerm2 && tabID != nil)
|
||||||
|
{
|
||||||
|
// These terminals provide explicit window/tab IDs, so we can register immediately
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
|
|
||||||
// Find the most recently created window for this terminal
|
if let windowInfo = findWindow(
|
||||||
if let windowInfo = findWindow(
|
for: terminalApp,
|
||||||
for: terminalApp,
|
sessionID: sessionID,
|
||||||
sessionID: sessionID,
|
tabReference: tabReference,
|
||||||
tabReference: tabReference,
|
tabID: tabID
|
||||||
tabID: tabID
|
) {
|
||||||
) {
|
mapLock.withLock {
|
||||||
mapLock.withLock {
|
sessionWindowMap[sessionID] = windowInfo
|
||||||
sessionWindowMap[sessionID] = windowInfo
|
}
|
||||||
|
logger
|
||||||
|
.info(
|
||||||
|
"Successfully registered window \(windowInfo.windowID) for session \(sessionID) with explicit ID"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
logger.info("Successfully registered window \(windowInfo.windowID) for session \(sessionID)")
|
|
||||||
} else {
|
|
||||||
logger.warning("Could not find window for session \(sessionID)")
|
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other terminals, use progressive delays to find the window
|
||||||
|
Task {
|
||||||
|
// Try multiple times with increasing delays
|
||||||
|
let delays: [Double] = [0.5, 1.0, 2.0, 3.0]
|
||||||
|
|
||||||
|
for (index, delay) in delays.enumerated() {
|
||||||
|
try? await Task.sleep(for: .seconds(delay))
|
||||||
|
|
||||||
|
// Try to find the window
|
||||||
|
if let windowInfo = findWindow(
|
||||||
|
for: terminalApp,
|
||||||
|
sessionID: sessionID,
|
||||||
|
tabReference: tabReference,
|
||||||
|
tabID: tabID
|
||||||
|
) {
|
||||||
|
mapLock.withLock {
|
||||||
|
sessionWindowMap[sessionID] = windowInfo
|
||||||
|
}
|
||||||
|
logger
|
||||||
|
.info(
|
||||||
|
"Successfully registered window \(windowInfo.windowID) for session \(sessionID) after \(index + 1) attempts"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger
|
||||||
|
.debug("Window registration attempt \(index + 1) failed for session \(sessionID), trying again...")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warning("Could not find window for session \(sessionID) after all attempts")
|
||||||
|
|
||||||
|
// Final fallback: try scanning
|
||||||
|
await scanForSession(sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,8 +149,14 @@ final class WindowTracker {
|
||||||
|
|
||||||
// Check if this is a terminal application
|
// Check if this is a terminal application
|
||||||
guard let terminal = Terminal.allCases.first(where: { term in
|
guard let terminal = Terminal.allCases.first(where: { term in
|
||||||
// Match by process name or app name
|
// Match by process name, app name, or bundle identifier parts
|
||||||
ownerName == term.processName || ownerName == term.rawValue
|
let processNameMatch = ownerName == term.processName ||
|
||||||
|
ownerName.lowercased() == term.processName.lowercased()
|
||||||
|
let appNameMatch = ownerName == term.rawValue
|
||||||
|
let bundleMatch = ownerName.contains(term.displayName) ||
|
||||||
|
term.bundleIdentifier.contains(ownerName)
|
||||||
|
|
||||||
|
return processNameMatch || appNameMatch || bundleMatch
|
||||||
}) else {
|
}) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -157,46 +204,125 @@ final class WindowTracker {
|
||||||
// Filter windows for the specific terminal
|
// Filter windows for the specific terminal
|
||||||
let terminalWindows = allWindows.filter { $0.terminalApp == terminal }
|
let terminalWindows = allWindows.filter { $0.terminalApp == terminal }
|
||||||
|
|
||||||
// If we have specific tab information, try to match by title or other properties
|
// First try to find window by title containing session path or command
|
||||||
// For now, return the most recently created window (highest window ID)
|
// Sessions typically show their working directory in the title
|
||||||
guard let latestWindow = terminalWindows.max(by: { $0.windowID < $1.windowID }) else {
|
if let sessionInfo = getSessionInfo(for: sessionID) {
|
||||||
return nil
|
let workingDir = sessionInfo.workingDir
|
||||||
|
let dirName = (workingDir as NSString).lastPathComponent
|
||||||
|
|
||||||
|
// Look for windows whose title contains the directory name
|
||||||
|
if let matchingWindow = terminalWindows.first(where: { window in
|
||||||
|
if let title = window.title {
|
||||||
|
return title.contains(dirName) || title.contains(workingDir)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}) {
|
||||||
|
logger.debug("Found window by directory match: \(dirName)")
|
||||||
|
return createWindowInfo(
|
||||||
|
from: matchingWindow,
|
||||||
|
sessionID: sessionID,
|
||||||
|
terminal: terminal,
|
||||||
|
tabReference: tabReference,
|
||||||
|
tabID: tabID
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new WindowInfo with the session information
|
// For Terminal.app and iTerm2 with specific tab/window IDs, use those
|
||||||
return WindowInfo(
|
if terminal == .terminal, let tabRef = tabReference {
|
||||||
windowID: latestWindow.windowID,
|
// Extract window ID from tab reference (format: "tab id X of window id Y")
|
||||||
ownerPID: latestWindow.ownerPID,
|
if let windowIDMatch = tabRef.firstMatch(of: /window id (\d+)/),
|
||||||
|
let windowID = CGWindowID(windowIDMatch.output.1)
|
||||||
|
{
|
||||||
|
if let matchingWindow = terminalWindows.first(where: { $0.windowID == windowID }) {
|
||||||
|
logger.debug("Found Terminal.app window by ID: \(windowID)")
|
||||||
|
return createWindowInfo(
|
||||||
|
from: matchingWindow,
|
||||||
|
sessionID: sessionID,
|
||||||
|
terminal: terminal,
|
||||||
|
tabReference: tabReference,
|
||||||
|
tabID: tabID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a window ID from launch result, use it
|
||||||
|
if let tabID, terminal == .iTerm2 {
|
||||||
|
// For iTerm2, tabID contains the window ID string
|
||||||
|
// Try to match by window title which often includes the window ID
|
||||||
|
if let matchingWindow = terminalWindows.first(where: { window in
|
||||||
|
if let title = window.title {
|
||||||
|
return title.contains(tabID)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}) {
|
||||||
|
logger.debug("Found iTerm2 window by ID in title: \(tabID)")
|
||||||
|
return createWindowInfo(
|
||||||
|
from: matchingWindow,
|
||||||
|
sessionID: sessionID,
|
||||||
|
terminal: terminal,
|
||||||
|
tabReference: tabReference,
|
||||||
|
tabID: tabID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: return the most recently created window (highest window ID)
|
||||||
|
// But only if it was created very recently (within 5 seconds)
|
||||||
|
if let latestWindow = terminalWindows.max(by: { $0.windowID < $1.windowID }) {
|
||||||
|
logger.debug("Using most recent window as fallback for session: \(sessionID)")
|
||||||
|
return createWindowInfo(
|
||||||
|
from: latestWindow,
|
||||||
|
sessionID: sessionID,
|
||||||
|
terminal: terminal,
|
||||||
|
tabReference: tabReference,
|
||||||
|
tabID: tabID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to create WindowInfo from a found window
|
||||||
|
private func createWindowInfo(
|
||||||
|
from window: WindowInfo,
|
||||||
|
sessionID: String,
|
||||||
|
terminal: Terminal,
|
||||||
|
tabReference: String?,
|
||||||
|
tabID: String?
|
||||||
|
)
|
||||||
|
-> WindowInfo
|
||||||
|
{
|
||||||
|
WindowInfo(
|
||||||
|
windowID: window.windowID,
|
||||||
|
ownerPID: window.ownerPID,
|
||||||
terminalApp: terminal,
|
terminalApp: terminal,
|
||||||
sessionID: sessionID,
|
sessionID: sessionID,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
tabReference: tabReference,
|
tabReference: tabReference,
|
||||||
tabID: tabID,
|
tabID: tabID,
|
||||||
bounds: latestWindow.bounds,
|
bounds: window.bounds,
|
||||||
title: latestWindow.title
|
title: window.title
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get session info from SessionMonitor
|
||||||
|
private func getSessionInfo(for sessionID: String) -> ServerSessionInfo? {
|
||||||
|
// Access SessionMonitor to get session details
|
||||||
|
// This is safe because both are @MainActor
|
||||||
|
SessionMonitor.shared.sessions[sessionID]
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Window Focus
|
// MARK: - Window Focus
|
||||||
|
|
||||||
/// Focuses the window associated with a session.
|
/// Focuses the window associated with a session.
|
||||||
func focusWindow(for sessionID: String) {
|
func focusWindow(for sessionID: String) {
|
||||||
mapLock.withLock {
|
// First check if we have the window info
|
||||||
guard let windowInfo = sessionWindowMap[sessionID] else {
|
let windowInfo = mapLock.withLock { sessionWindowMap[sessionID] }
|
||||||
logger.warning("No window found for session: \(sessionID)")
|
|
||||||
logger.debug("Available sessions: \(self.sessionWindowMap.keys.joined(separator: ", "))")
|
|
||||||
|
|
||||||
// Try to scan for the session one more time
|
|
||||||
Task {
|
|
||||||
await scanForSession(sessionID)
|
|
||||||
// Try focusing again after scan
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
self.focusWindow(for: sessionID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if let windowInfo {
|
||||||
|
// We have window info, try to focus it
|
||||||
logger
|
logger
|
||||||
.info(
|
.info(
|
||||||
"Focusing window for session: \(sessionID), terminal: \(windowInfo.terminalApp.rawValue), windowID: \(windowInfo.windowID)"
|
"Focusing window for session: \(sessionID), terminal: \(windowInfo.terminalApp.rawValue), windowID: \(windowInfo.windowID)"
|
||||||
|
|
@ -211,9 +337,87 @@ final class WindowTracker {
|
||||||
// For other terminals, use standard window focus
|
// For other terminals, use standard window focus
|
||||||
focusWindowUsingAccessibility(windowInfo)
|
focusWindowUsingAccessibility(windowInfo)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No window info found, try to scan for it
|
||||||
|
logger.warning("No window found for session: \(sessionID), attempting to locate...")
|
||||||
|
|
||||||
|
// Get available sessions for debugging
|
||||||
|
let availableSessions = mapLock.withLock { Array(sessionWindowMap.keys) }
|
||||||
|
logger.debug("Currently tracked sessions: \(availableSessions.joined(separator: ", "))")
|
||||||
|
|
||||||
|
// Try to find the window immediately (synchronously)
|
||||||
|
if let sessionInfo = getSessionInfo(for: sessionID) {
|
||||||
|
// Try to find window using enhanced logic
|
||||||
|
if let foundWindow = findWindowForSession(sessionID, sessionInfo: sessionInfo) {
|
||||||
|
mapLock.withLock {
|
||||||
|
sessionWindowMap[sessionID] = foundWindow
|
||||||
|
}
|
||||||
|
logger.info("Found window for session \(sessionID) on demand")
|
||||||
|
// Recursively call to focus the now-found window
|
||||||
|
focusWindow(for: sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still not found, scan asynchronously
|
||||||
|
Task {
|
||||||
|
await scanForSession(sessionID)
|
||||||
|
// Try focusing again after scan
|
||||||
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
|
await MainActor.run {
|
||||||
|
self.focusWindow(for: sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Synchronously find a window for a session
|
||||||
|
private func findWindowForSession(_ sessionID: String, sessionInfo: ServerSessionInfo) -> WindowInfo? {
|
||||||
|
let allWindows = Self.getAllTerminalWindows()
|
||||||
|
|
||||||
|
let workingDir = sessionInfo.workingDir
|
||||||
|
let dirName = (workingDir as NSString).lastPathComponent
|
||||||
|
let expandedDir = (workingDir as NSString).expandingTildeInPath
|
||||||
|
|
||||||
|
// Look through all windows to find a match
|
||||||
|
for window in allWindows {
|
||||||
|
var matchFound = false
|
||||||
|
|
||||||
|
if let title = window.title {
|
||||||
|
// Check for directory name match
|
||||||
|
if title.contains(dirName) || title.contains(expandedDir) {
|
||||||
|
matchFound = true
|
||||||
|
}
|
||||||
|
// Check for VibeTunnel markers
|
||||||
|
else if title.contains("vt") || title.contains("vibetunnel") || title.contains("TTY_SESSION_ID") {
|
||||||
|
matchFound = true
|
||||||
|
}
|
||||||
|
// Check for command match
|
||||||
|
else if let command = sessionInfo.command.first,
|
||||||
|
!command.isEmpty && title.contains(command)
|
||||||
|
{
|
||||||
|
matchFound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matchFound {
|
||||||
|
return WindowInfo(
|
||||||
|
windowID: window.windowID,
|
||||||
|
ownerPID: window.ownerPID,
|
||||||
|
terminalApp: window.terminalApp,
|
||||||
|
sessionID: sessionID,
|
||||||
|
createdAt: Date(),
|
||||||
|
tabReference: nil,
|
||||||
|
tabID: nil,
|
||||||
|
bounds: window.bounds,
|
||||||
|
title: window.title
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
/// Focuses a Terminal.app window/tab.
|
/// Focuses a Terminal.app window/tab.
|
||||||
private func focusTerminalAppWindow(_ windowInfo: WindowInfo) {
|
private func focusTerminalAppWindow(_ windowInfo: WindowInfo) {
|
||||||
if let tabRef = windowInfo.tabReference {
|
if let tabRef = windowInfo.tabReference {
|
||||||
|
|
@ -354,17 +558,47 @@ final class WindowTracker {
|
||||||
private func scanForSession(_ sessionID: String) async {
|
private func scanForSession(_ sessionID: String) async {
|
||||||
logger.info("Scanning for window containing session: \(sessionID)")
|
logger.info("Scanning for window containing session: \(sessionID)")
|
||||||
|
|
||||||
|
// Get session info to match by working directory
|
||||||
|
guard let sessionInfo = getSessionInfo(for: sessionID) else {
|
||||||
|
logger.warning("No session info found for session: \(sessionID)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Get all terminal windows
|
// Get all terminal windows
|
||||||
let allWindows = Self.getAllTerminalWindows()
|
let allWindows = Self.getAllTerminalWindows()
|
||||||
|
|
||||||
|
let workingDir = sessionInfo.workingDir
|
||||||
|
let dirName = (workingDir as NSString).lastPathComponent
|
||||||
|
let expandedDir = (workingDir as NSString).expandingTildeInPath
|
||||||
|
|
||||||
// Look for windows that might contain this session
|
// Look for windows that might contain this session
|
||||||
// Sessions typically show their ID in the window title
|
|
||||||
for window in allWindows {
|
for window in allWindows {
|
||||||
// Check if window title contains session ID
|
var matchFound = false
|
||||||
if let title = window.title,
|
var matchReason = ""
|
||||||
title.contains(sessionID) || title.contains("vt") || title.contains("vibetunnel")
|
|
||||||
{
|
// Check if window title contains working directory or session markers
|
||||||
logger.info("Found potential window for session \(sessionID): \(title)")
|
if let title = window.title {
|
||||||
|
// Check for directory name match (most common)
|
||||||
|
if title.contains(dirName) || title.contains(expandedDir) {
|
||||||
|
matchFound = true
|
||||||
|
matchReason = "directory match: \(dirName)"
|
||||||
|
}
|
||||||
|
// Check for VibeTunnel-specific markers
|
||||||
|
else if title.contains("vt") || title.contains("vibetunnel") || title.contains("TTY_SESSION_ID") {
|
||||||
|
matchFound = true
|
||||||
|
matchReason = "VibeTunnel marker in title"
|
||||||
|
}
|
||||||
|
// Check if title contains the command being run
|
||||||
|
else if let command = sessionInfo.command.first,
|
||||||
|
!command.isEmpty && title.contains(command)
|
||||||
|
{
|
||||||
|
matchFound = true
|
||||||
|
matchReason = "command match: \(command)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matchFound {
|
||||||
|
logger.info("Found window for session \(sessionID) by \(matchReason): \(window.title ?? "no title")")
|
||||||
|
|
||||||
// Create window info for this session
|
// Create window info for this session
|
||||||
let windowInfo = WindowInfo(
|
let windowInfo = WindowInfo(
|
||||||
|
|
@ -388,7 +622,11 @@ final class WindowTracker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("Could not find window for session \(sessionID) in \(allWindows.count) terminal windows")
|
// If no match found, log window titles for debugging
|
||||||
|
logger.debug("Could not find window for session \(sessionID) (workingDir: \(workingDir))")
|
||||||
|
for (index, window) in allWindows.enumerated() {
|
||||||
|
logger.debug("Window \(index): \(window.terminalApp.rawValue) - '\(window.title ?? "no title")'")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Session Monitoring
|
// MARK: - Session Monitoring
|
||||||
|
|
|
||||||
311
mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift
Normal file
311
mac/VibeTunnel/Presentation/Components/CustomMenuWindow.swift
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Custom borderless window that appears below the menu bar icon.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
@MainActor
|
||||||
|
final class CustomMenuWindow: NSPanel {
|
||||||
|
private var eventMonitor: Any?
|
||||||
|
private let hostingController: NSHostingController<AnyView>
|
||||||
|
private var retainedContentView: AnyView?
|
||||||
|
private var isEventMonitoringActive = false
|
||||||
|
|
||||||
|
/// Closure to be called when window hides
|
||||||
|
var onHide: (() -> Void)?
|
||||||
|
|
||||||
|
init(contentView: some View) {
|
||||||
|
// Store the content view to prevent deallocation in Release builds
|
||||||
|
let wrappedView = AnyView(contentView)
|
||||||
|
self.retainedContentView = wrappedView
|
||||||
|
|
||||||
|
// Create content view controller with the wrapped view
|
||||||
|
hostingController = NSHostingController(rootView: wrappedView)
|
||||||
|
|
||||||
|
// Initialize window with appropriate style
|
||||||
|
super.init(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 384, height: 400),
|
||||||
|
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// Configure window appearance
|
||||||
|
isOpaque = false
|
||||||
|
backgroundColor = .clear
|
||||||
|
hasShadow = true
|
||||||
|
level = .popUpMenu
|
||||||
|
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
|
||||||
|
isMovableByWindowBackground = false
|
||||||
|
hidesOnDeactivate = false
|
||||||
|
isReleasedWhenClosed = false
|
||||||
|
|
||||||
|
// Set content view controller
|
||||||
|
contentViewController = hostingController
|
||||||
|
|
||||||
|
// Force the view to load immediately
|
||||||
|
_ = hostingController.view
|
||||||
|
|
||||||
|
// Add visual effect background with rounded corners
|
||||||
|
if let contentView = contentViewController?.view {
|
||||||
|
contentView.wantsLayer = true
|
||||||
|
contentView.layer?.cornerRadius = 12
|
||||||
|
contentView.layer?.masksToBounds = true
|
||||||
|
|
||||||
|
// Add subtle shadow
|
||||||
|
contentView.shadow = NSShadow()
|
||||||
|
contentView.shadow?.shadowOffset = NSSize(width: 0, height: -1)
|
||||||
|
contentView.shadow?.shadowBlurRadius = 12
|
||||||
|
contentView.shadow?.shadowColor = NSColor.black.withAlphaComponent(0.3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func show(relativeTo statusItemButton: NSStatusBarButton) {
|
||||||
|
// First, make sure the SwiftUI hierarchy has laid itself out
|
||||||
|
hostingController.view.layoutSubtreeIfNeeded()
|
||||||
|
|
||||||
|
// Determine the preferred size based on the content's intrinsic size
|
||||||
|
let fittingSize = hostingController.view.fittingSize
|
||||||
|
let preferredSize = NSSize(width: fittingSize.width, height: fittingSize.height)
|
||||||
|
|
||||||
|
// Update the panel's content size
|
||||||
|
setContentSize(preferredSize)
|
||||||
|
|
||||||
|
// Get status item frame in screen coordinates
|
||||||
|
if let statusWindow = statusItemButton.window {
|
||||||
|
let buttonBounds = statusItemButton.bounds
|
||||||
|
let buttonFrameInWindow = statusItemButton.convert(buttonBounds, to: nil)
|
||||||
|
let buttonFrameInScreen = statusWindow.convertToScreen(buttonFrameInWindow)
|
||||||
|
|
||||||
|
// Check if the button frame is valid and visible
|
||||||
|
if buttonFrameInScreen.width > 0, buttonFrameInScreen.height > 0 {
|
||||||
|
// Calculate optimal position relative to the status bar icon
|
||||||
|
let targetFrame = calculateOptimalFrame(
|
||||||
|
relativeTo: buttonFrameInScreen,
|
||||||
|
preferredSize: preferredSize
|
||||||
|
)
|
||||||
|
|
||||||
|
setFrame(targetFrame, display: false)
|
||||||
|
} else {
|
||||||
|
// Fallback: Position at top right of screen
|
||||||
|
showAtTopRightFallback(withSize: preferredSize)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback case
|
||||||
|
showAtTopRightFallback(withSize: preferredSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the hosting controller's view is loaded
|
||||||
|
_ = hostingController.view
|
||||||
|
|
||||||
|
// Display window safely
|
||||||
|
displayWindowSafely()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func displayWindowSafely() {
|
||||||
|
alphaValue = 0
|
||||||
|
|
||||||
|
// Ensure app is active
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
|
||||||
|
// Make the window first responder to enable keyboard navigation
|
||||||
|
// but don't focus any specific element
|
||||||
|
makeFirstResponder(self)
|
||||||
|
|
||||||
|
// Small delay to ensure window is fully displayed before animation
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(10))
|
||||||
|
|
||||||
|
if self.isVisible {
|
||||||
|
self.animateWindowIn()
|
||||||
|
self.setupEventMonitoring()
|
||||||
|
} else {
|
||||||
|
await self.displayWindowFallback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func displayWindowFallback() async {
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
self.makeKeyAndOrderFront(nil)
|
||||||
|
|
||||||
|
try? await Task.sleep(for: .milliseconds(50))
|
||||||
|
|
||||||
|
if self.isVisible {
|
||||||
|
self.animateWindowIn()
|
||||||
|
self.setupEventMonitoring()
|
||||||
|
} else {
|
||||||
|
self.orderFrontRegardless()
|
||||||
|
self.alphaValue = 1.0
|
||||||
|
self.setupEventMonitoring()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func animateWindowIn() {
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.25
|
||||||
|
context.timingFunction = CAMediaTimingFunction(controlPoints: 0.2, 0.0, 0.2, 1.0)
|
||||||
|
context.allowsImplicitAnimation = true
|
||||||
|
self.animator().alphaValue = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateOptimalFrame(relativeTo statusFrame: NSRect, preferredSize: NSSize) -> NSRect {
|
||||||
|
guard let screen = NSScreen.main else {
|
||||||
|
let defaultScreenWidth: CGFloat = 1_920
|
||||||
|
let defaultScreenHeight: CGFloat = 1_080
|
||||||
|
let rightMargin: CGFloat = 10
|
||||||
|
let menuBarHeight: CGFloat = 25
|
||||||
|
let gap: CGFloat = 5
|
||||||
|
|
||||||
|
let x = defaultScreenWidth - preferredSize.width - rightMargin
|
||||||
|
let y = defaultScreenHeight - menuBarHeight - preferredSize.height - gap
|
||||||
|
return NSRect(origin: NSPoint(x: x, y: y), size: preferredSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
let screenFrame = screen.visibleFrame
|
||||||
|
let gap: CGFloat = 5
|
||||||
|
|
||||||
|
// Check if the status frame appears to be invalid
|
||||||
|
if statusFrame.midX < 100, statusFrame.midY < 100 {
|
||||||
|
// Fall back to top-right positioning
|
||||||
|
let rightMargin: CGFloat = 10
|
||||||
|
|
||||||
|
let x = screenFrame.maxX - preferredSize.width - rightMargin
|
||||||
|
let y = screenFrame.maxY - preferredSize.height - gap
|
||||||
|
|
||||||
|
return NSRect(origin: NSPoint(x: x, y: y), size: preferredSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start with centered position below status item
|
||||||
|
var x = statusFrame.midX - preferredSize.width / 2
|
||||||
|
let y = statusFrame.minY - preferredSize.height - gap
|
||||||
|
|
||||||
|
// Ensure window stays within screen bounds
|
||||||
|
let minX = screenFrame.minX + 10
|
||||||
|
let maxX = screenFrame.maxX - preferredSize.width - 10
|
||||||
|
x = max(minX, min(maxX, x))
|
||||||
|
|
||||||
|
// Ensure window doesn't go below screen
|
||||||
|
let finalY = max(screenFrame.minY + 10, y)
|
||||||
|
|
||||||
|
return NSRect(
|
||||||
|
origin: NSPoint(x: x, y: finalY),
|
||||||
|
size: preferredSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showAtTopRightFallback(withSize preferredSize: NSSize) {
|
||||||
|
guard let screen = NSScreen.main else { return }
|
||||||
|
|
||||||
|
let screenFrame = screen.visibleFrame
|
||||||
|
let rightMargin: CGFloat = 10
|
||||||
|
let gap: CGFloat = 5
|
||||||
|
|
||||||
|
let x = screenFrame.maxX - preferredSize.width - rightMargin
|
||||||
|
let y = screenFrame.maxY - preferredSize.height - gap
|
||||||
|
|
||||||
|
let fallbackFrame = NSRect(
|
||||||
|
origin: NSPoint(x: x, y: y),
|
||||||
|
size: preferredSize
|
||||||
|
)
|
||||||
|
|
||||||
|
setFrame(fallbackFrame, display: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hide() {
|
||||||
|
orderOut(nil)
|
||||||
|
teardownEventMonitoring()
|
||||||
|
onHide?()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func orderOut(_ sender: Any?) {
|
||||||
|
super.orderOut(sender)
|
||||||
|
if isVisible == false {
|
||||||
|
onHide?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupEventMonitoring() {
|
||||||
|
teardownEventMonitoring()
|
||||||
|
|
||||||
|
guard isVisible else { return }
|
||||||
|
|
||||||
|
eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
|
||||||
|
guard let self, self.isVisible else { return }
|
||||||
|
|
||||||
|
let mouseLocation = NSEvent.mouseLocation
|
||||||
|
|
||||||
|
if !self.frame.contains(mouseLocation) {
|
||||||
|
self.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isEventMonitoringActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func teardownEventMonitoring() {
|
||||||
|
if let monitor = eventMonitor {
|
||||||
|
NSEvent.removeMonitor(monitor)
|
||||||
|
eventMonitor = nil
|
||||||
|
isEventMonitoringActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func resignKey() {
|
||||||
|
super.resignKey()
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
override var canBecomeKey: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func makeKey() {
|
||||||
|
super.makeKey()
|
||||||
|
// Set the window itself as first responder to prevent auto-focus
|
||||||
|
makeFirstResponder(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
override var canBecomeMain: Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
teardownEventMonitoring()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wrapper view that applies modern SwiftUI material background to menu content.
|
||||||
|
struct CustomMenuContainer<Content: View>: View {
|
||||||
|
@ViewBuilder
|
||||||
|
let content: Content
|
||||||
|
|
||||||
|
@Environment(\.colorScheme)
|
||||||
|
private var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
content
|
||||||
|
.fixedSize()
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(borderColor, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var borderColor: Color {
|
||||||
|
switch colorScheme {
|
||||||
|
case .dark:
|
||||||
|
Color.white.opacity(0.1)
|
||||||
|
case .light:
|
||||||
|
Color.white.opacity(0.8)
|
||||||
|
@unknown default:
|
||||||
|
Color.white.opacity(0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Visual Indicator Styles
|
||||||
|
|
||||||
|
extension StatusBarController {
|
||||||
|
enum IndicatorStyle {
|
||||||
|
case dots // ●●● 5 (current implementation)
|
||||||
|
case bars // ▪︎▪︎▫︎▫︎▫︎
|
||||||
|
case compact // 2◆5
|
||||||
|
case minimalist // 2|5
|
||||||
|
case meter // [■■□□□]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format session counts with the specified visual style
|
||||||
|
func formatSessionIndicator(activeCount: Int, totalCount: Int, style: IndicatorStyle = .dots) -> String {
|
||||||
|
guard totalCount > 0 else { return "" }
|
||||||
|
|
||||||
|
switch style {
|
||||||
|
case .dots:
|
||||||
|
return formatDotsIndicator(activeCount: activeCount, totalCount: totalCount)
|
||||||
|
|
||||||
|
case .bars:
|
||||||
|
return formatBarsIndicator(activeCount: activeCount, totalCount: totalCount)
|
||||||
|
|
||||||
|
case .compact:
|
||||||
|
return formatCompactIndicator(activeCount: activeCount, totalCount: totalCount)
|
||||||
|
|
||||||
|
case .minimalist:
|
||||||
|
return formatMinimalistIndicator(activeCount: activeCount, totalCount: totalCount)
|
||||||
|
|
||||||
|
case .meter:
|
||||||
|
return formatMeterIndicator(activeCount: activeCount, totalCount: totalCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Indicator Implementations
|
||||||
|
|
||||||
|
private func formatDotsIndicator(activeCount: Int, totalCount: Int) -> String {
|
||||||
|
if activeCount == 0 {
|
||||||
|
// Only idle sessions, show simple count
|
||||||
|
return String(totalCount)
|
||||||
|
} else if activeCount > 0 {
|
||||||
|
// Show active sessions with dots
|
||||||
|
let dots = String(repeating: "●", count: min(activeCount, 3))
|
||||||
|
let suffix = activeCount > 3 ? "+" : ""
|
||||||
|
|
||||||
|
if totalCount > activeCount {
|
||||||
|
// Show active dots with total count
|
||||||
|
return "\(dots)\(suffix) \(totalCount)"
|
||||||
|
} else {
|
||||||
|
// Only active sessions, just show dots
|
||||||
|
return dots + suffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatBarsIndicator(activeCount: Int, totalCount: Int) -> String {
|
||||||
|
let maxBars = 5
|
||||||
|
let displayCount = min(totalCount, maxBars)
|
||||||
|
let displayActive = min(activeCount, displayCount)
|
||||||
|
|
||||||
|
let activeBars = String(repeating: "▪︎", count: displayActive)
|
||||||
|
let idleBars = String(repeating: "▫︎", count: displayCount - displayActive)
|
||||||
|
|
||||||
|
if totalCount > maxBars {
|
||||||
|
return "\(activeBars)\(idleBars)+"
|
||||||
|
}
|
||||||
|
return activeBars + idleBars
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatCompactIndicator(activeCount: Int, totalCount: Int) -> String {
|
||||||
|
if activeCount == 0 {
|
||||||
|
"◯\(totalCount)"
|
||||||
|
} else if activeCount == totalCount {
|
||||||
|
"◆\(activeCount)"
|
||||||
|
} else {
|
||||||
|
"\(activeCount)◆\(totalCount)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatMinimalistIndicator(activeCount: Int, totalCount: Int) -> String {
|
||||||
|
if activeCount == 0 {
|
||||||
|
String(totalCount)
|
||||||
|
} else if activeCount == totalCount {
|
||||||
|
"● \(activeCount)"
|
||||||
|
} else {
|
||||||
|
"\(activeCount) | \(totalCount)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatMeterIndicator(activeCount: Int, totalCount: Int) -> String {
|
||||||
|
let maxSegments = 5
|
||||||
|
let segmentCount = min(totalCount, maxSegments)
|
||||||
|
|
||||||
|
if segmentCount == 0 { return "" }
|
||||||
|
|
||||||
|
let activeSegments = Int(round(Double(activeCount) / Double(totalCount) * Double(segmentCount)))
|
||||||
|
let filled = String(repeating: "■", count: activeSegments)
|
||||||
|
let empty = String(repeating: "□", count: segmentCount - activeSegments)
|
||||||
|
|
||||||
|
return "[\(filled)\(empty)]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Alternative Unicode Characters
|
||||||
|
|
||||||
|
// Other visual indicators we could use:
|
||||||
|
//
|
||||||
|
// Dots and Circles:
|
||||||
|
// • ● ○ ◉ ◯ ◦ ⬤ ⚫ ⚪ ◐ ◑ ◒ ◓
|
||||||
|
//
|
||||||
|
// Squares and Blocks:
|
||||||
|
// ▪ ▫ ◼ ◻ ■ □ ▰ ▱ ◾ ◽
|
||||||
|
//
|
||||||
|
// Bars and Progress:
|
||||||
|
// ▁ ▂ ▃ ▄ ▅ ▆ ▇ █ ░ ▒ ▓
|
||||||
|
//
|
||||||
|
// Arrows and Triangles:
|
||||||
|
// ▶ ▷ ▸ ▹ ► ▻
|
||||||
|
//
|
||||||
|
// Special Characters:
|
||||||
|
// ◆ ◇ ♦ ♢ ★ ☆ ✦ ✧
|
||||||
251
mac/VibeTunnel/Presentation/Components/StatusBarController.swift
Normal file
251
mac/VibeTunnel/Presentation/Components/StatusBarController.swift
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
import Network
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Manages the macOS status bar item with custom left-click view and right-click menu.
|
||||||
|
@MainActor
|
||||||
|
final class StatusBarController: NSObject {
|
||||||
|
// MARK: - Core Properties
|
||||||
|
|
||||||
|
private var statusItem: NSStatusItem?
|
||||||
|
private let menuManager: StatusBarMenuManager
|
||||||
|
|
||||||
|
// MARK: - Dependencies
|
||||||
|
|
||||||
|
private let sessionMonitor: SessionMonitor
|
||||||
|
private let serverManager: ServerManager
|
||||||
|
private let ngrokService: NgrokService
|
||||||
|
private let tailscaleService: TailscaleService
|
||||||
|
private let terminalLauncher: TerminalLauncher
|
||||||
|
|
||||||
|
// MARK: - State Tracking
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var updateTimer: Timer?
|
||||||
|
private let monitor = NWPathMonitor()
|
||||||
|
private let monitorQueue = DispatchQueue(label: "vibetunnel.network.monitor")
|
||||||
|
private var hasNetworkAccess = true
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(
|
||||||
|
sessionMonitor: SessionMonitor,
|
||||||
|
serverManager: ServerManager,
|
||||||
|
ngrokService: NgrokService,
|
||||||
|
tailscaleService: TailscaleService,
|
||||||
|
terminalLauncher: TerminalLauncher
|
||||||
|
) {
|
||||||
|
self.sessionMonitor = sessionMonitor
|
||||||
|
self.serverManager = serverManager
|
||||||
|
self.ngrokService = ngrokService
|
||||||
|
self.tailscaleService = tailscaleService
|
||||||
|
self.terminalLauncher = terminalLauncher
|
||||||
|
|
||||||
|
self.menuManager = StatusBarMenuManager()
|
||||||
|
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
setupStatusItem()
|
||||||
|
setupMenuManager()
|
||||||
|
setupObservers()
|
||||||
|
startNetworkMonitoring()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Setup
|
||||||
|
|
||||||
|
private func setupStatusItem() {
|
||||||
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
|
|
||||||
|
if let button = statusItem?.button {
|
||||||
|
button.imagePosition = .imageLeading
|
||||||
|
button.action = #selector(handleClick(_:))
|
||||||
|
button.target = self
|
||||||
|
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
||||||
|
|
||||||
|
// Accessibility
|
||||||
|
button.setAccessibilityTitle("VibeTunnel")
|
||||||
|
button.setAccessibilityRole(.button)
|
||||||
|
button.setAccessibilityHelp("Shows terminal sessions and server information")
|
||||||
|
|
||||||
|
updateStatusItemDisplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupMenuManager() {
|
||||||
|
let configuration = StatusBarMenuManager.Configuration(
|
||||||
|
sessionMonitor: sessionMonitor,
|
||||||
|
serverManager: serverManager,
|
||||||
|
ngrokService: ngrokService,
|
||||||
|
tailscaleService: tailscaleService,
|
||||||
|
terminalLauncher: terminalLauncher
|
||||||
|
)
|
||||||
|
menuManager.setup(with: configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupObservers() {
|
||||||
|
// Create a timer to periodically update the display
|
||||||
|
// since SessionMonitor doesn't have a publisher
|
||||||
|
updateTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
_ = await self?.sessionMonitor.getSessions()
|
||||||
|
self?.updateStatusItemDisplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startNetworkMonitoring() {
|
||||||
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.hasNetworkAccess = path.status == .satisfied
|
||||||
|
self?.updateStatusItemDisplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monitor.start(queue: monitorQueue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Display Updates
|
||||||
|
|
||||||
|
func updateStatusItemDisplay() {
|
||||||
|
guard let button = statusItem?.button else { return }
|
||||||
|
|
||||||
|
// Update icon based on server and network status
|
||||||
|
let iconName = (serverManager.isRunning && hasNetworkAccess) ? "menubar" : "menubar.inactive"
|
||||||
|
if let image = NSImage(named: iconName) {
|
||||||
|
image.isTemplate = true
|
||||||
|
button.image = image
|
||||||
|
} else {
|
||||||
|
// Fallback to regular icon
|
||||||
|
if let image = NSImage(named: "menubar") {
|
||||||
|
image.isTemplate = true
|
||||||
|
button.image = image
|
||||||
|
button.alphaValue = (serverManager.isRunning && hasNetworkAccess) ? 1.0 : 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session count display
|
||||||
|
let sessions = sessionMonitor.sessions.values.filter(\.isRunning)
|
||||||
|
let activeSessions = sessions.filter { session in
|
||||||
|
// Check if session has recent activity (Claude Code or other custom actions)
|
||||||
|
if let activityStatus = session.activityStatus?.specificStatus?.status {
|
||||||
|
return !activityStatus.isEmpty
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeCount = activeSessions.count
|
||||||
|
let totalCount = sessions.count
|
||||||
|
|
||||||
|
// Format the title with visual indicators
|
||||||
|
// Try different styles by changing this:
|
||||||
|
// .dots (default): ●●● 5
|
||||||
|
// .bars: ▪︎▪︎▫︎▫︎▫︎
|
||||||
|
// .compact: 2◆5
|
||||||
|
// .minimalist: 2|5
|
||||||
|
// .meter: [■■□□□]
|
||||||
|
let indicatorStyle: IndicatorStyle = .minimalist
|
||||||
|
button.title = formatSessionIndicator(activeCount: activeCount, totalCount: totalCount, style: indicatorStyle)
|
||||||
|
|
||||||
|
// Update tooltip
|
||||||
|
updateTooltip()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateTooltip() {
|
||||||
|
guard let button = statusItem?.button else { return }
|
||||||
|
|
||||||
|
var tooltipParts: [String] = []
|
||||||
|
|
||||||
|
// Server status
|
||||||
|
if serverManager.isRunning {
|
||||||
|
let bindAddress = serverManager.bindAddress
|
||||||
|
if bindAddress == "127.0.0.1" {
|
||||||
|
tooltipParts.append("Server: 127.0.0.1:\(serverManager.port)")
|
||||||
|
} else if let localIP = NetworkUtility.getLocalIPAddress() {
|
||||||
|
tooltipParts.append("Server: \(localIP):\(serverManager.port)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ngrok status
|
||||||
|
if ngrokService.isActive, let publicURL = ngrokService.publicUrl {
|
||||||
|
tooltipParts.append("ngrok: \(publicURL)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tailscale status
|
||||||
|
if tailscaleService.isRunning, let hostname = tailscaleService.tailscaleHostname {
|
||||||
|
tooltipParts.append("Tailscale: \(hostname)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tooltipParts.append("Server stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session info
|
||||||
|
let sessions = sessionMonitor.sessions.values.filter(\.isRunning)
|
||||||
|
if !sessions.isEmpty {
|
||||||
|
let activeSessions = sessions.filter { session in
|
||||||
|
if let activityStatus = session.activityStatus?.specificStatus?.status {
|
||||||
|
return !activityStatus.isEmpty
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let idleCount = sessions.count - activeSessions.count
|
||||||
|
if !activeSessions.isEmpty {
|
||||||
|
if idleCount > 0 {
|
||||||
|
tooltipParts
|
||||||
|
.append(
|
||||||
|
"\(activeSessions.count) active, \(idleCount) idle session\(sessions.count == 1 ? "" : "s")"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
tooltipParts.append("\(activeSessions.count) active session\(activeSessions.count == 1 ? "" : "s")")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tooltipParts.append("\(sessions.count) idle session\(sessions.count == 1 ? "" : "s")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button.toolTip = tooltipParts.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Click Handling
|
||||||
|
|
||||||
|
@objc
|
||||||
|
private func handleClick(_ sender: NSStatusBarButton) {
|
||||||
|
guard let currentEvent = NSApp.currentEvent else {
|
||||||
|
handleLeftClick(sender)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch currentEvent.type {
|
||||||
|
case .leftMouseUp:
|
||||||
|
handleLeftClick(sender)
|
||||||
|
case .rightMouseUp:
|
||||||
|
handleRightClick(sender)
|
||||||
|
default:
|
||||||
|
handleLeftClick(sender)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleLeftClick(_ button: NSStatusBarButton) {
|
||||||
|
menuManager.toggleCustomWindow(relativeTo: button)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleRightClick(_ button: NSStatusBarButton) {
|
||||||
|
guard let statusItem else { return }
|
||||||
|
menuManager.showContextMenu(for: button, statusItem: statusItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
func showCustomWindow() {
|
||||||
|
guard let button = statusItem?.button else { return }
|
||||||
|
menuManager.showCustomWindow(relativeTo: button)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cleanup
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
updateTimer?.invalidate()
|
||||||
|
}
|
||||||
|
monitor.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Manages status bar menu behavior, providing left-click custom view and right-click context menu functionality.
|
||||||
|
@MainActor
|
||||||
|
final class StatusBarMenuManager {
|
||||||
|
// 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?
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
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: - 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 }
|
||||||
|
|
||||||
|
// Store button reference
|
||||||
|
self.statusBarButton = button
|
||||||
|
|
||||||
|
// Highlight the button immediately to show active state
|
||||||
|
button.highlight(true)
|
||||||
|
|
||||||
|
// Create the main view with all dependencies
|
||||||
|
let mainView = VibeTunnelMenuView()
|
||||||
|
.environment(sessionMonitor)
|
||||||
|
.environment(serverManager)
|
||||||
|
.environment(ngrokService)
|
||||||
|
.environment(tailscaleService)
|
||||||
|
.environment(terminalLauncher)
|
||||||
|
|
||||||
|
// 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 unhighlight button when window hides
|
||||||
|
customWindow?.onHide = { [weak self] in
|
||||||
|
// Ensure button is unhighlighted on main thread
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.statusBarButton?.highlight(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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?.statusBarButton?.highlight(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the custom window
|
||||||
|
customWindow?.show(relativeTo: button)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hideCustomWindow() {
|
||||||
|
customWindow?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
var isCustomWindowVisible: Bool {
|
||||||
|
customWindow?.isVisible ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Menu State Management
|
||||||
|
|
||||||
|
func hideAllMenus() {
|
||||||
|
hideCustomWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
var isAnyMenuVisible: Bool {
|
||||||
|
isCustomWindowVisible
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Right-Click Context Menu
|
||||||
|
|
||||||
|
func showContextMenu(for button: NSStatusBarButton, statusItem: NSStatusItem) {
|
||||||
|
// Hide custom window first if it's visible
|
||||||
|
hideCustomWindow()
|
||||||
|
|
||||||
|
let menu = NSMenu()
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
statusItem.menu = menu
|
||||||
|
button.performClick(nil)
|
||||||
|
statusItem.menu = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
583
mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift
Normal file
583
mac/VibeTunnel/Presentation/Components/VibeTunnelMenuView.swift
Normal file
|
|
@ -0,0 +1,583 @@
|
||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Main menu view displayed when left-clicking the status bar item.
|
||||||
|
/// Shows server status, session list, and quick actions in a rich interface.
|
||||||
|
struct VibeTunnelMenuView: View {
|
||||||
|
@Environment(SessionMonitor.self)
|
||||||
|
var sessionMonitor
|
||||||
|
@Environment(ServerManager.self)
|
||||||
|
var serverManager
|
||||||
|
@Environment(NgrokService.self)
|
||||||
|
var ngrokService
|
||||||
|
@Environment(TailscaleService.self)
|
||||||
|
var tailscaleService
|
||||||
|
@Environment(\.openWindow)
|
||||||
|
private var openWindow
|
||||||
|
|
||||||
|
@State private var hoveredSessionId: String?
|
||||||
|
@State private var hasStartedKeyboardNavigation = false
|
||||||
|
@FocusState private var focusedField: FocusField?
|
||||||
|
|
||||||
|
enum FocusField: Hashable {
|
||||||
|
case sessionRow(String)
|
||||||
|
case settingsButton
|
||||||
|
case quitButton
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header with server info
|
||||||
|
ServerInfoHeader()
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(NSColor.controlBackgroundColor).opacity(0.6),
|
||||||
|
Color(NSColor.controlBackgroundColor).opacity(0.3)
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Session list
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 1) {
|
||||||
|
if activeSessions.isEmpty && idleSessions.isEmpty {
|
||||||
|
EmptySessionsView()
|
||||||
|
.padding()
|
||||||
|
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||||
|
} else {
|
||||||
|
// Active sessions section
|
||||||
|
if !activeSessions.isEmpty {
|
||||||
|
SessionSectionHeader(title: "Active", count: activeSessions.count)
|
||||||
|
.transition(.opacity)
|
||||||
|
ForEach(activeSessions, id: \.key) { session in
|
||||||
|
SessionRow(
|
||||||
|
session: session,
|
||||||
|
isHovered: hoveredSessionId == session.key,
|
||||||
|
isActive: true,
|
||||||
|
isFocused: focusedField == .sessionRow(session.key) && hasStartedKeyboardNavigation
|
||||||
|
)
|
||||||
|
.onHover { hovering in
|
||||||
|
hoveredSessionId = hovering ? session.key : nil
|
||||||
|
}
|
||||||
|
.focused($focusedField, equals: .sessionRow(session.key))
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .opacity.combined(with: .move(edge: .top)),
|
||||||
|
removal: .opacity.combined(with: .move(edge: .bottom))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idle sessions section
|
||||||
|
if !idleSessions.isEmpty {
|
||||||
|
if !activeSessions.isEmpty {
|
||||||
|
Divider()
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionSectionHeader(title: "Idle", count: idleSessions.count)
|
||||||
|
.transition(.opacity)
|
||||||
|
ForEach(idleSessions, id: \.key) { session in
|
||||||
|
SessionRow(
|
||||||
|
session: session,
|
||||||
|
isHovered: hoveredSessionId == session.key,
|
||||||
|
isActive: false,
|
||||||
|
isFocused: focusedField == .sessionRow(session.key) && hasStartedKeyboardNavigation
|
||||||
|
)
|
||||||
|
.onHover { hovering in
|
||||||
|
hoveredSessionId = hovering ? session.key : nil
|
||||||
|
}
|
||||||
|
.focused($focusedField, equals: .sessionRow(session.key))
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .opacity.combined(with: .move(edge: .bottom)),
|
||||||
|
removal: .opacity.combined(with: .move(edge: .top))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.animation(.easeInOut(duration: 0.3), value: activeSessions.map(\.key))
|
||||||
|
.animation(.easeInOut(duration: 0.3), value: idleSessions.map(\.key))
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 400)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Bottom actions
|
||||||
|
HStack {
|
||||||
|
Button(action: {
|
||||||
|
SettingsOpener.openSettings()
|
||||||
|
}) {
|
||||||
|
Label("Settings", systemImage: "gear")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.focusable()
|
||||||
|
.focused($focusedField, equals: .settingsButton)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.strokeBorder(
|
||||||
|
focusedField == .settingsButton && hasStartedKeyboardNavigation ? Color.accentColor
|
||||||
|
.opacity(0.3) : Color.clear,
|
||||||
|
lineWidth: 1
|
||||||
|
)
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: focusedField)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
NSApplication.shared.terminate(nil)
|
||||||
|
}) {
|
||||||
|
Label("Quit", systemImage: "power")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.focusable()
|
||||||
|
.focused($focusedField, equals: .quitButton)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.strokeBorder(
|
||||||
|
focusedField == .quitButton && hasStartedKeyboardNavigation ? Color.accentColor
|
||||||
|
.opacity(0.3) : Color.clear,
|
||||||
|
lineWidth: 1
|
||||||
|
)
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: focusedField)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.frame(width: 384)
|
||||||
|
.background(Color.clear)
|
||||||
|
.onAppear {
|
||||||
|
// Clear any initial focus after a short delay
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .milliseconds(50))
|
||||||
|
await MainActor.run {
|
||||||
|
focusedField = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onKeyPress { keyPress in
|
||||||
|
if keyPress.key == .tab && !hasStartedKeyboardNavigation {
|
||||||
|
hasStartedKeyboardNavigation = true
|
||||||
|
// Let the system handle the Tab to actually move focus
|
||||||
|
return .ignored
|
||||||
|
}
|
||||||
|
return .ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activeSessions: [(key: String, value: ServerSessionInfo)] {
|
||||||
|
sessionMonitor.sessions
|
||||||
|
.filter { $0.value.isRunning && hasActivity($0.value) }
|
||||||
|
.sorted { $0.value.startedAt > $1.value.startedAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var idleSessions: [(key: String, value: ServerSessionInfo)] {
|
||||||
|
sessionMonitor.sessions
|
||||||
|
.filter { $0.value.isRunning && !hasActivity($0.value) }
|
||||||
|
.sorted { $0.value.startedAt > $1.value.startedAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hasActivity(_ session: ServerSessionInfo) -> Bool {
|
||||||
|
if let activityStatus = session.activityStatus?.specificStatus?.status {
|
||||||
|
return !activityStatus.isEmpty
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Server Info Header
|
||||||
|
|
||||||
|
struct ServerInfoHeader: View {
|
||||||
|
@Environment(ServerManager.self)
|
||||||
|
var serverManager
|
||||||
|
@Environment(NgrokService.self)
|
||||||
|
var ngrokService
|
||||||
|
@Environment(TailscaleService.self)
|
||||||
|
var tailscaleService
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
// Title and status
|
||||||
|
HStack {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.cornerRadius(4)
|
||||||
|
|
||||||
|
Text("VibeTunnel")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
ServerStatusBadge(isRunning: serverManager.isRunning)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server address
|
||||||
|
if serverManager.isRunning {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ServerAddressRow()
|
||||||
|
|
||||||
|
if ngrokService.isActive, let publicURL = ngrokService.publicUrl {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "network")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(.purple)
|
||||||
|
Text("ngrok:")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(publicURL)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.purple)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tailscaleService.isRunning, let hostname = tailscaleService.tailscaleHostname {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "shield")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
Text("Tailscale:")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Button(action: {
|
||||||
|
if let url = URL(string: "http://\(hostname)") {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text(hostname)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.underline()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.pointingHandCursor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ServerAddressRow: View {
|
||||||
|
@Environment(ServerManager.self)
|
||||||
|
var serverManager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "server.rack")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(.green)
|
||||||
|
Text("Local:")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Button(action: {
|
||||||
|
if let url = URL(string: "http://\(serverAddress)") {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text(serverAddress)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.underline()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.pointingHandCursor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var serverAddress: String {
|
||||||
|
let bindAddress = serverManager.bindAddress
|
||||||
|
if bindAddress == "127.0.0.1" {
|
||||||
|
return "127.0.0.1:\(serverManager.port)"
|
||||||
|
} else if let localIP = NetworkUtility.getLocalIPAddress() {
|
||||||
|
return "\(localIP):\(serverManager.port)"
|
||||||
|
} else {
|
||||||
|
return "0.0.0.0:\(serverManager.port)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ServerStatusBadge: View {
|
||||||
|
let isRunning: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Circle()
|
||||||
|
.fill(isRunning ? Color.green : Color.red)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
Text(isRunning ? "Running" : "Stopped")
|
||||||
|
.font(.system(size: 10, weight: .medium))
|
||||||
|
.foregroundColor(isRunning ? .green : .red)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(isRunning ? Color.green.opacity(0.1) : Color.red.opacity(0.1))
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.stroke(isRunning ? Color.green.opacity(0.3) : Color.red.opacity(0.3), lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Components
|
||||||
|
|
||||||
|
struct SessionSectionHeader: View {
|
||||||
|
let title: String
|
||||||
|
let count: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("(\(count))")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(Color.secondary.opacity(0.6))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SessionRow: View {
|
||||||
|
let session: (key: String, value: ServerSessionInfo)
|
||||||
|
let isHovered: Bool
|
||||||
|
let isActive: Bool
|
||||||
|
let isFocused: Bool
|
||||||
|
|
||||||
|
@Environment(\.openWindow)
|
||||||
|
private var openWindow
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: {
|
||||||
|
WindowTracker.shared.focusWindow(for: session.key)
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// Activity indicator with subtle glow
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(activityColor.opacity(0.3))
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
.blur(radius: 2)
|
||||||
|
.animation(.easeInOut(duration: 0.4), value: activityColor)
|
||||||
|
Circle()
|
||||||
|
.fill(activityColor)
|
||||||
|
.frame(width: 4, height: 4)
|
||||||
|
.animation(.easeInOut(duration: 0.4), value: activityColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session info
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack {
|
||||||
|
Text(sessionName)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if hasWindow {
|
||||||
|
Image(systemName: "macwindow")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let activityStatus = session.value.activityStatus?.specificStatus?.status {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(activityStatus)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(compactPath)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(compactPath)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration
|
||||||
|
Text(duration)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(Color.secondary.opacity(0.6))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(isHovered ? Color.accentColor.opacity(0.08) : Color.clear)
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: isHovered)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.strokeBorder(
|
||||||
|
isFocused ? Color.accentColor.opacity(0.3) : Color.clear,
|
||||||
|
lineWidth: 1
|
||||||
|
)
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: isFocused)
|
||||||
|
)
|
||||||
|
.focusable()
|
||||||
|
.contextMenu {
|
||||||
|
if hasWindow {
|
||||||
|
Button("Focus Terminal Window") {
|
||||||
|
WindowTracker.shared.focusWindow(for: session.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("View Session Details") {
|
||||||
|
openWindow(id: "session-detail", value: session.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("Copy Session ID") {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(session.key, forType: .string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sessionName: String {
|
||||||
|
let workingDir = session.value.workingDir
|
||||||
|
return (workingDir as NSString).lastPathComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
private var compactPath: String {
|
||||||
|
let path = session.value.workingDir
|
||||||
|
let homeDir = NSHomeDirectory()
|
||||||
|
|
||||||
|
if path.hasPrefix(homeDir) {
|
||||||
|
let relativePath = String(path.dropFirst(homeDir.count))
|
||||||
|
return "~" + relativePath
|
||||||
|
}
|
||||||
|
|
||||||
|
let components = (path as NSString).pathComponents
|
||||||
|
if components.count > 2 {
|
||||||
|
let lastTwo = components.suffix(2).joined(separator: "/")
|
||||||
|
return ".../" + lastTwo
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activityColor: Color {
|
||||||
|
if isActive {
|
||||||
|
.orange
|
||||||
|
} else {
|
||||||
|
.green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasWindow: Bool {
|
||||||
|
// Check if WindowTracker has a window registered for this session
|
||||||
|
WindowTracker.shared.windowInfo(for: session.key) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var duration: String {
|
||||||
|
// Parse ISO8601 date string with fractional seconds
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
|
||||||
|
guard let startDate = formatter.date(from: session.value.startedAt) else {
|
||||||
|
// Fallback: try without fractional seconds
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
guard let startDate = formatter.date(from: session.value.startedAt) else {
|
||||||
|
return "" // Return empty string instead of "unknown"
|
||||||
|
}
|
||||||
|
return formatDuration(from: startDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDuration(from: startDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDuration(from startDate: Date) -> String {
|
||||||
|
let elapsed = Date().timeIntervalSince(startDate)
|
||||||
|
|
||||||
|
if elapsed < 60 {
|
||||||
|
return "just now"
|
||||||
|
} else if elapsed < 3_600 {
|
||||||
|
let minutes = Int(elapsed / 60)
|
||||||
|
return "\(minutes)m"
|
||||||
|
} else if elapsed < 86_400 {
|
||||||
|
let hours = Int(elapsed / 3_600)
|
||||||
|
return "\(hours)h"
|
||||||
|
} else {
|
||||||
|
let days = Int(elapsed / 86_400)
|
||||||
|
return "\(days)d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EmptySessionsView: View {
|
||||||
|
@Environment(ServerManager.self)
|
||||||
|
var serverManager
|
||||||
|
@State private var isAnimating = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "terminal")
|
||||||
|
.font(.system(size: 32))
|
||||||
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.secondary, Color.secondary.opacity(0.6)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scaleEffect(isAnimating ? 1.05 : 1.0)
|
||||||
|
.animation(.easeInOut(duration: 2).repeatForever(autoreverses: true), value: isAnimating)
|
||||||
|
.onAppear { isAnimating = true }
|
||||||
|
|
||||||
|
Text("No active sessions")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
if serverManager.isRunning {
|
||||||
|
Button("Open Dashboard") {
|
||||||
|
if let url = URL(string: "http://127.0.0.1:\(serverManager.port)") {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.link)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
# Visual Indicator Styles for VibeTunnel Menu Bar
|
||||||
|
|
||||||
|
## Current Implementation
|
||||||
|
|
||||||
|
The menu bar now shows session status using visual indicators instead of cryptic numbers. Here are the available styles:
|
||||||
|
|
||||||
|
### 1. **Dots Style** (Default)
|
||||||
|
```
|
||||||
|
No sessions: [empty]
|
||||||
|
Only idle: 3
|
||||||
|
Only active: ●●●
|
||||||
|
Mixed (2/5): ●● 5
|
||||||
|
Many active: ●●●+ 8
|
||||||
|
```
|
||||||
|
- Filled dots (●) represent active sessions
|
||||||
|
- Shows up to 3 dots, then adds "+"
|
||||||
|
- Total count shown only when idle sessions exist
|
||||||
|
|
||||||
|
### 2. **Bars Style**
|
||||||
|
```
|
||||||
|
No sessions: [empty]
|
||||||
|
Only idle: ▫︎▫︎▫︎
|
||||||
|
Only active: ▪︎▪︎▪︎
|
||||||
|
Mixed (2/5): ▪︎▪︎▫︎▫︎▫︎
|
||||||
|
Many (3/7): ▪︎▪︎▪︎▫︎▫︎+
|
||||||
|
```
|
||||||
|
- Filled squares (▪︎) for active sessions
|
||||||
|
- Empty squares (▫︎) for idle sessions
|
||||||
|
- Shows up to 5 bars total
|
||||||
|
|
||||||
|
### 3. **Compact Style**
|
||||||
|
```
|
||||||
|
No sessions: [empty]
|
||||||
|
Only idle: ◯3
|
||||||
|
Only active: ◆2
|
||||||
|
Mixed (2/5): 2◆5
|
||||||
|
```
|
||||||
|
- Diamond (◆) as separator/indicator
|
||||||
|
- Most space-efficient option
|
||||||
|
|
||||||
|
### 4. **Minimalist Style**
|
||||||
|
```
|
||||||
|
No sessions: [empty]
|
||||||
|
Only idle: 3
|
||||||
|
Only active: ●2
|
||||||
|
Mixed (2/5): 2|5
|
||||||
|
```
|
||||||
|
- Simple vertical bar separator
|
||||||
|
- Dot prefix for active-only
|
||||||
|
|
||||||
|
### 5. **Meter Style**
|
||||||
|
```
|
||||||
|
No sessions: [empty]
|
||||||
|
Only idle: [□□□□□]
|
||||||
|
Only active: [■■■■■]
|
||||||
|
Mixed (2/5): [■■□□□]
|
||||||
|
Mixed (1/3): [■■□□□]
|
||||||
|
```
|
||||||
|
- Progress bar visualization
|
||||||
|
- Shows active/total ratio
|
||||||
|
|
||||||
|
## Changing Styles
|
||||||
|
|
||||||
|
To change the indicator style, modify line 144 in `StatusBarController.swift`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let indicatorStyle: IndicatorStyle = .dots // Change to .bars, .compact, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Button Highlighting
|
||||||
|
|
||||||
|
The menu bar button now properly highlights when the dropdown is open, providing clear visual feedback that the menu is active.
|
||||||
|
|
@ -18,7 +18,7 @@ struct AboutView: View {
|
||||||
return "\(version) (\(build))"
|
return "\(version) (\(build))"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special thanks contributors sorted by contribution count
|
/// Special thanks contributors sorted by contribution count
|
||||||
private let specialContributors = [
|
private let specialContributors = [
|
||||||
"Helmut Januschka",
|
"Helmut Januschka",
|
||||||
"Manuel Maly",
|
"Manuel Maly",
|
||||||
|
|
@ -187,9 +187,9 @@ struct HoverableLink: View {
|
||||||
|
|
||||||
// MARK: - Array Extension
|
// MARK: - Array Extension
|
||||||
|
|
||||||
private extension Array {
|
extension Array {
|
||||||
func chunked(into size: Int) -> [[Element]] {
|
fileprivate func chunked(into size: Int) -> [[Element]] {
|
||||||
return stride(from: 0, to: count, by: size).map {
|
stride(from: 0, to: count, by: size).map {
|
||||||
Array(self[$0..<Swift.min($0 + size, count)])
|
Array(self[$0..<Swift.min($0 + size, count)])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -181,13 +181,13 @@ struct MenuBarView: View {
|
||||||
while !serverManager.isRunning {
|
while !serverManager.isRunning {
|
||||||
try? await Task.sleep(for: .milliseconds(500))
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give the server a moment to fully initialize after starting
|
// Give the server a moment to fully initialize after starting
|
||||||
try? await Task.sleep(for: .milliseconds(100))
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
|
|
||||||
// Force initial refresh
|
// Force initial refresh
|
||||||
await sessionMonitor.refresh()
|
await sessionMonitor.refresh()
|
||||||
|
|
||||||
// Update sessions periodically while view is visible
|
// Update sessions periodically while view is visible
|
||||||
while true {
|
while true {
|
||||||
_ = await sessionMonitor.getSessions()
|
_ = await sessionMonitor.getSessions()
|
||||||
|
|
@ -339,28 +339,28 @@ struct SessionRowView: View {
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activity status and path row
|
// Activity status and path row
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(" ")
|
Text(" ")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
|
|
||||||
if let activityStatus = activityStatus {
|
if let activityStatus {
|
||||||
Text(activityStatus)
|
Text(activityStatus)
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
|
|
||||||
Text("·")
|
Text("·")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(.secondary.opacity(0.5))
|
.foregroundColor(.secondary.opacity(0.5))
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(compactPath)
|
Text(compactPath)
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -407,31 +407,31 @@ struct SessionRowView: View {
|
||||||
}
|
}
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
private var activityStatus: String? {
|
private var activityStatus: String? {
|
||||||
if let specificStatus = session.value.activityStatus?.specificStatus {
|
if let specificStatus = session.value.activityStatus?.specificStatus {
|
||||||
return specificStatus.status
|
return specificStatus.status
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private var compactPath: String {
|
private var compactPath: String {
|
||||||
let path = session.value.workingDir
|
let path = session.value.workingDir
|
||||||
let homeDir = NSHomeDirectory()
|
let homeDir = NSHomeDirectory()
|
||||||
|
|
||||||
// Replace home directory with ~
|
// Replace home directory with ~
|
||||||
if path.hasPrefix(homeDir) {
|
if path.hasPrefix(homeDir) {
|
||||||
let relativePath = String(path.dropFirst(homeDir.count))
|
let relativePath = String(path.dropFirst(homeDir.count))
|
||||||
return "~" + relativePath
|
return "~" + relativePath
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other paths, show last two components
|
// For other paths, show last two components
|
||||||
let components = (path as NSString).pathComponents
|
let components = (path as NSString).pathComponents
|
||||||
if components.count > 2 {
|
if components.count > 2 {
|
||||||
let lastTwo = components.suffix(2).joined(separator: "/")
|
let lastTwo = components.suffix(2).joined(separator: "/")
|
||||||
return ".../" + lastTwo
|
return ".../" + lastTwo
|
||||||
}
|
}
|
||||||
|
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ struct SessionDetailView: View {
|
||||||
DetailRow(label: "Status", value: session.status.capitalized)
|
DetailRow(label: "Status", value: session.status.capitalized)
|
||||||
DetailRow(label: "Started At", value: formatDate(session.startedAt))
|
DetailRow(label: "Started At", value: formatDate(session.startedAt))
|
||||||
DetailRow(label: "Last Modified", value: formatDate(session.lastModified))
|
DetailRow(label: "Last Modified", value: formatDate(session.lastModified))
|
||||||
|
|
||||||
if let pid = session.pid {
|
if let pid = session.pid {
|
||||||
DetailRow(label: "Process ID", value: "\(pid)")
|
DetailRow(label: "Process ID", value: "\(pid)")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ struct GeneralSettingsView: View {
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent Sleep
|
// Prevent Sleep
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Toggle("Prevent Sleep When Running", isOn: $preventSleepWhenRunning)
|
Toggle("Prevent Sleep When Running", isOn: $preventSleepWhenRunning)
|
||||||
|
|
|
||||||
|
|
@ -89,18 +89,7 @@ struct VibeTunnelApp: App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MenuBarExtra {
|
// MenuBarExtra is replaced by custom StatusBarController in AppDelegate
|
||||||
MenuBarView()
|
|
||||||
.environment(sessionMonitor)
|
|
||||||
.environment(serverManager)
|
|
||||||
.environment(ngrokService)
|
|
||||||
.environment(tailscaleService)
|
|
||||||
.environment(permissionManager)
|
|
||||||
.environment(terminalLauncher)
|
|
||||||
} label: {
|
|
||||||
Image("menubar")
|
|
||||||
.renderingMode(.template)
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -113,6 +102,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
|
||||||
private(set) var sparkleUpdaterManager: SparkleUpdaterManager?
|
private(set) var sparkleUpdaterManager: SparkleUpdaterManager?
|
||||||
var app: VibeTunnelApp?
|
var app: VibeTunnelApp?
|
||||||
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "AppDelegate")
|
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "AppDelegate")
|
||||||
|
private var statusBarController: StatusBarController?
|
||||||
|
|
||||||
/// Distributed notification name used to ask an existing instance to show the Settings window.
|
/// Distributed notification name used to ask an existing instance to show the Settings window.
|
||||||
private static let showSettingsNotification = Notification.Name("sh.vibetunnel.vibetunnel.showSettings")
|
private static let showSettingsNotification = Notification.Name("sh.vibetunnel.vibetunnel.showSettings")
|
||||||
|
|
@ -215,6 +205,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
|
||||||
logger.error("Server start error: \(error.localizedDescription)")
|
logger.error("Server start error: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize status bar controller after services are ready
|
||||||
|
if let sessionMonitor = app?.sessionMonitor,
|
||||||
|
let serverManager = app?.serverManager,
|
||||||
|
let ngrokService = app?.ngrokService,
|
||||||
|
let tailscaleService = app?.tailscaleService,
|
||||||
|
let terminalLauncher = app?.terminalLauncher
|
||||||
|
{
|
||||||
|
statusBarController = StatusBarController(
|
||||||
|
sessionMonitor: sessionMonitor,
|
||||||
|
serverManager: serverManager,
|
||||||
|
ngrokService: ngrokService,
|
||||||
|
tailscaleService: tailscaleService,
|
||||||
|
terminalLauncher: terminalLauncher
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,36 +4,35 @@ import Testing
|
||||||
|
|
||||||
@Suite("AppleScript Executor Tests", .tags(.integration))
|
@Suite("AppleScript Executor Tests", .tags(.integration))
|
||||||
struct AppleScriptExecutorTests {
|
struct AppleScriptExecutorTests {
|
||||||
|
|
||||||
@Test("Execute simple AppleScript")
|
@Test("Execute simple AppleScript")
|
||||||
@MainActor
|
@MainActor
|
||||||
func executeSimpleScript() throws {
|
func executeSimpleScript() throws {
|
||||||
let script = """
|
let script = """
|
||||||
return "Hello from AppleScript"
|
return "Hello from AppleScript"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
let result = try AppleScriptExecutor.shared.executeWithResult(script)
|
let result = try AppleScriptExecutor.shared.executeWithResult(script)
|
||||||
#expect(result == "Hello from AppleScript")
|
#expect(result == "Hello from AppleScript")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Execute script with math")
|
@Test("Execute script with math")
|
||||||
@MainActor
|
@MainActor
|
||||||
func executeScriptWithMath() throws {
|
func executeScriptWithMath() throws {
|
||||||
let script = """
|
let script = """
|
||||||
return 2 + 2
|
return 2 + 2
|
||||||
"""
|
"""
|
||||||
|
|
||||||
let result = try AppleScriptExecutor.shared.executeWithResult(script)
|
let result = try AppleScriptExecutor.shared.executeWithResult(script)
|
||||||
#expect(result == "4")
|
#expect(result == "4")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Handle script error")
|
@Test("Handle script error")
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleScriptError() throws {
|
func handleScriptError() throws {
|
||||||
let script = """
|
let script = """
|
||||||
error "This is a test error"
|
error "This is a test error"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try AppleScriptExecutor.shared.executeWithResult(script)
|
_ = try AppleScriptExecutor.shared.executeWithResult(script)
|
||||||
Issue.record("Expected error to be thrown")
|
Issue.record("Expected error to be thrown")
|
||||||
|
|
@ -41,14 +40,14 @@ struct AppleScriptExecutorTests {
|
||||||
#expect(error.localizedDescription.contains("test error"))
|
#expect(error.localizedDescription.contains("test error"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Handle invalid syntax")
|
@Test("Handle invalid syntax")
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleInvalidSyntax() throws {
|
func handleInvalidSyntax() throws {
|
||||||
let script = """
|
let script = """
|
||||||
this is not valid applescript syntax
|
this is not valid applescript syntax
|
||||||
"""
|
"""
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try AppleScriptExecutor.shared.executeWithResult(script)
|
_ = try AppleScriptExecutor.shared.executeWithResult(script)
|
||||||
Issue.record("Expected error to be thrown")
|
Issue.record("Expected error to be thrown")
|
||||||
|
|
@ -57,12 +56,12 @@ struct AppleScriptExecutorTests {
|
||||||
#expect(error is AppleScriptError)
|
#expect(error is AppleScriptError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Execute empty script")
|
@Test("Execute empty script")
|
||||||
@MainActor
|
@MainActor
|
||||||
func executeEmptyScript() throws {
|
func executeEmptyScript() throws {
|
||||||
let script = ""
|
let script = ""
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let result = try AppleScriptExecutor.shared.executeWithResult(script)
|
let result = try AppleScriptExecutor.shared.executeWithResult(script)
|
||||||
#expect(result.isEmpty || result == "missing value")
|
#expect(result.isEmpty || result == "missing value")
|
||||||
|
|
@ -71,7 +70,7 @@ struct AppleScriptExecutorTests {
|
||||||
#expect(error is AppleScriptError)
|
#expect(error is AppleScriptError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Check Terminal application", .disabled("Slow test - 0.44 seconds"))
|
@Test("Check Terminal application", .disabled("Slow test - 0.44 seconds"))
|
||||||
@MainActor
|
@MainActor
|
||||||
func checkTerminalApplication() throws {
|
func checkTerminalApplication() throws {
|
||||||
|
|
@ -80,19 +79,19 @@ struct AppleScriptExecutorTests {
|
||||||
return exists application process "Terminal"
|
return exists application process "Terminal"
|
||||||
end tell
|
end tell
|
||||||
"""
|
"""
|
||||||
|
|
||||||
let result = try AppleScriptExecutor.shared.executeWithResult(script)
|
let result = try AppleScriptExecutor.shared.executeWithResult(script)
|
||||||
// Result will be "true" or "false" as a string
|
// Result will be "true" or "false" as a string
|
||||||
#expect(result == "true" || result == "false")
|
#expect(result == "true" || result == "false")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Test async execution", .disabled("Slow test - 3.5 seconds"))
|
@Test("Test async execution", .disabled("Slow test - 3.5 seconds"))
|
||||||
func testAsyncExecution() async throws {
|
func asyncExecution() async throws {
|
||||||
// Test the async method
|
// Test the async method
|
||||||
let hasPermission = await AppleScriptExecutor.shared.checkPermission()
|
let hasPermission = await AppleScriptExecutor.shared.checkPermission()
|
||||||
#expect(hasPermission == true || hasPermission == false)
|
#expect(hasPermission == true || hasPermission == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Singleton instance")
|
@Test("Singleton instance")
|
||||||
@MainActor
|
@MainActor
|
||||||
func singletonInstance() {
|
func singletonInstance() {
|
||||||
|
|
@ -100,4 +99,4 @@ struct AppleScriptExecutorTests {
|
||||||
let instance2 = AppleScriptExecutor.shared
|
let instance2 = AppleScriptExecutor.shared
|
||||||
#expect(instance1 === instance2)
|
#expect(instance1 === instance2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
|
import AppKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
import AppKit
|
|
||||||
@testable import VibeTunnel
|
@testable import VibeTunnel
|
||||||
|
|
||||||
@Suite("Dock Icon Manager Tests")
|
@Suite("Dock Icon Manager Tests")
|
||||||
struct DockIconManagerTests {
|
struct DockIconManagerTests {
|
||||||
|
|
||||||
@Test("Singleton instance")
|
@Test("Singleton instance")
|
||||||
@MainActor
|
@MainActor
|
||||||
func singletonInstance() {
|
func singletonInstance() {
|
||||||
|
|
@ -13,21 +12,21 @@ struct DockIconManagerTests {
|
||||||
let instance2 = DockIconManager.shared
|
let instance2 = DockIconManager.shared
|
||||||
#expect(instance1 === instance2)
|
#expect(instance1 === instance2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Update dock visibility based on windows")
|
@Test("Update dock visibility based on windows")
|
||||||
@MainActor
|
@MainActor
|
||||||
func updateDockVisibilityBasedOnWindows() {
|
func updateDockVisibilityBasedOnWindows() {
|
||||||
let manager = DockIconManager.shared
|
let manager = DockIconManager.shared
|
||||||
|
|
||||||
// Save original preference
|
// Save original preference
|
||||||
let originalPref = UserDefaults.standard.bool(forKey: "showInDock")
|
let originalPref = UserDefaults.standard.bool(forKey: "showInDock")
|
||||||
|
|
||||||
// Set preference to hide dock
|
// Set preference to hide dock
|
||||||
UserDefaults.standard.set(false, forKey: "showInDock")
|
UserDefaults.standard.set(false, forKey: "showInDock")
|
||||||
|
|
||||||
// Update visibility - with no windows, dock should be hidden
|
// Update visibility - with no windows, dock should be hidden
|
||||||
manager.updateDockVisibility()
|
manager.updateDockVisibility()
|
||||||
|
|
||||||
// The policy depends on whether there are windows open
|
// The policy depends on whether there are windows open
|
||||||
// In test environment, NSApp might be nil
|
// In test environment, NSApp might be nil
|
||||||
if let app = NSApp {
|
if let app = NSApp {
|
||||||
|
|
@ -36,19 +35,19 @@ struct DockIconManagerTests {
|
||||||
// In test environment without NSApp, just verify no crash
|
// In test environment without NSApp, just verify no crash
|
||||||
#expect(true)
|
#expect(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore original preference
|
// Restore original preference
|
||||||
UserDefaults.standard.set(originalPref, forKey: "showInDock")
|
UserDefaults.standard.set(originalPref, forKey: "showInDock")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Temporarily show dock")
|
@Test("Temporarily show dock")
|
||||||
@MainActor
|
@MainActor
|
||||||
func temporarilyShowDock() {
|
func temporarilyShowDock() {
|
||||||
let manager = DockIconManager.shared
|
let manager = DockIconManager.shared
|
||||||
|
|
||||||
// Call temporarilyShowDock
|
// Call temporarilyShowDock
|
||||||
manager.temporarilyShowDock()
|
manager.temporarilyShowDock()
|
||||||
|
|
||||||
// In CI environment, NSApp might behave differently
|
// In CI environment, NSApp might behave differently
|
||||||
if let app = NSApp {
|
if let app = NSApp {
|
||||||
// Accept either regular or accessory since CI environment differs
|
// Accept either regular or accessory since CI environment differs
|
||||||
|
|
@ -58,13 +57,13 @@ struct DockIconManagerTests {
|
||||||
#expect(true)
|
#expect(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Dock visibility with user preference")
|
@Test("Dock visibility with user preference")
|
||||||
@MainActor
|
@MainActor
|
||||||
func dockVisibilityWithUserPreference() {
|
func dockVisibilityWithUserPreference() {
|
||||||
let manager = DockIconManager.shared
|
let manager = DockIconManager.shared
|
||||||
let originalPref = UserDefaults.standard.bool(forKey: "showInDock")
|
let originalPref = UserDefaults.standard.bool(forKey: "showInDock")
|
||||||
|
|
||||||
// Test with showInDock = true (user wants dock visible)
|
// Test with showInDock = true (user wants dock visible)
|
||||||
UserDefaults.standard.set(true, forKey: "showInDock")
|
UserDefaults.standard.set(true, forKey: "showInDock")
|
||||||
manager.updateDockVisibility()
|
manager.updateDockVisibility()
|
||||||
|
|
@ -75,7 +74,7 @@ struct DockIconManagerTests {
|
||||||
// In test environment without NSApp, just verify no crash
|
// In test environment without NSApp, just verify no crash
|
||||||
#expect(true)
|
#expect(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test with showInDock = false (user wants dock hidden)
|
// Test with showInDock = false (user wants dock hidden)
|
||||||
UserDefaults.standard.set(false, forKey: "showInDock")
|
UserDefaults.standard.set(false, forKey: "showInDock")
|
||||||
manager.updateDockVisibility()
|
manager.updateDockVisibility()
|
||||||
|
|
@ -87,7 +86,7 @@ struct DockIconManagerTests {
|
||||||
// In test environment without NSApp, just verify no crash
|
// In test environment without NSApp, just verify no crash
|
||||||
#expect(true)
|
#expect(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore
|
// Restore
|
||||||
UserDefaults.standard.set(originalPref, forKey: "showInDock")
|
UserDefaults.standard.set(originalPref, forKey: "showInDock")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import Testing
|
||||||
@Suite("Ngrok Service Tests", .tags(.networking))
|
@Suite("Ngrok Service Tests", .tags(.networking))
|
||||||
struct NgrokServiceTests {
|
struct NgrokServiceTests {
|
||||||
let testAuthToken = "test_auth_token_123"
|
let testAuthToken = "test_auth_token_123"
|
||||||
let testPort = 8888
|
let testPort = 8_888
|
||||||
|
|
||||||
@Test("Singleton instance")
|
@Test("Singleton instance")
|
||||||
@MainActor
|
@MainActor
|
||||||
func singletonInstance() {
|
func singletonInstance() {
|
||||||
|
|
@ -14,7 +14,7 @@ struct NgrokServiceTests {
|
||||||
let instance2 = NgrokService.shared
|
let instance2 = NgrokService.shared
|
||||||
#expect(instance1 === instance2)
|
#expect(instance1 === instance2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Initial state")
|
@Test("Initial state")
|
||||||
@MainActor
|
@MainActor
|
||||||
func initialState() {
|
func initialState() {
|
||||||
|
|
@ -23,40 +23,40 @@ struct NgrokServiceTests {
|
||||||
#expect(service.publicUrl == nil)
|
#expect(service.publicUrl == nil)
|
||||||
#expect(service.tunnelStatus == nil)
|
#expect(service.tunnelStatus == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Auth token management")
|
@Test("Auth token management")
|
||||||
@MainActor
|
@MainActor
|
||||||
func authTokenManagement() {
|
func authTokenManagement() {
|
||||||
let service = NgrokService.shared
|
let service = NgrokService.shared
|
||||||
|
|
||||||
// Save original token
|
// Save original token
|
||||||
let originalToken = service.authToken
|
let originalToken = service.authToken
|
||||||
|
|
||||||
// Set test token
|
// Set test token
|
||||||
service.authToken = testAuthToken
|
service.authToken = testAuthToken
|
||||||
#expect(service.authToken == testAuthToken)
|
#expect(service.authToken == testAuthToken)
|
||||||
#expect(service.hasAuthToken == true)
|
#expect(service.hasAuthToken == true)
|
||||||
|
|
||||||
// Clear token
|
// Clear token
|
||||||
service.authToken = nil
|
service.authToken = nil
|
||||||
#expect(service.authToken == nil)
|
#expect(service.authToken == nil)
|
||||||
#expect(service.hasAuthToken == false)
|
#expect(service.hasAuthToken == false)
|
||||||
|
|
||||||
// Restore original token
|
// Restore original token
|
||||||
service.authToken = originalToken
|
service.authToken = originalToken
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Start without auth token fails")
|
@Test("Start without auth token fails")
|
||||||
@MainActor
|
@MainActor
|
||||||
func startWithoutAuthToken() async throws {
|
func startWithoutAuthToken() async throws {
|
||||||
let service = NgrokService.shared
|
let service = NgrokService.shared
|
||||||
|
|
||||||
// Save original token
|
// Save original token
|
||||||
let originalToken = service.authToken
|
let originalToken = service.authToken
|
||||||
|
|
||||||
// Clear token
|
// Clear token
|
||||||
service.authToken = nil
|
service.authToken = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await service.start(port: testPort)
|
_ = try await service.start(port: testPort)
|
||||||
Issue.record("Expected error to be thrown")
|
Issue.record("Expected error to be thrown")
|
||||||
|
|
@ -65,51 +65,51 @@ struct NgrokServiceTests {
|
||||||
} catch {
|
} catch {
|
||||||
Issue.record("Expected NgrokError.authTokenMissing")
|
Issue.record("Expected NgrokError.authTokenMissing")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore original token
|
// Restore original token
|
||||||
service.authToken = originalToken
|
service.authToken = originalToken
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Stop when not running")
|
@Test("Stop when not running")
|
||||||
@MainActor
|
@MainActor
|
||||||
func stopWhenNotRunning() async throws {
|
func stopWhenNotRunning() async throws {
|
||||||
let service = NgrokService.shared
|
let service = NgrokService.shared
|
||||||
|
|
||||||
// Ensure not running
|
// Ensure not running
|
||||||
if service.isActive {
|
if service.isActive {
|
||||||
try await service.stop()
|
try await service.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop again should be safe
|
// Stop again should be safe
|
||||||
try await service.stop()
|
try await service.stop()
|
||||||
|
|
||||||
#expect(service.isActive == false)
|
#expect(service.isActive == false)
|
||||||
#expect(service.publicUrl == nil)
|
#expect(service.publicUrl == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Is running check")
|
@Test("Is running check")
|
||||||
@MainActor
|
@MainActor
|
||||||
func isRunningCheck() async {
|
func isRunningCheck() async {
|
||||||
let service = NgrokService.shared
|
let service = NgrokService.shared
|
||||||
|
|
||||||
let running = await service.isRunning()
|
let running = await service.isRunning()
|
||||||
#expect(running == service.isActive)
|
#expect(running == service.isActive)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Get status when inactive")
|
@Test("Get status when inactive")
|
||||||
@MainActor
|
@MainActor
|
||||||
func getStatusWhenInactive() async {
|
func getStatusWhenInactive() async {
|
||||||
let service = NgrokService.shared
|
let service = NgrokService.shared
|
||||||
|
|
||||||
// Ensure not running
|
// Ensure not running
|
||||||
if service.isActive {
|
if service.isActive {
|
||||||
try? await service.stop()
|
try? await service.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
let status = await service.getStatus()
|
let status = await service.getStatus()
|
||||||
#expect(status == nil)
|
#expect(status == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("NgrokError descriptions")
|
@Test("NgrokError descriptions")
|
||||||
func ngrokErrorDescriptions() {
|
func ngrokErrorDescriptions() {
|
||||||
let errors: [NgrokError] = [
|
let errors: [NgrokError] = [
|
||||||
|
|
@ -119,13 +119,13 @@ struct NgrokServiceTests {
|
||||||
.invalidConfiguration,
|
.invalidConfiguration,
|
||||||
.networkError("connection failed")
|
.networkError("connection failed")
|
||||||
]
|
]
|
||||||
|
|
||||||
for error in errors {
|
for error in errors {
|
||||||
#expect(error.errorDescription != nil)
|
#expect(error.errorDescription != nil)
|
||||||
#expect(!error.errorDescription!.isEmpty)
|
#expect(!error.errorDescription!.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("NgrokError equality")
|
@Test("NgrokError equality")
|
||||||
func ngrokErrorEquality() {
|
func ngrokErrorEquality() {
|
||||||
#expect(NgrokError.notInstalled == NgrokError.notInstalled)
|
#expect(NgrokError.notInstalled == NgrokError.notInstalled)
|
||||||
|
|
@ -133,4 +133,4 @@ struct NgrokServiceTests {
|
||||||
#expect(NgrokError.tunnelCreationFailed("a") == NgrokError.tunnelCreationFailed("a"))
|
#expect(NgrokError.tunnelCreationFailed("a") == NgrokError.tunnelCreationFailed("a"))
|
||||||
#expect(NgrokError.tunnelCreationFailed("a") != NgrokError.tunnelCreationFailed("b"))
|
#expect(NgrokError.tunnelCreationFailed("a") != NgrokError.tunnelCreationFailed("b"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,129 +1,128 @@
|
||||||
import Testing
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Testing
|
||||||
@testable import VibeTunnel
|
@testable import VibeTunnel
|
||||||
|
|
||||||
/// Tests for PowerManagementService that work reliably in CI environments
|
/// Tests for PowerManagementService that work reliably in CI environments
|
||||||
@Suite("Power Management Service")
|
@Suite("Power Management Service")
|
||||||
@MainActor
|
@MainActor
|
||||||
struct PowerManagementServiceTests {
|
struct PowerManagementServiceTests {
|
||||||
|
|
||||||
// Since PowerManagementService has a private init, we can only test through the shared instance
|
// Since PowerManagementService has a private init, we can only test through the shared instance
|
||||||
// We need to ensure proper cleanup between tests
|
// We need to ensure proper cleanup between tests
|
||||||
|
|
||||||
@Test("Sleep prevention defaults to true when key doesn't exist")
|
@Test("Sleep prevention defaults to true when key doesn't exist")
|
||||||
func sleepPreventionDefaultValue() async {
|
func sleepPreventionDefaultValue() async {
|
||||||
// Save current value
|
// Save current value
|
||||||
let currentValue = UserDefaults.standard.object(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
let currentValue = UserDefaults.standard.object(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
||||||
defer {
|
defer {
|
||||||
// Restore original value
|
// Restore original value
|
||||||
if let currentValue = currentValue {
|
if let currentValue {
|
||||||
UserDefaults.standard.set(currentValue, forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
UserDefaults.standard.set(currentValue, forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
||||||
} else {
|
} else {
|
||||||
UserDefaults.standard.removeObject(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
UserDefaults.standard.removeObject(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the key to simulate first launch
|
// Remove the key to simulate first launch
|
||||||
UserDefaults.standard.removeObject(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
UserDefaults.standard.removeObject(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
||||||
|
|
||||||
// Test our helper method returns true for non-existent key
|
// Test our helper method returns true for non-existent key
|
||||||
let defaultValue = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
let defaultValue = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
||||||
#expect(defaultValue == true, "Sleep prevention should default to true when key doesn't exist")
|
#expect(defaultValue == true, "Sleep prevention should default to true when key doesn't exist")
|
||||||
|
|
||||||
// Verify UserDefaults.standard.bool returns false (the bug we're fixing)
|
// Verify UserDefaults.standard.bool returns false (the bug we're fixing)
|
||||||
let standardDefault = UserDefaults.standard.bool(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
let standardDefault = UserDefaults.standard.bool(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
||||||
#expect(standardDefault == false, "UserDefaults.standard.bool returns false for non-existent keys")
|
#expect(standardDefault == false, "UserDefaults.standard.bool returns false for non-existent keys")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Update sleep prevention logic with all combinations")
|
@Test("Update sleep prevention logic with all combinations")
|
||||||
func updateSleepPreventionLogic() async {
|
func updateSleepPreventionLogic() async {
|
||||||
let service = PowerManagementService.shared
|
let service = PowerManagementService.shared
|
||||||
|
|
||||||
// Ensure clean state
|
// Ensure clean state
|
||||||
service.allowSleep()
|
service.allowSleep()
|
||||||
|
|
||||||
// Test Case 1: Both enabled and server running should prevent sleep
|
// Test Case 1: Both enabled and server running should prevent sleep
|
||||||
service.updateSleepPrevention(enabled: true, serverRunning: true)
|
service.updateSleepPrevention(enabled: true, serverRunning: true)
|
||||||
#expect(service.isSleepPrevented)
|
#expect(service.isSleepPrevented)
|
||||||
|
|
||||||
// Test Case 2: Disabled setting should allow sleep
|
// Test Case 2: Disabled setting should allow sleep
|
||||||
service.updateSleepPrevention(enabled: false, serverRunning: true)
|
service.updateSleepPrevention(enabled: false, serverRunning: true)
|
||||||
#expect(!service.isSleepPrevented)
|
#expect(!service.isSleepPrevented)
|
||||||
|
|
||||||
// Test Case 3: Server not running should allow sleep
|
// Test Case 3: Server not running should allow sleep
|
||||||
service.updateSleepPrevention(enabled: true, serverRunning: false)
|
service.updateSleepPrevention(enabled: true, serverRunning: false)
|
||||||
#expect(!service.isSleepPrevented)
|
#expect(!service.isSleepPrevented)
|
||||||
|
|
||||||
// Test Case 4: Both false should allow sleep
|
// Test Case 4: Both false should allow sleep
|
||||||
service.updateSleepPrevention(enabled: false, serverRunning: false)
|
service.updateSleepPrevention(enabled: false, serverRunning: false)
|
||||||
#expect(!service.isSleepPrevented)
|
#expect(!service.isSleepPrevented)
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
service.allowSleep()
|
service.allowSleep()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Multiple prevent sleep calls are idempotent")
|
@Test("Multiple prevent sleep calls are idempotent")
|
||||||
func preventSleepIdempotency() async {
|
func preventSleepIdempotency() async {
|
||||||
let service = PowerManagementService.shared
|
let service = PowerManagementService.shared
|
||||||
|
|
||||||
// Ensure clean state
|
// Ensure clean state
|
||||||
service.allowSleep()
|
service.allowSleep()
|
||||||
|
|
||||||
// Call preventSleep multiple times
|
// Call preventSleep multiple times
|
||||||
service.preventSleep()
|
service.preventSleep()
|
||||||
let firstState = service.isSleepPrevented
|
let firstState = service.isSleepPrevented
|
||||||
|
|
||||||
service.preventSleep()
|
service.preventSleep()
|
||||||
service.preventSleep()
|
service.preventSleep()
|
||||||
|
|
||||||
// State should remain the same
|
// State should remain the same
|
||||||
#expect(service.isSleepPrevented == firstState)
|
#expect(service.isSleepPrevented == firstState)
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
service.allowSleep()
|
service.allowSleep()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Multiple allow sleep calls are idempotent")
|
@Test("Multiple allow sleep calls are idempotent")
|
||||||
func allowSleepIdempotency() async {
|
func allowSleepIdempotency() async {
|
||||||
let service = PowerManagementService.shared
|
let service = PowerManagementService.shared
|
||||||
|
|
||||||
// Set up initial state
|
// Set up initial state
|
||||||
service.preventSleep()
|
service.preventSleep()
|
||||||
|
|
||||||
// Call allowSleep multiple times
|
// Call allowSleep multiple times
|
||||||
service.allowSleep()
|
service.allowSleep()
|
||||||
#expect(!service.isSleepPrevented)
|
#expect(!service.isSleepPrevented)
|
||||||
|
|
||||||
service.allowSleep()
|
service.allowSleep()
|
||||||
service.allowSleep()
|
service.allowSleep()
|
||||||
|
|
||||||
// State should remain false
|
// State should remain false
|
||||||
#expect(!service.isSleepPrevented)
|
#expect(!service.isSleepPrevented)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("State transitions work correctly")
|
@Test("State transitions work correctly")
|
||||||
func stateTransitions() async {
|
func stateTransitions() async {
|
||||||
let service = PowerManagementService.shared
|
let service = PowerManagementService.shared
|
||||||
|
|
||||||
// Ensure clean state
|
// Ensure clean state
|
||||||
service.allowSleep()
|
service.allowSleep()
|
||||||
#expect(!service.isSleepPrevented)
|
#expect(!service.isSleepPrevented)
|
||||||
|
|
||||||
// Prevent sleep
|
// Prevent sleep
|
||||||
service.preventSleep()
|
service.preventSleep()
|
||||||
#expect(service.isSleepPrevented)
|
#expect(service.isSleepPrevented)
|
||||||
|
|
||||||
// Allow sleep again
|
// Allow sleep again
|
||||||
service.allowSleep()
|
service.allowSleep()
|
||||||
#expect(!service.isSleepPrevented)
|
#expect(!service.isSleepPrevented)
|
||||||
|
|
||||||
// Use updateSleepPrevention
|
// Use updateSleepPrevention
|
||||||
service.updateSleepPrevention(enabled: true, serverRunning: true)
|
service.updateSleepPrevention(enabled: true, serverRunning: true)
|
||||||
#expect(service.isSleepPrevented)
|
#expect(service.isSleepPrevented)
|
||||||
|
|
||||||
service.updateSleepPrevention(enabled: false, serverRunning: false)
|
service.updateSleepPrevention(enabled: false, serverRunning: false)
|
||||||
#expect(!service.isSleepPrevented)
|
#expect(!service.isSleepPrevented)
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
service.allowSleep()
|
service.allowSleep()
|
||||||
}
|
}
|
||||||
|
|
@ -134,33 +133,32 @@ struct PowerManagementServiceTests {
|
||||||
@Suite("Power Management Edge Cases")
|
@Suite("Power Management Edge Cases")
|
||||||
@MainActor
|
@MainActor
|
||||||
struct PowerManagementEdgeCaseTests {
|
struct PowerManagementEdgeCaseTests {
|
||||||
|
|
||||||
@Test("Rapid state changes handle correctly")
|
@Test("Rapid state changes handle correctly")
|
||||||
func rapidStateChanges() async {
|
func rapidStateChanges() async {
|
||||||
let service = PowerManagementService.shared
|
let service = PowerManagementService.shared
|
||||||
|
|
||||||
// Ensure clean state
|
// Ensure clean state
|
||||||
service.allowSleep()
|
service.allowSleep()
|
||||||
|
|
||||||
// Rapidly toggle state
|
// Rapidly toggle state
|
||||||
for _ in 0..<10 {
|
for _ in 0..<10 {
|
||||||
service.preventSleep()
|
service.preventSleep()
|
||||||
service.allowSleep()
|
service.allowSleep()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final state should be sleep allowed
|
// Final state should be sleep allowed
|
||||||
#expect(!service.isSleepPrevented)
|
#expect(!service.isSleepPrevented)
|
||||||
|
|
||||||
// Now rapidly toggle with updateSleepPrevention
|
// Now rapidly toggle with updateSleepPrevention
|
||||||
for i in 0..<10 {
|
for i in 0..<10 {
|
||||||
let enabled = i % 2 == 0
|
let enabled = i % 2 == 0
|
||||||
service.updateSleepPrevention(enabled: enabled, serverRunning: true)
|
service.updateSleepPrevention(enabled: enabled, serverRunning: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final state should match last call (i=9, odd, so enabled=false)
|
// Final state should match last call (i=9, odd, so enabled=false)
|
||||||
#expect(!service.isSleepPrevented)
|
#expect(!service.isSleepPrevented)
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
service.allowSleep()
|
service.allowSleep()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ final class ServerManagerTests {
|
||||||
await manager.start()
|
await manager.start()
|
||||||
|
|
||||||
// Give server time to attempt start
|
// Give server time to attempt start
|
||||||
try await Task.sleep(for: .milliseconds(2000))
|
try await Task.sleep(for: .milliseconds(2_000))
|
||||||
|
|
||||||
// In test environment, server binary won't be found, so we expect failure
|
// In test environment, server binary won't be found, so we expect failure
|
||||||
// Check that lastError indicates the binary wasn't found
|
// Check that lastError indicates the binary wasn't found
|
||||||
|
|
@ -54,7 +54,7 @@ final class ServerManagerTests {
|
||||||
|
|
||||||
// First attempt to start
|
// First attempt to start
|
||||||
await manager.start()
|
await manager.start()
|
||||||
try await Task.sleep(for: .milliseconds(1000))
|
try await Task.sleep(for: .milliseconds(1_000))
|
||||||
|
|
||||||
let firstServer = manager.bunServer
|
let firstServer = manager.bunServer
|
||||||
let firstError = manager.lastError
|
let firstError = manager.lastError
|
||||||
|
|
@ -67,7 +67,8 @@ final class ServerManagerTests {
|
||||||
|
|
||||||
// Error should be consistent
|
// Error should be consistent
|
||||||
if let error1 = firstError as? BunServerError,
|
if let error1 = firstError as? BunServerError,
|
||||||
let error2 = manager.lastError as? BunServerError {
|
let error2 = manager.lastError as? BunServerError
|
||||||
|
{
|
||||||
#expect(error1 == error2)
|
#expect(error1 == error2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ final class SessionMonitorTests {
|
||||||
#expect(session.exitCode == nil)
|
#expect(session.exitCode == nil)
|
||||||
#expect(session.startedAt == "2025-01-01T10:00:00.000Z")
|
#expect(session.startedAt == "2025-01-01T10:00:00.000Z")
|
||||||
#expect(session.lastModified == "2025-01-01T10:05:00.000Z")
|
#expect(session.lastModified == "2025-01-01T10:05:00.000Z")
|
||||||
#expect(session.pid == 12345)
|
#expect(session.pid == 12_345)
|
||||||
#expect(session.initialCols == 80)
|
#expect(session.initialCols == 80)
|
||||||
#expect(session.initialRows == 24)
|
#expect(session.initialRows == 24)
|
||||||
#expect(session.activityStatus?.isActive == true)
|
#expect(session.activityStatus?.isActive == true)
|
||||||
|
|
@ -177,7 +177,7 @@ final class SessionMonitorTests {
|
||||||
#expect(sessions[0].id == "session-1")
|
#expect(sessions[0].id == "session-1")
|
||||||
#expect(sessions[0].command == ["bash"])
|
#expect(sessions[0].command == ["bash"])
|
||||||
#expect(sessions[0].isRunning == true)
|
#expect(sessions[0].isRunning == true)
|
||||||
#expect(sessions[0].pid == 1001)
|
#expect(sessions[0].pid == 1_001)
|
||||||
|
|
||||||
// Verify second session
|
// Verify second session
|
||||||
#expect(sessions[1].id == "session-2")
|
#expect(sessions[1].id == "session-2")
|
||||||
|
|
@ -288,8 +288,10 @@ final class SessionMonitorTests {
|
||||||
let data = json.data(using: .utf8)!
|
let data = json.data(using: .utf8)!
|
||||||
let session = try JSONDecoder().decode(ServerSessionInfo.self, from: data)
|
let session = try JSONDecoder().decode(ServerSessionInfo.self, from: data)
|
||||||
|
|
||||||
#expect(session.isRunning == expectedRunning,
|
#expect(
|
||||||
"Status '\(status)' should result in isRunning=\(expectedRunning)")
|
session.isRunning == expectedRunning,
|
||||||
|
"Status '\(status)' should result in isRunning=\(expectedRunning)"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -430,7 +432,7 @@ final class SessionMonitorTests {
|
||||||
let devSession = sessions[1]
|
let devSession = sessions[1]
|
||||||
#expect(devSession.command == ["pnpm", "run", "dev"])
|
#expect(devSession.command == ["pnpm", "run", "dev"])
|
||||||
#expect(devSession.isRunning == true)
|
#expect(devSession.isRunning == true)
|
||||||
#expect(devSession.pid == 34567)
|
#expect(devSession.pid == 34_567)
|
||||||
|
|
||||||
// Verify exited session
|
// Verify exited session
|
||||||
let gitSession = sessions[2]
|
let gitSession = sessions[2]
|
||||||
|
|
@ -485,4 +487,4 @@ final class SessionMonitorTests {
|
||||||
// Cached access should be very fast
|
// Cached access should be very fast
|
||||||
#expect(elapsed < 0.1, "Cached access took too long: \(elapsed)s for 100 calls")
|
#expect(elapsed < 0.1, "Cached access took too long: \(elapsed)s for 100 calls")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
|
||||||
import ServiceManagement
|
import ServiceManagement
|
||||||
|
import Testing
|
||||||
@testable import VibeTunnel
|
@testable import VibeTunnel
|
||||||
|
|
||||||
@Suite("Startup Manager Tests")
|
@Suite("Startup Manager Tests")
|
||||||
struct StartupManagerTests {
|
struct StartupManagerTests {
|
||||||
|
|
||||||
@Test("Create instance")
|
@Test("Create instance")
|
||||||
@MainActor
|
@MainActor
|
||||||
func createInstance() {
|
func createInstance() {
|
||||||
|
|
@ -13,54 +12,54 @@ struct StartupManagerTests {
|
||||||
// Just verify we can create an instance
|
// Just verify we can create an instance
|
||||||
#expect(manager.isLaunchAtLoginEnabled == true || manager.isLaunchAtLoginEnabled == false)
|
#expect(manager.isLaunchAtLoginEnabled == true || manager.isLaunchAtLoginEnabled == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Initial launch at login state")
|
@Test("Initial launch at login state")
|
||||||
@MainActor
|
@MainActor
|
||||||
func initialLaunchAtLoginState() {
|
func initialLaunchAtLoginState() {
|
||||||
let manager = StartupManager()
|
let manager = StartupManager()
|
||||||
|
|
||||||
// The initial state depends on system configuration
|
// The initial state depends on system configuration
|
||||||
// We just verify it returns a boolean
|
// We just verify it returns a boolean
|
||||||
let state = manager.isLaunchAtLoginEnabled
|
let state = manager.isLaunchAtLoginEnabled
|
||||||
#expect(state == true || state == false)
|
#expect(state == true || state == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Set launch at login")
|
@Test("Set launch at login")
|
||||||
@MainActor
|
@MainActor
|
||||||
func setLaunchAtLogin() {
|
func setLaunchAtLogin() {
|
||||||
let manager = StartupManager()
|
let manager = StartupManager()
|
||||||
|
|
||||||
// Try to enable (may fail in test environment)
|
// Try to enable (may fail in test environment)
|
||||||
manager.setLaunchAtLogin(enabled: true)
|
manager.setLaunchAtLogin(enabled: true)
|
||||||
|
|
||||||
// Try to disable (may fail in test environment)
|
// Try to disable (may fail in test environment)
|
||||||
manager.setLaunchAtLogin(enabled: false)
|
manager.setLaunchAtLogin(enabled: false)
|
||||||
|
|
||||||
// We can't verify the actual state change in tests
|
// We can't verify the actual state change in tests
|
||||||
// Just ensure the methods don't crash
|
// Just ensure the methods don't crash
|
||||||
#expect(true)
|
#expect(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Service management availability")
|
@Test("Service management availability")
|
||||||
@available(macOS 13.0, *)
|
@available(macOS 13.0, *)
|
||||||
func serviceManagementAvailability() {
|
func serviceManagementAvailability() {
|
||||||
// Test that we can at least query the service status
|
// Test that we can at least query the service status
|
||||||
let service = SMAppService.mainApp
|
let service = SMAppService.mainApp
|
||||||
|
|
||||||
// Status should be queryable
|
// Status should be queryable
|
||||||
let status = service.status
|
let status = service.status
|
||||||
|
|
||||||
// We just verify that we can get a status without crashing
|
// We just verify that we can get a status without crashing
|
||||||
// The actual value depends on the test environment
|
// The actual value depends on the test environment
|
||||||
#expect(status.rawValue >= 0)
|
#expect(status.rawValue >= 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("App bundle identifier")
|
@Test("App bundle identifier")
|
||||||
func appBundleIdentifier() {
|
func appBundleIdentifier() {
|
||||||
// In test environment, bundle identifier might be nil
|
// In test environment, bundle identifier might be nil
|
||||||
let bundleId = Bundle.main.bundleIdentifier
|
let bundleId = Bundle.main.bundleIdentifier
|
||||||
|
|
||||||
if let bundleId = bundleId {
|
if let bundleId {
|
||||||
#expect(!bundleId.isEmpty)
|
#expect(!bundleId.isEmpty)
|
||||||
// In test environment, bundle ID can vary widely
|
// In test environment, bundle ID can vary widely
|
||||||
// Just verify it's a valid identifier format (contains a dot for reverse domain notation)
|
// Just verify it's a valid identifier format (contains a dot for reverse domain notation)
|
||||||
|
|
@ -70,18 +69,18 @@ struct StartupManagerTests {
|
||||||
#expect(bundleId == nil)
|
#expect(bundleId == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Multiple operations")
|
@Test("Multiple operations")
|
||||||
@MainActor
|
@MainActor
|
||||||
func multipleOperations() {
|
func multipleOperations() {
|
||||||
let manager = StartupManager()
|
let manager = StartupManager()
|
||||||
|
|
||||||
// Perform multiple operations
|
// Perform multiple operations
|
||||||
manager.setLaunchAtLogin(enabled: true)
|
manager.setLaunchAtLogin(enabled: true)
|
||||||
manager.setLaunchAtLogin(enabled: false)
|
manager.setLaunchAtLogin(enabled: false)
|
||||||
manager.setLaunchAtLogin(enabled: true)
|
manager.setLaunchAtLogin(enabled: true)
|
||||||
|
|
||||||
// Just ensure no crashes
|
// Just ensure no crashes
|
||||||
#expect(true)
|
#expect(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ enum TestFixtures {
|
||||||
processID: Int32? = nil,
|
processID: Int32? = nil,
|
||||||
isActive: Bool = true
|
isActive: Bool = true
|
||||||
)
|
)
|
||||||
-> TunnelSession {
|
-> TunnelSession
|
||||||
|
{
|
||||||
var session = TunnelSession(
|
var session = TunnelSession(
|
||||||
id: UUID(uuidString: id) ?? UUID(),
|
id: UUID(uuidString: id) ?? UUID(),
|
||||||
processID: processID
|
processID: processID
|
||||||
|
|
@ -34,7 +35,8 @@ enum TestFixtures {
|
||||||
static func createSessionRequest(
|
static func createSessionRequest(
|
||||||
clientInfo: TunnelSession.ClientInfo? = nil
|
clientInfo: TunnelSession.ClientInfo? = nil
|
||||||
)
|
)
|
||||||
-> TunnelSession.CreateRequest {
|
-> TunnelSession.CreateRequest
|
||||||
|
{
|
||||||
TunnelSession.CreateRequest(clientInfo: clientInfo ?? defaultClientInfo())
|
TunnelSession.CreateRequest(clientInfo: clientInfo ?? defaultClientInfo())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,7 +44,8 @@ enum TestFixtures {
|
||||||
id: String = "00000000-0000-0000-0000-000000000123",
|
id: String = "00000000-0000-0000-0000-000000000123",
|
||||||
session: TunnelSession? = nil
|
session: TunnelSession? = nil
|
||||||
)
|
)
|
||||||
-> TunnelSession.CreateResponse {
|
-> TunnelSession.CreateResponse
|
||||||
|
{
|
||||||
TunnelSession.CreateResponse(
|
TunnelSession.CreateResponse(
|
||||||
id: id,
|
id: id,
|
||||||
session: session ?? createSession(id: id)
|
session: session ?? createSession(id: id)
|
||||||
|
|
@ -57,7 +60,8 @@ enum TestFixtures {
|
||||||
environment: [String: String]? = nil,
|
environment: [String: String]? = nil,
|
||||||
workingDirectory: String? = nil
|
workingDirectory: String? = nil
|
||||||
)
|
)
|
||||||
-> TunnelSession.ExecuteCommandRequest {
|
-> TunnelSession.ExecuteCommandRequest
|
||||||
|
{
|
||||||
TunnelSession.ExecuteCommandRequest(
|
TunnelSession.ExecuteCommandRequest(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
command: command,
|
command: command,
|
||||||
|
|
@ -71,7 +75,8 @@ enum TestFixtures {
|
||||||
stdout: String = "test output",
|
stdout: String = "test output",
|
||||||
stderr: String = ""
|
stderr: String = ""
|
||||||
)
|
)
|
||||||
-> TunnelSession.ExecuteCommandResponse {
|
-> TunnelSession.ExecuteCommandResponse
|
||||||
|
{
|
||||||
TunnelSession.ExecuteCommandResponse(
|
TunnelSession.ExecuteCommandResponse(
|
||||||
exitCode: exitCode,
|
exitCode: exitCode,
|
||||||
stdout: stdout,
|
stdout: stdout,
|
||||||
|
|
@ -85,7 +90,8 @@ enum TestFixtures {
|
||||||
error: String = "Test error",
|
error: String = "Test error",
|
||||||
code: String? = "TEST_ERROR"
|
code: String? = "TEST_ERROR"
|
||||||
)
|
)
|
||||||
-> TunnelSession.ErrorResponse {
|
-> TunnelSession.ErrorResponse
|
||||||
|
{
|
||||||
TunnelSession.ErrorResponse(error: error, code: code)
|
TunnelSession.ErrorResponse(error: error, code: code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,7 +122,8 @@ extension TestFixtures {
|
||||||
timeout: TimeInterval = 1.0,
|
timeout: TimeInterval = 1.0,
|
||||||
interval: TimeInterval = 0.1
|
interval: TimeInterval = 0.1
|
||||||
)
|
)
|
||||||
async throws {
|
async throws
|
||||||
|
{
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue