vibetunnel/VibeTunnel/Core/Services/TerminalManager.swift
Peter Steinberger 70a8da5235 feat: enhance UI and automatic update handling
- Fix session count display to show on single line in menu bar
- Add conditional compilation to disable automatic updates in DEBUG mode
- Add "Open Dashboard" menu item that opens internal server URL
- Convert Help menu from popover to native macOS submenu style
- Enable automatic update downloads in Sparkle configuration
- Increase Advanced Settings tab height from 400 to 500 pixels
- Add Tailscale recommendation with clickable markdown link
- Fix Sendable protocol conformance issues throughout codebase
- Add ApplicationMover utility for app installation location management

These changes improve the overall user experience by making the UI more
intuitive and ensuring automatic updates work correctly in production
while being disabled during development.
2025-06-16 05:53:08 +02:00

171 lines
5.2 KiB
Swift

import Combine
import Foundation
import Logging
/// Holds pipes for a terminal session
private struct SessionPipes {
let stdin: Pipe
let stdout: Pipe
let stderr: Pipe
}
/// Manages terminal sessions and command execution
actor TerminalManager {
private var sessions: [UUID: TunnelSession] = [:]
private var processes: [UUID: Process] = [:]
private var pipes: [UUID: SessionPipes] = [:]
private let logger = Logger(label: "VibeTunnel.TerminalManager")
/// Create a new terminal session
func createSession(request: CreateSessionRequest) throws -> TunnelSession {
let session = TunnelSession()
sessions[session.id] = session
// Set up process and pipes
let process = Process()
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
// Configure the process
process.executableURL = URL(fileURLWithPath: request.shell ?? "/bin/zsh")
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
if let workingDirectory = request.workingDirectory {
process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory)
}
if let environment = request.environment {
process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new }
}
// Start the process
do {
try process.run()
processes[session.id] = process
pipes[session.id] = SessionPipes(stdin: stdinPipe, stdout: stdoutPipe, stderr: stderrPipe)
logger.info("Created session \(session.id) with process \(process.processIdentifier)")
} catch {
sessions.removeValue(forKey: session.id)
throw error
}
return session
}
/// Execute a command in a session
func executeCommand(sessionId: UUID, command: String) async throws -> (output: String, error: String) {
guard var session = sessions[sessionId],
let process = processes[sessionId],
let sessionPipes = pipes[sessionId],
process.isRunning
else {
throw TunnelError.sessionNotFound
}
// Update session activity
session.updateActivity()
sessions[sessionId] = session
// Send command to stdin
guard let commandData = (command + "\n").data(using: .utf8) else {
throw TunnelError.commandExecutionFailed("Failed to encode command")
}
sessionPipes.stdin.fileHandleForWriting.write(commandData)
// Read output with timeout
let outputData = try await withTimeout(seconds: 5) {
sessionPipes.stdout.fileHandleForReading.availableData
}
let errorData = try await withTimeout(seconds: 0.1) {
sessionPipes.stderr.fileHandleForReading.availableData
}
let output = String(data: outputData, encoding: .utf8) ?? ""
let error = String(data: errorData, encoding: .utf8) ?? ""
return (output, error)
}
/// Get all active sessions
func listSessions() -> [TunnelSession] {
Array(sessions.values)
}
/// Get a specific session
func getSession(id: UUID) -> TunnelSession? {
sessions[id]
}
/// Close a session
func closeSession(id: UUID) {
if let process = processes[id] {
process.terminate()
processes.removeValue(forKey: id)
}
pipes.removeValue(forKey: id)
sessions.removeValue(forKey: id)
logger.info("Closed session \(id)")
}
/// Clean up inactive sessions
func cleanupInactiveSessions(olderThan minutes: Int = 30) {
let cutoffDate = Date().addingTimeInterval(-Double(minutes * 60))
for (id, session) in sessions where session.lastActivity < cutoffDate {
closeSession(id: id)
logger.info("Cleaned up inactive session \(id)")
}
}
/// Helper function for timeout
private func withTimeout<T: Sendable>(
seconds: TimeInterval,
operation: @escaping @Sendable () async throws -> T
)
async throws -> T
{
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await operation()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw TunnelError.timeout
}
guard let result = try await group.next() else {
throw TunnelError.timeout
}
group.cancelAll()
return result
}
}
}
/// Errors that can occur in tunnel operations
enum TunnelError: LocalizedError {
case sessionNotFound
case commandExecutionFailed(String)
case timeout
case invalidRequest
var errorDescription: String? {
switch self {
case .sessionNotFound:
"Session not found"
case .commandExecutionFailed(let message):
"Command execution failed: \(message)"
case .timeout:
"Operation timed out"
case .invalidRequest:
"Invalid request"
}
}
}