mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-17 13:15:53 +00:00
821 lines
32 KiB
Swift
821 lines
32 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import Observation
|
|
import os.log
|
|
import SwiftUI
|
|
|
|
/// Terminal launch result with window/tab information
|
|
struct TerminalLaunchResult {
|
|
let terminal: Terminal
|
|
let tabReference: String?
|
|
let tabID: String?
|
|
let windowID: CGWindowID?
|
|
}
|
|
|
|
/// Terminal launch configuration
|
|
struct TerminalLaunchConfig {
|
|
let command: String
|
|
let workingDirectory: String?
|
|
let terminal: Terminal
|
|
|
|
var fullCommand: String {
|
|
guard let workingDirectory else {
|
|
return command
|
|
}
|
|
let escapedDir = workingDirectory.replacingOccurrences(of: "\"", with: "\\\"")
|
|
return "cd \"\(escapedDir)\" && \(command)"
|
|
}
|
|
|
|
var escapedCommand: String {
|
|
command
|
|
.replacingOccurrences(of: "\\", with: "\\\\")
|
|
.replacingOccurrences(of: "\"", with: "\\\"")
|
|
}
|
|
|
|
var appleScriptEscapedCommand: String {
|
|
fullCommand
|
|
.replacingOccurrences(of: "\\", with: "\\\\")
|
|
.replacingOccurrences(of: "\"", with: "\\\"")
|
|
}
|
|
|
|
var keystrokeEscapedCommand: String {
|
|
// For keystroke commands, we need to escape backslashes and quotes
|
|
// AppleScript keystroke requires double-escaping for quotes
|
|
fullCommand
|
|
.replacingOccurrences(of: "\\", with: "\\\\")
|
|
.replacingOccurrences(of: "\"", with: "\\\"")
|
|
}
|
|
}
|
|
|
|
/// Terminal launch methods
|
|
enum TerminalLaunchMethod {
|
|
case appleScript(script: String)
|
|
case processWithArgs(args: [String])
|
|
case processWithTyping(delaySeconds: Double = 0.5)
|
|
case urlScheme(url: String)
|
|
}
|
|
|
|
/// Supported terminal applications.
|
|
///
|
|
/// Represents terminal emulators that VibeTunnel can launch
|
|
/// with commands, including detection of installed terminals.
|
|
///
|
|
/// Note: Tabby is not included as it shows a startup screen
|
|
/// which makes it difficult to support automated command execution.
|
|
enum Terminal: String, CaseIterable {
|
|
case terminal = "Terminal"
|
|
case iTerm2 = "iTerm2"
|
|
case ghostty = "Ghostty"
|
|
case warp = "Warp"
|
|
case alacritty = "Alacritty"
|
|
case hyper = "Hyper"
|
|
case wezterm = "WezTerm"
|
|
case kitty = "Kitty"
|
|
|
|
var bundleIdentifier: String {
|
|
switch self {
|
|
case .terminal:
|
|
"com.apple.Terminal"
|
|
case .iTerm2:
|
|
"com.googlecode.iterm2"
|
|
case .ghostty:
|
|
"com.mitchellh.ghostty"
|
|
case .warp:
|
|
"dev.warp.Warp-Stable"
|
|
case .alacritty:
|
|
"org.alacritty"
|
|
case .hyper:
|
|
"co.zeit.hyper"
|
|
case .wezterm:
|
|
"com.github.wez.wezterm"
|
|
case .kitty:
|
|
"net.kovidgoyal.kitty"
|
|
}
|
|
}
|
|
|
|
/// Priority for auto-detection (higher is better, based on popularity)
|
|
var detectionPriority: Int {
|
|
switch self {
|
|
case .terminal: 100 // Highest - macOS default, most popular
|
|
case .iTerm2: 95 // Very popular among developers
|
|
case .warp: 85 // Popular modern terminal
|
|
case .ghostty: 80 // New but gaining popularity
|
|
case .kitty: 75 // Fast GPU-based terminal
|
|
case .alacritty: 70 // Popular among power users
|
|
case .wezterm: 60 // Less common but powerful
|
|
case .hyper: 50 // Less popular Electron-based
|
|
}
|
|
}
|
|
|
|
var displayName: String {
|
|
rawValue
|
|
}
|
|
|
|
var isInstalled: Bool {
|
|
if self == .terminal {
|
|
return true // Terminal is always installed
|
|
}
|
|
return NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) != nil
|
|
}
|
|
|
|
var appIcon: NSImage? {
|
|
guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) else {
|
|
return nil
|
|
}
|
|
return NSWorkspace.shared.icon(forFile: appURL.path)
|
|
}
|
|
|
|
static var installed: [Self] {
|
|
allCases.filter(\.isInstalled)
|
|
}
|
|
|
|
/// Generate unified AppleScript for all terminals
|
|
func unifiedAppleScript(for config: TerminalLaunchConfig) -> String {
|
|
// Terminal.app supports 'do script' which handles complex commands better
|
|
if self == .terminal {
|
|
return """
|
|
tell application "Terminal"
|
|
activate
|
|
do script "\(config.appleScriptEscapedCommand)"
|
|
end tell
|
|
"""
|
|
}
|
|
|
|
// For all other terminals, use clipboard approach for reliability
|
|
// This avoids issues with special characters and long commands
|
|
// Note: The command is already copied to clipboard before this script runs
|
|
|
|
// Special handling for iTerm2 to ensure new window (not tab)
|
|
if self == .iTerm2 {
|
|
return """
|
|
tell application "\(processName)"
|
|
activate
|
|
tell application "System Events"
|
|
-- Create new window (Cmd+Shift+N for iTerm2)
|
|
keystroke "n" using {command down, shift down}
|
|
delay 0.5
|
|
-- Paste command from clipboard
|
|
keystroke "v" using {command down}
|
|
delay 0.1
|
|
-- Execute the command
|
|
key code 36
|
|
end tell
|
|
end tell
|
|
"""
|
|
}
|
|
|
|
// Special handling for Warp terminal
|
|
// Warp doesn't recognize standard key codes for Enter (36 or 76)
|
|
// and requires ASCII character 13 (carriage return) to execute commands
|
|
if self == .warp {
|
|
return """
|
|
tell application "\(processName)"
|
|
activate
|
|
tell application "System Events"
|
|
-- Create new window
|
|
keystroke "n" using {command down}
|
|
delay 1.0
|
|
-- Paste command from clipboard
|
|
keystroke "v" using {command down}
|
|
delay 0.5
|
|
-- Warp requires ASCII character 13 instead of key code 36
|
|
keystroke (ASCII character 13)
|
|
end tell
|
|
end tell
|
|
"""
|
|
}
|
|
|
|
// For other terminals, Cmd+N typically creates a new window
|
|
return """
|
|
tell application "\(processName)"
|
|
activate
|
|
tell application "System Events"
|
|
-- Create new window
|
|
keystroke "n" using {command down}
|
|
delay 0.5
|
|
-- Paste command from clipboard
|
|
keystroke "v" using {command down}
|
|
delay 0.1
|
|
-- Execute the command
|
|
key code 36
|
|
end tell
|
|
end tell
|
|
"""
|
|
}
|
|
|
|
/// Determine the launch method for this terminal
|
|
/// The idea is that we optimize this later to use sth faster than AppleScript if available
|
|
func launchMethod(for config: TerminalLaunchConfig) -> TerminalLaunchMethod {
|
|
switch self {
|
|
case .terminal:
|
|
// Use unified AppleScript approach for consistency
|
|
.appleScript(script: unifiedAppleScript(for: config))
|
|
|
|
case .iTerm2:
|
|
// Use unified AppleScript approach for consistency
|
|
.appleScript(script: unifiedAppleScript(for: config))
|
|
|
|
case .ghostty:
|
|
// Use unified AppleScript approach
|
|
.appleScript(script: unifiedAppleScript(for: config))
|
|
|
|
case .alacritty:
|
|
// Use unified AppleScript approach for consistency
|
|
.appleScript(script: unifiedAppleScript(for: config))
|
|
|
|
case .warp:
|
|
// Use unified AppleScript approach
|
|
.appleScript(script: unifiedAppleScript(for: config))
|
|
|
|
case .hyper:
|
|
// Use unified AppleScript approach
|
|
.appleScript(script: unifiedAppleScript(for: config))
|
|
|
|
case .wezterm:
|
|
// Use unified AppleScript approach for consistency
|
|
.appleScript(script: unifiedAppleScript(for: config))
|
|
|
|
case .kitty:
|
|
// Use unified AppleScript approach for consistency
|
|
.appleScript(script: unifiedAppleScript(for: config))
|
|
}
|
|
}
|
|
|
|
/// Process name for AppleScript typing
|
|
var processName: String {
|
|
switch self {
|
|
case .terminal: "Terminal"
|
|
case .iTerm2: "iTerm"
|
|
case .ghostty: "ghostty" // lowercase for System Events
|
|
case .warp: "Warp"
|
|
case .alacritty: "Alacritty"
|
|
case .hyper: "Hyper"
|
|
case .wezterm: "WezTerm"
|
|
case .kitty: "kitty"
|
|
}
|
|
}
|
|
|
|
/// Whether this terminal requires keystroke-based input (needs Accessibility permission)
|
|
var requiresKeystrokeInput: Bool {
|
|
// All terminals now use keystroke-based input
|
|
true
|
|
}
|
|
}
|
|
|
|
/// Errors that can occur when launching terminal commands.
|
|
///
|
|
/// Represents failures during terminal application launch,
|
|
/// including permission issues and missing applications.
|
|
enum TerminalLauncherError: LocalizedError {
|
|
case terminalNotFound
|
|
case appleScriptPermissionDenied
|
|
case accessibilityPermissionDenied
|
|
case appleScriptExecutionFailed(String, errorCode: Int?)
|
|
case processLaunchFailed(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .terminalNotFound:
|
|
"Selected terminal application not found"
|
|
case .appleScriptPermissionDenied:
|
|
"AppleScript permission denied. Please grant permission in System Settings."
|
|
case .accessibilityPermissionDenied:
|
|
"Accessibility permission required to send keystrokes. Please grant permission in System Settings."
|
|
case .appleScriptExecutionFailed(let message, let errorCode):
|
|
if let code = errorCode {
|
|
"AppleScript error \(code): \(message)"
|
|
} else {
|
|
"AppleScript error: \(message)"
|
|
}
|
|
case .processLaunchFailed(let message):
|
|
"Failed to launch process: \(message)"
|
|
}
|
|
}
|
|
|
|
var failureReason: String? {
|
|
switch self {
|
|
case .appleScriptPermissionDenied:
|
|
return "VibeTunnel needs Automation permission to control terminal applications."
|
|
case .accessibilityPermissionDenied:
|
|
return "VibeTunnel needs Accessibility permission to send keystrokes to terminal applications."
|
|
case .appleScriptExecutionFailed(_, let errorCode):
|
|
if let code = errorCode {
|
|
switch code {
|
|
case -1_743:
|
|
return "User permission is required to control other applications."
|
|
case -1_728:
|
|
return "The application is not running or cannot be controlled."
|
|
case -1_708:
|
|
return "The event was not handled by the target application."
|
|
case -25_211:
|
|
return "Accessibility permission is required to send keystrokes."
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
return nil
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Manages launching terminal commands in the user's preferred terminal.
|
|
///
|
|
/// Handles terminal application detection, preference management,
|
|
/// and command execution through AppleScript or direct process launching.
|
|
/// Supports Terminal, iTerm2, and Ghostty with automatic fallback.
|
|
@MainActor
|
|
@Observable
|
|
final class TerminalLauncher {
|
|
static let shared = TerminalLauncher()
|
|
private let logger = Logger(subsystem: "sh.vibetunnel.VibeTunnel", category: "TerminalLauncher")
|
|
|
|
private init() {
|
|
performFirstRunAutoDetection()
|
|
}
|
|
|
|
func launchCommand(_ command: String) throws {
|
|
let terminal = getValidTerminal()
|
|
let config = TerminalLaunchConfig(command: command, workingDirectory: nil, terminal: terminal)
|
|
_ = try launchWithConfig(config)
|
|
}
|
|
|
|
func verifyPreferredTerminal() {
|
|
let currentPreference = UserDefaults.standard.string(forKey: "preferredTerminal") ?? Terminal.terminal.rawValue
|
|
let terminal = Terminal(rawValue: currentPreference) ?? .terminal
|
|
if !terminal.isInstalled {
|
|
UserDefaults.standard.set(Terminal.terminal.rawValue, forKey: "preferredTerminal")
|
|
}
|
|
}
|
|
|
|
// MARK: - Private Methods
|
|
|
|
private func performFirstRunAutoDetection() {
|
|
// Check if terminal preference has already been set
|
|
let hasSetPreference = UserDefaults.standard.object(forKey: "preferredTerminal") != nil
|
|
|
|
if !hasSetPreference {
|
|
logger.info("First run detected, auto-detecting preferred terminal from running processes")
|
|
|
|
if let detectedTerminal = detectRunningTerminals() {
|
|
UserDefaults.standard.set(detectedTerminal.rawValue, forKey: "preferredTerminal")
|
|
logger.info("Auto-detected and set preferred terminal to: \(detectedTerminal.rawValue)")
|
|
} else {
|
|
// No terminals detected in running processes, check installed terminals
|
|
let installedTerminals = Terminal.installed.filter { $0 != .terminal }
|
|
if let bestTerminal = installedTerminals.max(by: { $0.detectionPriority < $1.detectionPriority }) {
|
|
UserDefaults.standard.set(bestTerminal.rawValue, forKey: "preferredTerminal")
|
|
logger
|
|
.info(
|
|
"No running terminals found, set preferred terminal to most popular installed: \(bestTerminal.rawValue)"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func detectRunningTerminals() -> Terminal? {
|
|
// Get all running applications
|
|
let runningApps = NSWorkspace.shared.runningApplications
|
|
|
|
// Find all terminals that are currently running
|
|
var runningTerminals: [Terminal] = []
|
|
|
|
for terminal in Terminal.allCases
|
|
where runningApps.contains(where: { $0.bundleIdentifier == terminal.bundleIdentifier })
|
|
{
|
|
runningTerminals.append(terminal)
|
|
logger.debug("Detected running terminal: \(terminal.rawValue)")
|
|
}
|
|
|
|
// Return the terminal with highest priority
|
|
return runningTerminals.max { $0.detectionPriority < $1.detectionPriority }
|
|
}
|
|
|
|
private func getValidTerminal() -> Terminal {
|
|
// Read the current preference directly from UserDefaults
|
|
// @AppStorage doesn't work properly in non-View contexts
|
|
let currentPreference = UserDefaults.standard.string(forKey: "preferredTerminal") ?? Terminal.terminal.rawValue
|
|
let terminal = Terminal(rawValue: currentPreference) ?? .terminal
|
|
let actualTerminal = terminal.isInstalled ? terminal : .terminal
|
|
|
|
if actualTerminal != terminal {
|
|
// Update preference to fallback
|
|
UserDefaults.standard.set(actualTerminal.rawValue, forKey: "preferredTerminal")
|
|
logger
|
|
.warning(
|
|
"Preferred terminal \(terminal.rawValue) not installed, falling back to \(actualTerminal.rawValue)"
|
|
)
|
|
}
|
|
|
|
return actualTerminal
|
|
}
|
|
|
|
private func launchWithConfig(
|
|
_ config: TerminalLaunchConfig,
|
|
sessionId: String? = nil
|
|
)
|
|
throws -> TerminalLaunchResult
|
|
{
|
|
logger.debug("Launch config - command: \(config.command)")
|
|
logger.debug("Launch config - fullCommand: \(config.fullCommand)")
|
|
logger.debug("Launch config - keystrokeEscapedCommand: \(config.keystrokeEscapedCommand)")
|
|
|
|
let method = config.terminal.launchMethod(for: config)
|
|
var tabReference: String?
|
|
var tabID: String?
|
|
var windowID: CGWindowID?
|
|
|
|
switch method {
|
|
case .appleScript(let script):
|
|
logger.debug("Generated AppleScript:\n\(script)")
|
|
|
|
// For Terminal.app and iTerm2, use enhanced scripts to get tab info
|
|
if let sessionId, config.terminal == .terminal || config.terminal == .iTerm2 {
|
|
let enhancedScript = generateEnhancedScript(for: config, sessionId: sessionId)
|
|
let result = try executeAppleScriptWithResult(enhancedScript)
|
|
|
|
logger.debug("Enhanced script result for \(config.terminal.rawValue): '\(result)'")
|
|
|
|
// Parse the result to extract tab/window info
|
|
if config.terminal == .terminal {
|
|
// Terminal.app returns "windowID|tabID"
|
|
let components = result.split(separator: "|").map(String.init)
|
|
logger.debug("Terminal.app components: \(components)")
|
|
if components.count >= 2 {
|
|
windowID = CGWindowID(components[0])
|
|
tabReference = "tab id \(components[1]) of window id \(components[0])"
|
|
logger.info("Terminal.app window ID: \(windowID ?? 0), tab reference: \(tabReference ?? "")")
|
|
}
|
|
} else if config.terminal == .iTerm2 {
|
|
// iTerm2 returns window ID
|
|
let windowIDString = result.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
// For iTerm2, we store the window ID as tabID for consistency
|
|
tabID = windowIDString
|
|
logger.info("iTerm2 window ID: \(windowIDString)")
|
|
}
|
|
} else {
|
|
// For non-Terminal.app terminals, copy command to clipboard first
|
|
if config.terminal != .terminal {
|
|
copyToClipboard(config.fullCommand)
|
|
}
|
|
try executeAppleScript(script)
|
|
}
|
|
|
|
case .processWithArgs(let args):
|
|
try launchProcess(bundleIdentifier: config.terminal.bundleIdentifier, args: args)
|
|
|
|
case .processWithTyping(let delay):
|
|
try launchProcess(bundleIdentifier: config.terminal.bundleIdentifier, args: [])
|
|
|
|
// Give the terminal time to start
|
|
Thread.sleep(forTimeInterval: delay)
|
|
|
|
// Use the same keystroke pattern as other terminals
|
|
try executeAppleScript(config.terminal.unifiedAppleScript(for: config))
|
|
|
|
case .urlScheme(let url):
|
|
// Open URL schemes using NSWorkspace
|
|
guard let nsUrl = URL(string: url) else {
|
|
throw TerminalLauncherError.processLaunchFailed("Invalid URL: \(url)")
|
|
}
|
|
|
|
if !NSWorkspace.shared.open(nsUrl) {
|
|
// Fallback to using 'open' command if NSWorkspace fails
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
|
|
process.arguments = [url]
|
|
|
|
do {
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
|
|
if process.terminationStatus != 0 {
|
|
throw TerminalLauncherError.processLaunchFailed("Failed to open URL scheme")
|
|
}
|
|
} catch {
|
|
throw TerminalLauncherError.processLaunchFailed(error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
|
|
return TerminalLaunchResult(
|
|
terminal: config.terminal,
|
|
tabReference: tabReference,
|
|
tabID: tabID,
|
|
windowID: windowID
|
|
)
|
|
}
|
|
|
|
private func launchProcess(bundleIdentifier: String, args: [String]) throws {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
|
|
process.arguments = ["-b", bundleIdentifier] + args
|
|
|
|
do {
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
|
|
if process.terminationStatus != 0 {
|
|
throw TerminalLauncherError
|
|
.processLaunchFailed("Process exited with status \(process.terminationStatus)")
|
|
}
|
|
} catch {
|
|
logger.error("Failed to launch terminal: \(error.localizedDescription)")
|
|
throw TerminalLauncherError.processLaunchFailed(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private func copyToClipboard(_ text: String) {
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(text, forType: .string)
|
|
}
|
|
|
|
private func executeAppleScript(_ script: String) throws {
|
|
do {
|
|
// Use a longer timeout (15 seconds) for terminal launch operations
|
|
// as some terminals (like Ghostty) can take longer to start up
|
|
try AppleScriptExecutor.shared.execute(script, timeout: 15.0)
|
|
} catch let error as AppleScriptError {
|
|
// Check if this is a permission error
|
|
if case .executionFailed(_, let errorCode) = error,
|
|
let code = errorCode
|
|
{
|
|
switch code {
|
|
case -25_211, -1_719:
|
|
// These error codes indicate accessibility permission issues
|
|
throw TerminalLauncherError.accessibilityPermissionDenied
|
|
case -2_741:
|
|
// This is a syntax error: "Expected end of line but found identifier"
|
|
// It usually means the AppleScript has unescaped quotes or other syntax issues
|
|
throw TerminalLauncherError.appleScriptExecutionFailed(
|
|
"AppleScript syntax error - likely unescaped quotes in command",
|
|
errorCode: code
|
|
)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
// Convert AppleScriptError to TerminalLauncherError
|
|
throw error.toTerminalLauncherError()
|
|
} catch {
|
|
// Handle any unexpected errors
|
|
throw TerminalLauncherError.appleScriptExecutionFailed(error.localizedDescription, errorCode: nil)
|
|
}
|
|
}
|
|
|
|
private func executeAppleScriptWithResult(_ script: String) throws -> String {
|
|
do {
|
|
// Use a longer timeout (15 seconds) for terminal launch operations
|
|
return try AppleScriptExecutor.shared.executeWithResult(script, timeout: 15.0)
|
|
} catch let error as AppleScriptError {
|
|
// Check if this is a permission error
|
|
if case .executionFailed(_, let errorCode) = error,
|
|
let code = errorCode
|
|
{
|
|
switch code {
|
|
case -25_211, -1_719:
|
|
throw TerminalLauncherError.accessibilityPermissionDenied
|
|
case -2_741:
|
|
throw TerminalLauncherError.appleScriptExecutionFailed(
|
|
"AppleScript syntax error - likely unescaped quotes in command",
|
|
errorCode: code
|
|
)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
throw error.toTerminalLauncherError()
|
|
} catch {
|
|
throw TerminalLauncherError.appleScriptExecutionFailed(error.localizedDescription, errorCode: nil)
|
|
}
|
|
}
|
|
|
|
private func generateEnhancedScript(for config: TerminalLaunchConfig, sessionId: String) -> String {
|
|
switch config.terminal {
|
|
case .terminal:
|
|
// Terminal.app script that returns window and tab info
|
|
"""
|
|
tell application "Terminal"
|
|
activate
|
|
set newTab to do script "\(config.appleScriptEscapedCommand)"
|
|
-- newTab is already a tab reference, get its window's ID
|
|
set tabWindows to windows whose tabs contains newTab
|
|
if (count of tabWindows) > 0 then
|
|
set windowID to id of item 1 of tabWindows
|
|
else
|
|
set windowID to id of front window
|
|
end if
|
|
set tabID to id of newTab
|
|
return (windowID as string) & "|" & (tabID as string)
|
|
end tell
|
|
"""
|
|
|
|
case .iTerm2:
|
|
// iTerm2 script that returns window info
|
|
"""
|
|
tell application "iTerm2"
|
|
activate
|
|
set newWindow to (create window with default profile)
|
|
tell current session of newWindow
|
|
write text "\(config.appleScriptEscapedCommand)"
|
|
end tell
|
|
return id of newWindow
|
|
end tell
|
|
"""
|
|
|
|
default:
|
|
// For other terminals, use the standard script
|
|
config.terminal.unifiedAppleScript(for: config)
|
|
}
|
|
}
|
|
|
|
// MARK: - Terminal Session Launching
|
|
|
|
func launchTerminalSession(workingDirectory: String, command: String, sessionId: String) throws {
|
|
// Expand tilde in working directory path
|
|
let expandedWorkingDir = (workingDirectory as NSString).expandingTildeInPath
|
|
|
|
// Escape the working directory for shell
|
|
let escapedWorkingDir = expandedWorkingDir.replacingOccurrences(of: "\"", with: "\\\"")
|
|
|
|
// Construct the full command for Bun server
|
|
// For Bun server, use fwd to create sessions
|
|
logger.info("Using Bun server session creation via fwd")
|
|
let bunPath = findBunExecutable()
|
|
let bunCommand = buildBunCommand(
|
|
bunPath: bunPath,
|
|
userCommand: command,
|
|
workingDir: escapedWorkingDir,
|
|
sessionId: sessionId
|
|
)
|
|
let fullCommand = "cd \"\(escapedWorkingDir)\" && \(bunCommand) && exit"
|
|
|
|
// Get the preferred terminal or fallback
|
|
let terminal = getValidTerminal()
|
|
|
|
// Launch with configuration - no working directory since we handle it in the command
|
|
let config = TerminalLaunchConfig(
|
|
command: fullCommand,
|
|
workingDirectory: nil,
|
|
terminal: terminal
|
|
)
|
|
|
|
// Launch the terminal and get tab/window info
|
|
let launchResult = try launchWithConfig(config, sessionId: sessionId)
|
|
|
|
// Register the window with WindowTracker
|
|
WindowTracker.shared.registerWindow(
|
|
for: sessionId,
|
|
terminalApp: terminal,
|
|
tabReference: launchResult.tabReference,
|
|
tabID: launchResult.tabID
|
|
)
|
|
}
|
|
|
|
/// Optimized terminal session launching that receives pre-formatted command from Go server
|
|
func launchOptimizedTerminalSession(
|
|
workingDirectory: String,
|
|
command: String,
|
|
sessionId: String,
|
|
vibetunnelPath: String? = nil
|
|
)
|
|
throws
|
|
{
|
|
// Expand tilde in working directory path
|
|
let expandedWorkingDir = (workingDirectory as NSString).expandingTildeInPath
|
|
|
|
// Properly escape the directory path for shell
|
|
let escapedDir = expandedWorkingDir.replacingOccurrences(of: "\\", with: "\\\\")
|
|
.replacingOccurrences(of: "\"", with: "\\\"")
|
|
|
|
// Check which server type is running and use appropriate command
|
|
let fullCommand: String
|
|
// Check if we have a Bun executable (it would be bundled as vibetunnel)
|
|
let bunServerActive = Bundle.main.path(forResource: "vibetunnel", ofType: nil) != nil &&
|
|
!command.contains("TTY_SESSION_ID=") // If command already has session ID, it's from Go server
|
|
if bunServerActive {
|
|
// For Bun server, use fwd command
|
|
logger.info("Using Bun server session creation via fwd")
|
|
|
|
// Find the Bun executable path
|
|
let bunPath = findBunExecutable()
|
|
|
|
// When called from socket, command is already pre-formatted
|
|
if command.contains("TTY_SESSION_ID=") {
|
|
// Command is pre-formatted, extract the actual command part
|
|
// Format: TTY_SESSION_ID="..." vibetunnel <actual_command>
|
|
// We need to find where the actual command starts (after "vibetunnel ")
|
|
if let vibetunnelRange = command.range(of: "vibetunnel ") {
|
|
let actualCommand = String(command[vibetunnelRange.upperBound...])
|
|
let bunCommand = buildBunCommand(
|
|
bunPath: bunPath,
|
|
userCommand: actualCommand,
|
|
workingDir: escapedDir,
|
|
sessionId: sessionId
|
|
)
|
|
fullCommand = "cd \"\(escapedDir)\" && \(bunCommand) && exit"
|
|
} else {
|
|
// Fallback if format is different
|
|
let bunCommand = buildBunCommand(
|
|
bunPath: bunPath,
|
|
userCommand: command,
|
|
workingDir: escapedDir,
|
|
sessionId: sessionId
|
|
)
|
|
fullCommand = "cd \"\(escapedDir)\" && \(bunCommand) && exit"
|
|
}
|
|
} else {
|
|
// Command is just the user command
|
|
let bunCommand = buildBunCommand(
|
|
bunPath: bunPath,
|
|
userCommand: command,
|
|
workingDir: escapedDir,
|
|
sessionId: sessionId
|
|
)
|
|
fullCommand = "cd \"\(escapedDir)\" && \(bunCommand) && exit"
|
|
}
|
|
} else {
|
|
// For Go server, use vibetunnel binary
|
|
logger.info("Using Go server session creation via vibetunnel binary")
|
|
|
|
// Use provided vibetunnel path or find bundled one
|
|
let vibetunnel = vibetunnelPath ?? findVibeTunnelBinary()
|
|
|
|
// When called from Swift server, we need to construct the full command with vibetunnel
|
|
// When called from Go server via socket, command is already pre-formatted
|
|
if command.contains("TTY_SESSION_ID=") {
|
|
// Command is pre-formatted from Go server, add cd and exit
|
|
fullCommand = "cd \"\(escapedDir)\" && \(command) && exit"
|
|
} else {
|
|
// Command is just the user command, need to add vibetunnel
|
|
fullCommand = "cd \"\(escapedDir)\" && TTY_SESSION_ID=\"\(sessionId)\" \(vibetunnel) \(command) && exit"
|
|
}
|
|
}
|
|
|
|
// Get the preferred terminal or fallback
|
|
let terminal = getValidTerminal()
|
|
|
|
// Launch with configuration
|
|
let config = TerminalLaunchConfig(
|
|
command: fullCommand,
|
|
workingDirectory: nil,
|
|
terminal: terminal
|
|
)
|
|
|
|
// Launch the terminal and get tab/window info
|
|
let launchResult = try launchWithConfig(config, sessionId: sessionId)
|
|
|
|
// Register the window with WindowTracker
|
|
WindowTracker.shared.registerWindow(
|
|
for: sessionId,
|
|
terminalApp: terminal,
|
|
tabReference: launchResult.tabReference,
|
|
tabID: launchResult.tabID
|
|
)
|
|
}
|
|
|
|
private func findVibeTunnelBinary() -> String {
|
|
// Look for bundled vibetunnel binary (shipped with the app)
|
|
if let bundledVibetunnel = Bundle.main.path(forResource: "vibetunnel", ofType: nil) {
|
|
logger.info("Using bundled vibetunnel at: \(bundledVibetunnel)")
|
|
return bundledVibetunnel
|
|
}
|
|
|
|
logger.error("No vibetunnel binary found in app bundle, command will fail")
|
|
return "echo 'VibeTunnel: vibetunnel binary not found in app bundle'; false"
|
|
}
|
|
|
|
private func findBunExecutable() -> String {
|
|
// Look for Bun executable in Resources
|
|
if let bundledPath = Bundle.main.path(forResource: "vibetunnel", ofType: nil) {
|
|
if FileManager.default.fileExists(atPath: bundledPath) {
|
|
logger.info("Using Bun executable at: \(bundledPath)")
|
|
return bundledPath
|
|
}
|
|
}
|
|
|
|
logger.error("No Bun executable found in app bundle, command will fail")
|
|
return "echo 'VibeTunnel: Bun executable not found in app bundle'; false"
|
|
}
|
|
|
|
private func buildBunCommand(
|
|
bunPath: String,
|
|
userCommand: String,
|
|
workingDir: String,
|
|
sessionId: String? = nil
|
|
)
|
|
-> String
|
|
{
|
|
// Bun executable has fwd command built-in
|
|
logger.info("Using Bun executable for session creation")
|
|
if let sessionId {
|
|
// Pass the pre-generated session ID to fwd
|
|
return "\"\(bunPath)\" fwd --session-id \(sessionId) \(userCommand)"
|
|
} else {
|
|
return "\"\(bunPath)\" fwd \(userCommand)"
|
|
}
|
|
}
|
|
}
|