mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-07 11:35:53 +00:00
Add terminal logic
This commit is contained in:
parent
f4a4f6b16b
commit
3e3bca0fb2
3 changed files with 208 additions and 4 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
152
VibeTunnel/Utilities/TerminalLauncher.swift
Normal file
152
VibeTunnel/Utilities/TerminalLauncher.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue