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
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Download web build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: web-build-${{ github.sha }}
|
||||
path: web/
|
||||
|
||||
- name: Resolve Dependencies (once)
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@ struct WelcomeView: View {
|
|||
HStack(spacing: 8) {
|
||||
ForEach(0..<5) { index in
|
||||
Circle()
|
||||
.fill(index == currentPage ?
|
||||
Theme.Colors.primaryAccent :
|
||||
.fill(index == currentPage ?
|
||||
Theme.Colors.primaryAccent :
|
||||
Theme.Colors.secondaryText.opacity(0.3))
|
||||
.frame(width: 8, height: 8)
|
||||
.animation(.easeInOut, value: currentPage)
|
||||
|
|
|
|||
|
|
@ -11,13 +11,13 @@ enum AppConstants {
|
|||
static let welcomeVersion = "welcomeVersion"
|
||||
static let preventSleepWhenRunning = "preventSleepWhenRunning"
|
||||
}
|
||||
|
||||
|
||||
/// Default values for UserDefaults
|
||||
enum Defaults {
|
||||
/// Sleep prevention is enabled by default for better user experience
|
||||
static let preventSleepWhenRunning = true
|
||||
}
|
||||
|
||||
|
||||
/// Helper to get boolean value with proper default
|
||||
static func boolValue(for key: String) -> Bool {
|
||||
// If the key doesn't exist in UserDefaults, return our default
|
||||
|
|
|
|||
|
|
@ -12,28 +12,28 @@ import Observation
|
|||
@MainActor
|
||||
final class PowerManagementService {
|
||||
static let shared = PowerManagementService()
|
||||
|
||||
|
||||
private(set) var isSleepPrevented = false
|
||||
|
||||
|
||||
private var assertionID: IOPMAssertionID = 0
|
||||
private var isAssertionActive = false
|
||||
|
||||
|
||||
private init() {}
|
||||
|
||||
|
||||
/// Prevents the system from sleeping
|
||||
func preventSleep() {
|
||||
guard !isAssertionActive else { return }
|
||||
|
||||
|
||||
let reason = "VibeTunnel is running terminal sessions" as CFString
|
||||
let assertionType = kIOPMAssertionTypeNoIdleSleep as CFString
|
||||
|
||||
|
||||
let success = IOPMAssertionCreateWithName(
|
||||
assertionType,
|
||||
IOPMAssertionLevel(kIOPMAssertionLevelOn),
|
||||
reason,
|
||||
&assertionID
|
||||
)
|
||||
|
||||
|
||||
if success == kIOReturnSuccess {
|
||||
isAssertionActive = true
|
||||
isSleepPrevented = true
|
||||
|
|
@ -42,13 +42,13 @@ final class PowerManagementService {
|
|||
print("Failed to prevent sleep: \(success)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Allows the system to sleep normally
|
||||
func allowSleep() {
|
||||
guard isAssertionActive else { return }
|
||||
|
||||
|
||||
let success = IOPMAssertionRelease(assertionID)
|
||||
|
||||
|
||||
if success == kIOReturnSuccess {
|
||||
isAssertionActive = false
|
||||
isSleepPrevented = false
|
||||
|
|
@ -58,7 +58,7 @@ final class PowerManagementService {
|
|||
print("Failed to release sleep assertion: \(success)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Updates sleep prevention based on user preference and server state
|
||||
func updateSleepPrevention(enabled: Bool, serverRunning: Bool) {
|
||||
if enabled && serverRunning {
|
||||
|
|
@ -67,10 +67,10 @@ final class PowerManagementService {
|
|||
allowSleep()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
// Deinit runs on arbitrary thread, but we need to check MainActor state
|
||||
// Since we can't access MainActor properties directly in deinit,
|
||||
// we handle cleanup in allowSleep() which is called when server stops
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,11 +128,11 @@ class ServerManager {
|
|||
Task { @MainActor in
|
||||
// Only update sleep prevention if server is running
|
||||
guard isRunning else { return }
|
||||
|
||||
|
||||
// Check if preventSleepWhenRunning setting changed
|
||||
let preventSleep = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
||||
powerManager.updateSleepPrevention(enabled: preventSleep, serverRunning: true)
|
||||
|
||||
|
||||
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
|
||||
let preventSleep = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
||||
powerManager.updateSleepPrevention(enabled: preventSleep, serverRunning: true)
|
||||
|
||||
|
||||
// Now update state
|
||||
isRunning = true
|
||||
lastError = nil
|
||||
|
|
@ -276,7 +276,7 @@ class ServerManager {
|
|||
|
||||
// Clear the auth token from SessionMonitor
|
||||
SessionMonitor.shared.setLocalAuthToken(nil)
|
||||
|
||||
|
||||
// Allow sleep when server is stopped
|
||||
powerManager.updateSleepPrevention(enabled: false, serverRunning: false)
|
||||
|
||||
|
|
@ -396,7 +396,7 @@ class ServerManager {
|
|||
// Update state immediately
|
||||
isRunning = false
|
||||
bunServer = nil
|
||||
|
||||
|
||||
// Allow sleep when server crashes
|
||||
powerManager.updateSleepPrevention(enabled: false, serverRunning: false)
|
||||
|
||||
|
|
@ -516,7 +516,7 @@ class ServerManager {
|
|||
let preventSleep = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
||||
powerManager.updateSleepPrevention(enabled: preventSleep, serverRunning: true)
|
||||
}
|
||||
|
||||
|
||||
while true {
|
||||
try? await Task.sleep(for: .seconds(30))
|
||||
|
||||
|
|
|
|||
|
|
@ -5,18 +5,18 @@ import os.log
|
|||
/// Server session information returned by the API
|
||||
struct ServerSessionInfo: Codable {
|
||||
let id: String
|
||||
let command: [String] // Changed from String to [String] to match server
|
||||
let name: String? // Added missing field
|
||||
let command: [String] // Changed from String to [String] to match server
|
||||
let name: String? // Added missing field
|
||||
let workingDir: String
|
||||
let status: String
|
||||
let exitCode: Int?
|
||||
let startedAt: String
|
||||
let lastModified: String
|
||||
let pid: Int? // Made optional since it might not exist for all sessions
|
||||
let initialCols: Int? // Added missing field
|
||||
let initialRows: Int? // Added missing field
|
||||
let pid: Int? // Made optional since it might not exist for all sessions
|
||||
let initialCols: Int? // Added missing field
|
||||
let initialRows: Int? // Added missing field
|
||||
let activityStatus: ActivityStatus?
|
||||
let source: String? // Added for HQ mode
|
||||
let source: String? // Added for HQ mode
|
||||
|
||||
var isRunning: Bool {
|
||||
status == "running"
|
||||
|
|
@ -123,13 +123,19 @@ final class SessionMonitor {
|
|||
self.sessions = sessionsDict
|
||||
self.lastError = nil
|
||||
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
|
||||
for session in sessionsArray {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -57,24 +57,65 @@ final class WindowTracker {
|
|||
) {
|
||||
logger.info("Registering window for session: \(sessionID), terminal: \(terminalApp.rawValue)")
|
||||
|
||||
// Give the terminal some time to create the window
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(1.0))
|
||||
// For Terminal.app and iTerm2 with explicit window/tab info, register immediately
|
||||
if (terminalApp == .terminal && tabReference != nil) ||
|
||||
(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(
|
||||
for: terminalApp,
|
||||
sessionID: sessionID,
|
||||
tabReference: tabReference,
|
||||
tabID: tabID
|
||||
) {
|
||||
mapLock.withLock {
|
||||
sessionWindowMap[sessionID] = windowInfo
|
||||
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) 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
|
||||
guard let terminal = Terminal.allCases.first(where: { term in
|
||||
// Match by process name or app name
|
||||
ownerName == term.processName || ownerName == term.rawValue
|
||||
// Match by process name, app name, or bundle identifier parts
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -157,46 +204,125 @@ final class WindowTracker {
|
|||
// Filter windows for the specific terminal
|
||||
let terminalWindows = allWindows.filter { $0.terminalApp == terminal }
|
||||
|
||||
// If we have specific tab information, try to match by title or other properties
|
||||
// For now, return the most recently created window (highest window ID)
|
||||
guard let latestWindow = terminalWindows.max(by: { $0.windowID < $1.windowID }) else {
|
||||
return nil
|
||||
// First try to find window by title containing session path or command
|
||||
// Sessions typically show their working directory in the title
|
||||
if let sessionInfo = getSessionInfo(for: sessionID) {
|
||||
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
|
||||
return WindowInfo(
|
||||
windowID: latestWindow.windowID,
|
||||
ownerPID: latestWindow.ownerPID,
|
||||
// For Terminal.app and iTerm2 with specific tab/window IDs, use those
|
||||
if terminal == .terminal, let tabRef = tabReference {
|
||||
// Extract window ID from tab reference (format: "tab id X of window id Y")
|
||||
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,
|
||||
sessionID: sessionID,
|
||||
createdAt: Date(),
|
||||
tabReference: tabReference,
|
||||
tabID: tabID,
|
||||
bounds: latestWindow.bounds,
|
||||
title: latestWindow.title
|
||||
bounds: window.bounds,
|
||||
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
|
||||
|
||||
/// Focuses the window associated with a session.
|
||||
func focusWindow(for sessionID: String) {
|
||||
mapLock.withLock {
|
||||
guard let windowInfo = sessionWindowMap[sessionID] else {
|
||||
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
|
||||
}
|
||||
// First check if we have the window info
|
||||
let windowInfo = mapLock.withLock { sessionWindowMap[sessionID] }
|
||||
|
||||
if let windowInfo {
|
||||
// We have window info, try to focus it
|
||||
logger
|
||||
.info(
|
||||
"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
|
||||
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.
|
||||
private func focusTerminalAppWindow(_ windowInfo: WindowInfo) {
|
||||
if let tabRef = windowInfo.tabReference {
|
||||
|
|
@ -354,17 +558,47 @@ final class WindowTracker {
|
|||
private func scanForSession(_ sessionID: String) async {
|
||||
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
|
||||
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
|
||||
// Sessions typically show their ID in the window title
|
||||
for window in allWindows {
|
||||
// Check if window title contains session ID
|
||||
if let title = window.title,
|
||||
title.contains(sessionID) || title.contains("vt") || title.contains("vibetunnel")
|
||||
{
|
||||
logger.info("Found potential window for session \(sessionID): \(title)")
|
||||
var matchFound = false
|
||||
var matchReason = ""
|
||||
|
||||
// Check if window title contains working directory or session markers
|
||||
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
|
||||
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
|
||||
|
|
|
|||
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))"
|
||||
}
|
||||
|
||||
// Special thanks contributors sorted by contribution count
|
||||
/// Special thanks contributors sorted by contribution count
|
||||
private let specialContributors = [
|
||||
"Helmut Januschka",
|
||||
"Manuel Maly",
|
||||
|
|
@ -187,9 +187,9 @@ struct HoverableLink: View {
|
|||
|
||||
// MARK: - Array Extension
|
||||
|
||||
private extension Array {
|
||||
func chunked(into size: Int) -> [[Element]] {
|
||||
return stride(from: 0, to: count, by: size).map {
|
||||
extension Array {
|
||||
fileprivate func chunked(into size: Int) -> [[Element]] {
|
||||
stride(from: 0, to: count, by: size).map {
|
||||
Array(self[$0..<Swift.min($0 + size, count)])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,13 +181,13 @@ struct MenuBarView: View {
|
|||
while !serverManager.isRunning {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
}
|
||||
|
||||
|
||||
// Give the server a moment to fully initialize after starting
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
|
||||
|
||||
// Force initial refresh
|
||||
await sessionMonitor.refresh()
|
||||
|
||||
|
||||
// Update sessions periodically while view is visible
|
||||
while true {
|
||||
_ = await sessionMonitor.getSessions()
|
||||
|
|
@ -339,28 +339,28 @@ struct SessionRowView: View {
|
|||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Activity status and path row
|
||||
HStack(spacing: 4) {
|
||||
Text(" ")
|
||||
.font(.system(size: 11))
|
||||
|
||||
if let activityStatus = activityStatus {
|
||||
|
||||
if let activityStatus {
|
||||
Text(activityStatus)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.orange)
|
||||
|
||||
|
||||
Text("·")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary.opacity(0.5))
|
||||
}
|
||||
|
||||
|
||||
Text(compactPath)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
|
@ -407,31 +407,31 @@ struct SessionRowView: View {
|
|||
}
|
||||
return name
|
||||
}
|
||||
|
||||
|
||||
private var activityStatus: String? {
|
||||
if let specificStatus = session.value.activityStatus?.specificStatus {
|
||||
return specificStatus.status
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
private var compactPath: String {
|
||||
let path = session.value.workingDir
|
||||
let homeDir = NSHomeDirectory()
|
||||
|
||||
|
||||
// Replace home directory with ~
|
||||
if path.hasPrefix(homeDir) {
|
||||
let relativePath = String(path.dropFirst(homeDir.count))
|
||||
return "~" + relativePath
|
||||
}
|
||||
|
||||
|
||||
// For other paths, show last two components
|
||||
let components = (path as NSString).pathComponents
|
||||
if components.count > 2 {
|
||||
let lastTwo = components.suffix(2).joined(separator: "/")
|
||||
return ".../" + lastTwo
|
||||
}
|
||||
|
||||
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ struct SessionDetailView: View {
|
|||
DetailRow(label: "Status", value: session.status.capitalized)
|
||||
DetailRow(label: "Started At", value: formatDate(session.startedAt))
|
||||
DetailRow(label: "Last Modified", value: formatDate(session.lastModified))
|
||||
|
||||
|
||||
if let pid = session.pid {
|
||||
DetailRow(label: "Process ID", value: "\(pid)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ struct GeneralSettingsView: View {
|
|||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
|
||||
// Prevent Sleep
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Toggle("Prevent Sleep When Running", isOn: $preventSleepWhenRunning)
|
||||
|
|
|
|||
|
|
@ -89,18 +89,7 @@ struct VibeTunnelApp: App {
|
|||
}
|
||||
}
|
||||
|
||||
MenuBarExtra {
|
||||
MenuBarView()
|
||||
.environment(sessionMonitor)
|
||||
.environment(serverManager)
|
||||
.environment(ngrokService)
|
||||
.environment(tailscaleService)
|
||||
.environment(permissionManager)
|
||||
.environment(terminalLauncher)
|
||||
} label: {
|
||||
Image("menubar")
|
||||
.renderingMode(.template)
|
||||
}
|
||||
// MenuBarExtra is replaced by custom StatusBarController in AppDelegate
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
@ -113,6 +102,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
|
|||
private(set) var sparkleUpdaterManager: SparkleUpdaterManager?
|
||||
var app: VibeTunnelApp?
|
||||
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.
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
struct AppleScriptExecutorTests {
|
||||
|
||||
@Test("Execute simple AppleScript")
|
||||
@MainActor
|
||||
func executeSimpleScript() throws {
|
||||
let script = """
|
||||
return "Hello from AppleScript"
|
||||
"""
|
||||
|
||||
|
||||
let result = try AppleScriptExecutor.shared.executeWithResult(script)
|
||||
#expect(result == "Hello from AppleScript")
|
||||
}
|
||||
|
||||
|
||||
@Test("Execute script with math")
|
||||
@MainActor
|
||||
func executeScriptWithMath() throws {
|
||||
let script = """
|
||||
return 2 + 2
|
||||
"""
|
||||
|
||||
|
||||
let result = try AppleScriptExecutor.shared.executeWithResult(script)
|
||||
#expect(result == "4")
|
||||
}
|
||||
|
||||
|
||||
@Test("Handle script error")
|
||||
@MainActor
|
||||
func handleScriptError() throws {
|
||||
let script = """
|
||||
error "This is a test error"
|
||||
"""
|
||||
|
||||
|
||||
do {
|
||||
_ = try AppleScriptExecutor.shared.executeWithResult(script)
|
||||
Issue.record("Expected error to be thrown")
|
||||
|
|
@ -41,14 +40,14 @@ struct AppleScriptExecutorTests {
|
|||
#expect(error.localizedDescription.contains("test error"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test("Handle invalid syntax")
|
||||
@MainActor
|
||||
func handleInvalidSyntax() throws {
|
||||
let script = """
|
||||
this is not valid applescript syntax
|
||||
"""
|
||||
|
||||
|
||||
do {
|
||||
_ = try AppleScriptExecutor.shared.executeWithResult(script)
|
||||
Issue.record("Expected error to be thrown")
|
||||
|
|
@ -57,12 +56,12 @@ struct AppleScriptExecutorTests {
|
|||
#expect(error is AppleScriptError)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test("Execute empty script")
|
||||
@MainActor
|
||||
func executeEmptyScript() throws {
|
||||
let script = ""
|
||||
|
||||
|
||||
do {
|
||||
let result = try AppleScriptExecutor.shared.executeWithResult(script)
|
||||
#expect(result.isEmpty || result == "missing value")
|
||||
|
|
@ -71,7 +70,7 @@ struct AppleScriptExecutorTests {
|
|||
#expect(error is AppleScriptError)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test("Check Terminal application", .disabled("Slow test - 0.44 seconds"))
|
||||
@MainActor
|
||||
func checkTerminalApplication() throws {
|
||||
|
|
@ -80,19 +79,19 @@ struct AppleScriptExecutorTests {
|
|||
return exists application process "Terminal"
|
||||
end tell
|
||||
"""
|
||||
|
||||
|
||||
let result = try AppleScriptExecutor.shared.executeWithResult(script)
|
||||
// Result will be "true" or "false" as a string
|
||||
#expect(result == "true" || result == "false")
|
||||
}
|
||||
|
||||
|
||||
@Test("Test async execution", .disabled("Slow test - 3.5 seconds"))
|
||||
func testAsyncExecution() async throws {
|
||||
func asyncExecution() async throws {
|
||||
// Test the async method
|
||||
let hasPermission = await AppleScriptExecutor.shared.checkPermission()
|
||||
#expect(hasPermission == true || hasPermission == false)
|
||||
}
|
||||
|
||||
|
||||
@Test("Singleton instance")
|
||||
@MainActor
|
||||
func singletonInstance() {
|
||||
|
|
@ -100,4 +99,4 @@ struct AppleScriptExecutorTests {
|
|||
let instance2 = AppleScriptExecutor.shared
|
||||
#expect(instance1 === instance2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import AppKit
|
||||
import Foundation
|
||||
import Testing
|
||||
import AppKit
|
||||
@testable import VibeTunnel
|
||||
|
||||
@Suite("Dock Icon Manager Tests")
|
||||
struct DockIconManagerTests {
|
||||
|
||||
@Test("Singleton instance")
|
||||
@MainActor
|
||||
func singletonInstance() {
|
||||
|
|
@ -13,21 +12,21 @@ struct DockIconManagerTests {
|
|||
let instance2 = DockIconManager.shared
|
||||
#expect(instance1 === instance2)
|
||||
}
|
||||
|
||||
|
||||
@Test("Update dock visibility based on windows")
|
||||
@MainActor
|
||||
func updateDockVisibilityBasedOnWindows() {
|
||||
let manager = DockIconManager.shared
|
||||
|
||||
|
||||
// Save original preference
|
||||
let originalPref = UserDefaults.standard.bool(forKey: "showInDock")
|
||||
|
||||
|
||||
// Set preference to hide dock
|
||||
UserDefaults.standard.set(false, forKey: "showInDock")
|
||||
|
||||
|
||||
// Update visibility - with no windows, dock should be hidden
|
||||
manager.updateDockVisibility()
|
||||
|
||||
|
||||
// The policy depends on whether there are windows open
|
||||
// In test environment, NSApp might be nil
|
||||
if let app = NSApp {
|
||||
|
|
@ -36,19 +35,19 @@ struct DockIconManagerTests {
|
|||
// In test environment without NSApp, just verify no crash
|
||||
#expect(true)
|
||||
}
|
||||
|
||||
|
||||
// Restore original preference
|
||||
UserDefaults.standard.set(originalPref, forKey: "showInDock")
|
||||
}
|
||||
|
||||
|
||||
@Test("Temporarily show dock")
|
||||
@MainActor
|
||||
func temporarilyShowDock() {
|
||||
let manager = DockIconManager.shared
|
||||
|
||||
|
||||
// Call temporarilyShowDock
|
||||
manager.temporarilyShowDock()
|
||||
|
||||
|
||||
// In CI environment, NSApp might behave differently
|
||||
if let app = NSApp {
|
||||
// Accept either regular or accessory since CI environment differs
|
||||
|
|
@ -58,13 +57,13 @@ struct DockIconManagerTests {
|
|||
#expect(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test("Dock visibility with user preference")
|
||||
@MainActor
|
||||
@MainActor
|
||||
func dockVisibilityWithUserPreference() {
|
||||
let manager = DockIconManager.shared
|
||||
let originalPref = UserDefaults.standard.bool(forKey: "showInDock")
|
||||
|
||||
|
||||
// Test with showInDock = true (user wants dock visible)
|
||||
UserDefaults.standard.set(true, forKey: "showInDock")
|
||||
manager.updateDockVisibility()
|
||||
|
|
@ -75,7 +74,7 @@ struct DockIconManagerTests {
|
|||
// In test environment without NSApp, just verify no crash
|
||||
#expect(true)
|
||||
}
|
||||
|
||||
|
||||
// Test with showInDock = false (user wants dock hidden)
|
||||
UserDefaults.standard.set(false, forKey: "showInDock")
|
||||
manager.updateDockVisibility()
|
||||
|
|
@ -87,7 +86,7 @@ struct DockIconManagerTests {
|
|||
// In test environment without NSApp, just verify no crash
|
||||
#expect(true)
|
||||
}
|
||||
|
||||
|
||||
// Restore
|
||||
UserDefaults.standard.set(originalPref, forKey: "showInDock")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import Testing
|
|||
@Suite("Ngrok Service Tests", .tags(.networking))
|
||||
struct NgrokServiceTests {
|
||||
let testAuthToken = "test_auth_token_123"
|
||||
let testPort = 8888
|
||||
|
||||
let testPort = 8_888
|
||||
|
||||
@Test("Singleton instance")
|
||||
@MainActor
|
||||
func singletonInstance() {
|
||||
|
|
@ -14,7 +14,7 @@ struct NgrokServiceTests {
|
|||
let instance2 = NgrokService.shared
|
||||
#expect(instance1 === instance2)
|
||||
}
|
||||
|
||||
|
||||
@Test("Initial state")
|
||||
@MainActor
|
||||
func initialState() {
|
||||
|
|
@ -23,40 +23,40 @@ struct NgrokServiceTests {
|
|||
#expect(service.publicUrl == nil)
|
||||
#expect(service.tunnelStatus == nil)
|
||||
}
|
||||
|
||||
|
||||
@Test("Auth token management")
|
||||
@MainActor
|
||||
func authTokenManagement() {
|
||||
let service = NgrokService.shared
|
||||
|
||||
|
||||
// Save original token
|
||||
let originalToken = service.authToken
|
||||
|
||||
|
||||
// Set test token
|
||||
service.authToken = testAuthToken
|
||||
#expect(service.authToken == testAuthToken)
|
||||
#expect(service.hasAuthToken == true)
|
||||
|
||||
|
||||
// Clear token
|
||||
service.authToken = nil
|
||||
#expect(service.authToken == nil)
|
||||
#expect(service.hasAuthToken == false)
|
||||
|
||||
|
||||
// Restore original token
|
||||
service.authToken = originalToken
|
||||
}
|
||||
|
||||
|
||||
@Test("Start without auth token fails")
|
||||
@MainActor
|
||||
func startWithoutAuthToken() async throws {
|
||||
let service = NgrokService.shared
|
||||
|
||||
|
||||
// Save original token
|
||||
let originalToken = service.authToken
|
||||
|
||||
|
||||
// Clear token
|
||||
service.authToken = nil
|
||||
|
||||
|
||||
do {
|
||||
_ = try await service.start(port: testPort)
|
||||
Issue.record("Expected error to be thrown")
|
||||
|
|
@ -65,51 +65,51 @@ struct NgrokServiceTests {
|
|||
} catch {
|
||||
Issue.record("Expected NgrokError.authTokenMissing")
|
||||
}
|
||||
|
||||
|
||||
// Restore original token
|
||||
service.authToken = originalToken
|
||||
}
|
||||
|
||||
|
||||
@Test("Stop when not running")
|
||||
@MainActor
|
||||
func stopWhenNotRunning() async throws {
|
||||
let service = NgrokService.shared
|
||||
|
||||
|
||||
// Ensure not running
|
||||
if service.isActive {
|
||||
try await service.stop()
|
||||
}
|
||||
|
||||
|
||||
// Stop again should be safe
|
||||
try await service.stop()
|
||||
|
||||
|
||||
#expect(service.isActive == false)
|
||||
#expect(service.publicUrl == nil)
|
||||
}
|
||||
|
||||
|
||||
@Test("Is running check")
|
||||
@MainActor
|
||||
func isRunningCheck() async {
|
||||
let service = NgrokService.shared
|
||||
|
||||
|
||||
let running = await service.isRunning()
|
||||
#expect(running == service.isActive)
|
||||
}
|
||||
|
||||
|
||||
@Test("Get status when inactive")
|
||||
@MainActor
|
||||
func getStatusWhenInactive() async {
|
||||
let service = NgrokService.shared
|
||||
|
||||
|
||||
// Ensure not running
|
||||
if service.isActive {
|
||||
try? await service.stop()
|
||||
}
|
||||
|
||||
|
||||
let status = await service.getStatus()
|
||||
#expect(status == nil)
|
||||
}
|
||||
|
||||
|
||||
@Test("NgrokError descriptions")
|
||||
func ngrokErrorDescriptions() {
|
||||
let errors: [NgrokError] = [
|
||||
|
|
@ -119,13 +119,13 @@ struct NgrokServiceTests {
|
|||
.invalidConfiguration,
|
||||
.networkError("connection failed")
|
||||
]
|
||||
|
||||
|
||||
for error in errors {
|
||||
#expect(error.errorDescription != nil)
|
||||
#expect(!error.errorDescription!.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test("NgrokError equality")
|
||||
func ngrokErrorEquality() {
|
||||
#expect(NgrokError.notInstalled == NgrokError.notInstalled)
|
||||
|
|
@ -133,4 +133,4 @@ struct NgrokServiceTests {
|
|||
#expect(NgrokError.tunnelCreationFailed("a") == NgrokError.tunnelCreationFailed("a"))
|
||||
#expect(NgrokError.tunnelCreationFailed("a") != NgrokError.tunnelCreationFailed("b"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,129 +1,128 @@
|
|||
import Testing
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import VibeTunnel
|
||||
|
||||
/// Tests for PowerManagementService that work reliably in CI environments
|
||||
@Suite("Power Management Service")
|
||||
@MainActor
|
||||
struct PowerManagementServiceTests {
|
||||
|
||||
// Since PowerManagementService has a private init, we can only test through the shared instance
|
||||
// We need to ensure proper cleanup between tests
|
||||
|
||||
|
||||
@Test("Sleep prevention defaults to true when key doesn't exist")
|
||||
func sleepPreventionDefaultValue() async {
|
||||
// Save current value
|
||||
let currentValue = UserDefaults.standard.object(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
||||
defer {
|
||||
// Restore original value
|
||||
if let currentValue = currentValue {
|
||||
if let currentValue {
|
||||
UserDefaults.standard.set(currentValue, forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Remove the key to simulate first launch
|
||||
UserDefaults.standard.removeObject(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
||||
|
||||
|
||||
// Test our helper method returns true for non-existent key
|
||||
let defaultValue = AppConstants.boolValue(for: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
||||
#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)
|
||||
let standardDefault = UserDefaults.standard.bool(forKey: AppConstants.UserDefaultsKeys.preventSleepWhenRunning)
|
||||
#expect(standardDefault == false, "UserDefaults.standard.bool returns false for non-existent keys")
|
||||
}
|
||||
|
||||
|
||||
@Test("Update sleep prevention logic with all combinations")
|
||||
func updateSleepPreventionLogic() async {
|
||||
let service = PowerManagementService.shared
|
||||
|
||||
|
||||
// Ensure clean state
|
||||
service.allowSleep()
|
||||
|
||||
|
||||
// Test Case 1: Both enabled and server running should prevent sleep
|
||||
service.updateSleepPrevention(enabled: true, serverRunning: true)
|
||||
#expect(service.isSleepPrevented)
|
||||
|
||||
|
||||
// Test Case 2: Disabled setting should allow sleep
|
||||
service.updateSleepPrevention(enabled: false, serverRunning: true)
|
||||
#expect(!service.isSleepPrevented)
|
||||
|
||||
|
||||
// Test Case 3: Server not running should allow sleep
|
||||
service.updateSleepPrevention(enabled: true, serverRunning: false)
|
||||
#expect(!service.isSleepPrevented)
|
||||
|
||||
|
||||
// Test Case 4: Both false should allow sleep
|
||||
service.updateSleepPrevention(enabled: false, serverRunning: false)
|
||||
#expect(!service.isSleepPrevented)
|
||||
|
||||
|
||||
// Cleanup
|
||||
service.allowSleep()
|
||||
}
|
||||
|
||||
|
||||
@Test("Multiple prevent sleep calls are idempotent")
|
||||
func preventSleepIdempotency() async {
|
||||
let service = PowerManagementService.shared
|
||||
|
||||
|
||||
// Ensure clean state
|
||||
service.allowSleep()
|
||||
|
||||
|
||||
// Call preventSleep multiple times
|
||||
service.preventSleep()
|
||||
let firstState = service.isSleepPrevented
|
||||
|
||||
|
||||
service.preventSleep()
|
||||
service.preventSleep()
|
||||
|
||||
|
||||
// State should remain the same
|
||||
#expect(service.isSleepPrevented == firstState)
|
||||
|
||||
|
||||
// Cleanup
|
||||
service.allowSleep()
|
||||
}
|
||||
|
||||
|
||||
@Test("Multiple allow sleep calls are idempotent")
|
||||
func allowSleepIdempotency() async {
|
||||
let service = PowerManagementService.shared
|
||||
|
||||
|
||||
// Set up initial state
|
||||
service.preventSleep()
|
||||
|
||||
|
||||
// Call allowSleep multiple times
|
||||
service.allowSleep()
|
||||
#expect(!service.isSleepPrevented)
|
||||
|
||||
|
||||
service.allowSleep()
|
||||
service.allowSleep()
|
||||
|
||||
|
||||
// State should remain false
|
||||
#expect(!service.isSleepPrevented)
|
||||
}
|
||||
|
||||
|
||||
@Test("State transitions work correctly")
|
||||
func stateTransitions() async {
|
||||
let service = PowerManagementService.shared
|
||||
|
||||
|
||||
// Ensure clean state
|
||||
service.allowSleep()
|
||||
#expect(!service.isSleepPrevented)
|
||||
|
||||
|
||||
// Prevent sleep
|
||||
service.preventSleep()
|
||||
#expect(service.isSleepPrevented)
|
||||
|
||||
|
||||
// Allow sleep again
|
||||
service.allowSleep()
|
||||
#expect(!service.isSleepPrevented)
|
||||
|
||||
|
||||
// Use updateSleepPrevention
|
||||
service.updateSleepPrevention(enabled: true, serverRunning: true)
|
||||
#expect(service.isSleepPrevented)
|
||||
|
||||
|
||||
service.updateSleepPrevention(enabled: false, serverRunning: false)
|
||||
#expect(!service.isSleepPrevented)
|
||||
|
||||
|
||||
// Cleanup
|
||||
service.allowSleep()
|
||||
}
|
||||
|
|
@ -134,33 +133,32 @@ struct PowerManagementServiceTests {
|
|||
@Suite("Power Management Edge Cases")
|
||||
@MainActor
|
||||
struct PowerManagementEdgeCaseTests {
|
||||
|
||||
@Test("Rapid state changes handle correctly")
|
||||
func rapidStateChanges() async {
|
||||
let service = PowerManagementService.shared
|
||||
|
||||
|
||||
// Ensure clean state
|
||||
service.allowSleep()
|
||||
|
||||
|
||||
// Rapidly toggle state
|
||||
for _ in 0..<10 {
|
||||
service.preventSleep()
|
||||
service.allowSleep()
|
||||
}
|
||||
|
||||
|
||||
// Final state should be sleep allowed
|
||||
#expect(!service.isSleepPrevented)
|
||||
|
||||
|
||||
// Now rapidly toggle with updateSleepPrevention
|
||||
for i in 0..<10 {
|
||||
let enabled = i % 2 == 0
|
||||
service.updateSleepPrevention(enabled: enabled, serverRunning: true)
|
||||
}
|
||||
|
||||
|
||||
// Final state should match last call (i=9, odd, so enabled=false)
|
||||
#expect(!service.isSleepPrevented)
|
||||
|
||||
|
||||
// Cleanup
|
||||
service.allowSleep()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ final class ServerManagerTests {
|
|||
await manager.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
|
||||
// Check that lastError indicates the binary wasn't found
|
||||
|
|
@ -54,7 +54,7 @@ final class ServerManagerTests {
|
|||
|
||||
// First attempt to start
|
||||
await manager.start()
|
||||
try await Task.sleep(for: .milliseconds(1000))
|
||||
try await Task.sleep(for: .milliseconds(1_000))
|
||||
|
||||
let firstServer = manager.bunServer
|
||||
let firstError = manager.lastError
|
||||
|
|
@ -67,7 +67,8 @@ final class ServerManagerTests {
|
|||
|
||||
// Error should be consistent
|
||||
if let error1 = firstError as? BunServerError,
|
||||
let error2 = manager.lastError as? BunServerError {
|
||||
let error2 = manager.lastError as? BunServerError
|
||||
{
|
||||
#expect(error1 == error2)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ final class SessionMonitorTests {
|
|||
#expect(session.exitCode == nil)
|
||||
#expect(session.startedAt == "2025-01-01T10:00: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.initialRows == 24)
|
||||
#expect(session.activityStatus?.isActive == true)
|
||||
|
|
@ -177,7 +177,7 @@ final class SessionMonitorTests {
|
|||
#expect(sessions[0].id == "session-1")
|
||||
#expect(sessions[0].command == ["bash"])
|
||||
#expect(sessions[0].isRunning == true)
|
||||
#expect(sessions[0].pid == 1001)
|
||||
#expect(sessions[0].pid == 1_001)
|
||||
|
||||
// Verify second session
|
||||
#expect(sessions[1].id == "session-2")
|
||||
|
|
@ -288,8 +288,10 @@ final class SessionMonitorTests {
|
|||
let data = json.data(using: .utf8)!
|
||||
let session = try JSONDecoder().decode(ServerSessionInfo.self, from: data)
|
||||
|
||||
#expect(session.isRunning == expectedRunning,
|
||||
"Status '\(status)' should result in isRunning=\(expectedRunning)")
|
||||
#expect(
|
||||
session.isRunning == expectedRunning,
|
||||
"Status '\(status)' should result in isRunning=\(expectedRunning)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -430,7 +432,7 @@ final class SessionMonitorTests {
|
|||
let devSession = sessions[1]
|
||||
#expect(devSession.command == ["pnpm", "run", "dev"])
|
||||
#expect(devSession.isRunning == true)
|
||||
#expect(devSession.pid == 34567)
|
||||
#expect(devSession.pid == 34_567)
|
||||
|
||||
// Verify exited session
|
||||
let gitSession = sessions[2]
|
||||
|
|
@ -485,4 +487,4 @@ final class SessionMonitorTests {
|
|||
// Cached access should be very fast
|
||||
#expect(elapsed < 0.1, "Cached access took too long: \(elapsed)s for 100 calls")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
import ServiceManagement
|
||||
import Testing
|
||||
@testable import VibeTunnel
|
||||
|
||||
@Suite("Startup Manager Tests")
|
||||
struct StartupManagerTests {
|
||||
|
||||
@Test("Create instance")
|
||||
@MainActor
|
||||
func createInstance() {
|
||||
|
|
@ -13,54 +12,54 @@ struct StartupManagerTests {
|
|||
// Just verify we can create an instance
|
||||
#expect(manager.isLaunchAtLoginEnabled == true || manager.isLaunchAtLoginEnabled == false)
|
||||
}
|
||||
|
||||
|
||||
@Test("Initial launch at login state")
|
||||
@MainActor
|
||||
func initialLaunchAtLoginState() {
|
||||
let manager = StartupManager()
|
||||
|
||||
|
||||
// The initial state depends on system configuration
|
||||
// We just verify it returns a boolean
|
||||
let state = manager.isLaunchAtLoginEnabled
|
||||
#expect(state == true || state == false)
|
||||
}
|
||||
|
||||
|
||||
@Test("Set launch at login")
|
||||
@MainActor
|
||||
func setLaunchAtLogin() {
|
||||
let manager = StartupManager()
|
||||
|
||||
|
||||
// Try to enable (may fail in test environment)
|
||||
manager.setLaunchAtLogin(enabled: true)
|
||||
|
||||
|
||||
// Try to disable (may fail in test environment)
|
||||
manager.setLaunchAtLogin(enabled: false)
|
||||
|
||||
|
||||
// We can't verify the actual state change in tests
|
||||
// Just ensure the methods don't crash
|
||||
#expect(true)
|
||||
}
|
||||
|
||||
|
||||
@Test("Service management availability")
|
||||
@available(macOS 13.0, *)
|
||||
func serviceManagementAvailability() {
|
||||
// Test that we can at least query the service status
|
||||
let service = SMAppService.mainApp
|
||||
|
||||
|
||||
// Status should be queryable
|
||||
let status = service.status
|
||||
|
||||
|
||||
// We just verify that we can get a status without crashing
|
||||
// The actual value depends on the test environment
|
||||
#expect(status.rawValue >= 0)
|
||||
}
|
||||
|
||||
|
||||
@Test("App bundle identifier")
|
||||
func appBundleIdentifier() {
|
||||
// In test environment, bundle identifier might be nil
|
||||
let bundleId = Bundle.main.bundleIdentifier
|
||||
|
||||
if let bundleId = bundleId {
|
||||
|
||||
if let bundleId {
|
||||
#expect(!bundleId.isEmpty)
|
||||
// In test environment, bundle ID can vary widely
|
||||
// Just verify it's a valid identifier format (contains a dot for reverse domain notation)
|
||||
|
|
@ -70,18 +69,18 @@ struct StartupManagerTests {
|
|||
#expect(bundleId == nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test("Multiple operations")
|
||||
@MainActor
|
||||
func multipleOperations() {
|
||||
let manager = StartupManager()
|
||||
|
||||
|
||||
// Perform multiple operations
|
||||
manager.setLaunchAtLogin(enabled: true)
|
||||
manager.setLaunchAtLogin(enabled: false)
|
||||
manager.setLaunchAtLogin(enabled: true)
|
||||
|
||||
|
||||
// Just ensure no crashes
|
||||
#expect(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ enum TestFixtures {
|
|||
processID: Int32? = nil,
|
||||
isActive: Bool = true
|
||||
)
|
||||
-> TunnelSession {
|
||||
-> TunnelSession
|
||||
{
|
||||
var session = TunnelSession(
|
||||
id: UUID(uuidString: id) ?? UUID(),
|
||||
processID: processID
|
||||
|
|
@ -34,7 +35,8 @@ enum TestFixtures {
|
|||
static func createSessionRequest(
|
||||
clientInfo: TunnelSession.ClientInfo? = nil
|
||||
)
|
||||
-> TunnelSession.CreateRequest {
|
||||
-> TunnelSession.CreateRequest
|
||||
{
|
||||
TunnelSession.CreateRequest(clientInfo: clientInfo ?? defaultClientInfo())
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +44,8 @@ enum TestFixtures {
|
|||
id: String = "00000000-0000-0000-0000-000000000123",
|
||||
session: TunnelSession? = nil
|
||||
)
|
||||
-> TunnelSession.CreateResponse {
|
||||
-> TunnelSession.CreateResponse
|
||||
{
|
||||
TunnelSession.CreateResponse(
|
||||
id: id,
|
||||
session: session ?? createSession(id: id)
|
||||
|
|
@ -57,7 +60,8 @@ enum TestFixtures {
|
|||
environment: [String: String]? = nil,
|
||||
workingDirectory: String? = nil
|
||||
)
|
||||
-> TunnelSession.ExecuteCommandRequest {
|
||||
-> TunnelSession.ExecuteCommandRequest
|
||||
{
|
||||
TunnelSession.ExecuteCommandRequest(
|
||||
sessionId: sessionId,
|
||||
command: command,
|
||||
|
|
@ -71,7 +75,8 @@ enum TestFixtures {
|
|||
stdout: String = "test output",
|
||||
stderr: String = ""
|
||||
)
|
||||
-> TunnelSession.ExecuteCommandResponse {
|
||||
-> TunnelSession.ExecuteCommandResponse
|
||||
{
|
||||
TunnelSession.ExecuteCommandResponse(
|
||||
exitCode: exitCode,
|
||||
stdout: stdout,
|
||||
|
|
@ -85,7 +90,8 @@ enum TestFixtures {
|
|||
error: String = "Test error",
|
||||
code: String? = "TEST_ERROR"
|
||||
)
|
||||
-> TunnelSession.ErrorResponse {
|
||||
-> TunnelSession.ErrorResponse
|
||||
{
|
||||
TunnelSession.ErrorResponse(error: error, code: code)
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +122,8 @@ extension TestFixtures {
|
|||
timeout: TimeInterval = 1.0,
|
||||
interval: TimeInterval = 0.1
|
||||
)
|
||||
async throws {
|
||||
async throws
|
||||
{
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
while Date() < deadline {
|
||||
|
|
|
|||
Loading…
Reference in a new issue