mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
- 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.
171 lines
5.2 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|