Add terminal logic

This commit is contained in:
Peter Steinberger 2025-06-17 01:30:37 +02:00
parent f4a4f6b16b
commit 3e3bca0fb2
3 changed files with 208 additions and 4 deletions

View file

@ -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)
}
}
}
}
// 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)
}
}
}

View file

@ -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
}
}
}

View file

@ -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,