From 3e3bca0fb2977522aefe77b82ca07a22c080aa83 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Jun 2025 01:30:37 +0200 Subject: [PATCH] Add terminal logic --- .../Views/Settings/DebugSettingsView.swift | 57 ++++++- VibeTunnel/Utilities/TerminalLauncher.swift | 152 ++++++++++++++++++ VibeTunnel/VibeTunnelApp.swift | 3 + 3 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 VibeTunnel/Utilities/TerminalLauncher.swift diff --git a/VibeTunnel/Presentation/Views/Settings/DebugSettingsView.swift b/VibeTunnel/Presentation/Views/Settings/DebugSettingsView.swift index aa4573e7..67a61785 100644 --- a/VibeTunnel/Presentation/Views/Settings/DebugSettingsView.swift +++ b/VibeTunnel/Presentation/Views/Settings/DebugSettingsView.swift @@ -65,6 +65,8 @@ struct DebugSettingsView: View { logLevel: $logLevel ) + TerminalPreferenceSection() + DeveloperToolsSection( showPurgeConfirmation: $showPurgeConfirmation, showServerConsole: showServerConsole, @@ -100,7 +102,9 @@ struct DebugSettingsView: View { purgeAllUserDefaults() } } message: { - Text("This will remove all stored preferences and reset the app to its default state. The app will quit after purging.") + Text( + "This will remove all stored preferences and reset the app to its default state. The app will quit after purging." + ) } } } @@ -220,13 +224,13 @@ struct DebugSettingsView: View { } } } - + private func getCurrentServerMode() -> String { // If server is switching, show transitioning state if serverManager.isSwitching { return "Switching..." } - + // Always use the configured mode from settings to ensure immediate UI update return ServerMode(rawValue: serverModeString)?.displayName ?? "None" } @@ -621,4 +625,49 @@ private struct DeveloperToolsSection: View { .font(.headline) } } -} \ No newline at end of file +} + +// MARK: - Terminal Preference Section + +private struct TerminalPreferenceSection: View { + @AppStorage("preferredTerminal") private var preferredTerminal = Terminal.terminal.rawValue + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Preferred Terminal") + Spacer() + Picker("", selection: $preferredTerminal) { + ForEach(Terminal.installed, id: \.rawValue) { terminal in + HStack { + if let icon = terminal.appIcon { + Image(nsImage: icon) + .resizable() + .frame(width: 16, height: 16) + } + Text(terminal.displayName) + } + .tag(terminal.rawValue) + } + } + .pickerStyle(.menu) + .labelsHidden() + } + Text("Select which terminal application to use when creating new sessions") + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text("Terminal Preference") + .font(.headline) + } footer: { + Text( + "VibeTunnel will use this terminal when launching new terminal sessions. Falls back to Terminal if the selected app is not available." + ) + .font(.caption) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } + } +} diff --git a/VibeTunnel/Utilities/TerminalLauncher.swift b/VibeTunnel/Utilities/TerminalLauncher.swift new file mode 100644 index 00000000..d87f1e20 --- /dev/null +++ b/VibeTunnel/Utilities/TerminalLauncher.swift @@ -0,0 +1,152 @@ +import AppKit +import Foundation +import SwiftUI + +enum Terminal: String, CaseIterable { + case terminal = "Terminal" + case iTerm2 = "iTerm2" + case ghostty = "Ghostty" + + var bundleIdentifier: String { + switch self { + case .terminal: + "com.apple.Terminal" + case .iTerm2: + "com.googlecode.iterm2" + case .ghostty: + "com.mitchellh.ghostty" + } + } + + 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] { + Self.allCases.filter(\.isInstalled) + } +} + +enum TerminalLauncherError: LocalizedError { + case terminalNotFound + case appleScriptPermissionDenied + case appleScriptExecutionFailed(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 .appleScriptExecutionFailed(let message): + "Failed to execute AppleScript: \(message)" + } + } +} + +@MainActor +final class TerminalLauncher { + static let shared = TerminalLauncher() + + @AppStorage("preferredTerminal") + private var preferredTerminal = Terminal.terminal.rawValue + + private init() {} + + func launchCommand(_ command: String) throws { + let terminal = Terminal(rawValue: preferredTerminal) ?? .terminal + + // Verify terminal is still installed, fallback to Terminal if not + let actualTerminal = terminal.isInstalled ? terminal : .terminal + + if actualTerminal != terminal { + // Update preference to fallback + preferredTerminal = actualTerminal.rawValue + } + + try launchCommand(command, in: actualTerminal) + } + + private func launchCommand(_ command: String, in terminal: Terminal) throws { + let escapedCommand = command.replacingOccurrences(of: "\"", with: "\\\"") + + let appleScript: String + switch terminal { + case .terminal: + appleScript = """ + tell application "Terminal" + activate + do script "\(escapedCommand)" + end tell + """ + + case .iTerm2: + appleScript = """ + tell application "iTerm" + activate + create window with default profile + tell current session of current window + write text "\(escapedCommand)" + end tell + end tell + """ + + case .ghostty: + // Ghostty doesn't have AppleScript support, so we use open command + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = ["-b", terminal.bundleIdentifier, "--args", "-e", command] + + do { + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + throw TerminalLauncherError.appleScriptExecutionFailed("Failed to launch Ghostty") + } + return + } catch { + throw TerminalLauncherError.appleScriptExecutionFailed(error.localizedDescription) + } + } + + // Execute AppleScript for Terminal and iTerm2 + var error: NSDictionary? + if let scriptObject = NSAppleScript(source: appleScript) { + _ = scriptObject.executeAndReturnError(&error) + + if let error { + let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error" + let errorNumber = error["NSAppleScriptErrorNumber"] as? Int ?? 0 + + // Check for permission errors + if errorNumber == -1_743 { + throw TerminalLauncherError.appleScriptPermissionDenied + } + + throw TerminalLauncherError.appleScriptExecutionFailed(errorMessage) + } + } + } + + func verifyPreferredTerminal() { + let terminal = Terminal(rawValue: preferredTerminal) ?? .terminal + if !terminal.isInstalled { + preferredTerminal = Terminal.terminal.rawValue + } + } +} diff --git a/VibeTunnel/VibeTunnelApp.swift b/VibeTunnel/VibeTunnelApp.swift index 7eb87260..650a6083 100644 --- a/VibeTunnel/VibeTunnelApp.swift +++ b/VibeTunnel/VibeTunnelApp.swift @@ -107,6 +107,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { showWelcomeScreen() } + // Verify preferred terminal is still available + TerminalLauncher.shared.verifyPreferredTerminal() + // Listen for update check requests NotificationCenter.default.addObserver( self,