show welcome dialog on update + lint

This commit is contained in:
Peter Steinberger 2025-06-18 12:55:20 +02:00
parent d9d134ff2b
commit daf455ec9a
17 changed files with 445 additions and 369 deletions

View file

@ -0,0 +1,13 @@
import Foundation
/// Central location for app-wide constants and configuration values
enum AppConstants {
/// Current version of the welcome dialog
/// Increment this when significant changes require re-showing the welcome flow
static let currentWelcomeVersion = 2
/// UserDefaults keys
enum UserDefaultsKeys {
static let welcomeVersion = "welcomeVersion"
}
}

View file

@ -1,6 +1,6 @@
import Foundation
import AppKit
import ApplicationServices
import Foundation
import OSLog
/// Manages Accessibility permissions required for sending keystrokes.
@ -9,40 +9,42 @@ import OSLog
/// required for simulating keyboard input via AppleScript/System Events.
final class AccessibilityPermissionManager {
@MainActor static let shared = AccessibilityPermissionManager()
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "VibeTunnel",
category: "AccessibilityPermissions"
)
private init() {}
/// Checks if we have Accessibility permissions.
func hasPermission() -> Bool {
let permitted = AXIsProcessTrusted()
logger.info("Accessibility permission status: \(permitted)")
return permitted
}
/// Requests Accessibility permissions by triggering the system dialog.
func requestPermission() {
logger.info("Requesting Accessibility permissions")
// Create options dictionary with the prompt key
// Using hardcoded string to avoid concurrency issues with kAXTrustedCheckOptionPrompt
let options: NSDictionary = ["AXTrustedCheckOptionPrompt": true]
let alreadyTrusted = AXIsProcessTrustedWithOptions(options)
if alreadyTrusted {
logger.info("Accessibility permission already granted")
} else {
logger.info("Accessibility permission dialog triggered")
// After a short delay, also open System Settings as a fallback
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
if let url =
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
{
NSWorkspace.shared.open(url)
}
}
}
}
}
}

View file

@ -1,5 +1,5 @@
import Foundation
@preconcurrency import AppKit
import Foundation
import OSLog
/// Sendable wrapper for NSAppleEventDescriptor
@ -18,12 +18,12 @@ final class AppleScriptExecutor {
subsystem: Bundle.main.bundleIdentifier ?? "VibeTunnel",
category: "AppleScriptExecutor"
)
/// Shared instance for app-wide AppleScript execution
static let shared = AppleScriptExecutor()
private init() {}
/// Executes an AppleScript synchronously with proper error handling.
///
/// This method runs on the main thread and is suitable for use in
@ -40,41 +40,41 @@ final class AppleScriptExecutor {
if Thread.isMainThread {
// Add a small delay to avoid crashes from SwiftUI actions
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1))
var error: NSDictionary?
guard let scriptObject = NSAppleScript(source: script) else {
logger.error("Failed to create NSAppleScript object")
throw AppleScriptError.scriptCreationFailed
}
let result = scriptObject.executeAndReturnError(&error)
if let error = error {
if let error {
let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
let errorNumber = error["NSAppleScriptErrorNumber"] as? Int
logger.error("AppleScript execution failed: \(errorMessage) (code: \(errorNumber ?? -1))")
throw AppleScriptError.executionFailed(
message: errorMessage,
errorCode: errorNumber
)
}
logger.debug("AppleScript executed successfully")
return result
} else {
// If on background thread, dispatch to main and wait
var result: Result<NSAppleEventDescriptor?, Error>?
DispatchQueue.main.sync {
do {
result = .success(try execute(script, timeout: timeout))
result = try .success(execute(script, timeout: timeout))
} catch {
result = .failure(error)
}
}
switch result! {
case .success(let value):
return value
@ -83,7 +83,7 @@ final class AppleScriptExecutor {
}
}
}
/// Executes an AppleScript asynchronously.
///
/// This method ensures AppleScript runs on the main thread with proper
@ -95,40 +95,40 @@ final class AppleScriptExecutor {
/// - Returns: The result of the AppleScript execution, if any
func executeAsync(_ script: String, timeout: TimeInterval = 5.0) async throws -> NSAppleEventDescriptor? {
let timeoutDuration = min(timeout, 30.0)
// Use a class with NSLock to ensure thread-safe access
final class ContinuationWrapper: @unchecked Sendable {
private let lock = NSLock()
private var hasResumed = false
private let continuation: CheckedContinuation<SendableDescriptor, Error>
init(continuation: CheckedContinuation<SendableDescriptor, Error>) {
self.continuation = continuation
}
func resume(throwing error: Error) {
lock.lock()
defer { lock.unlock() }
guard !hasResumed else { return }
hasResumed = true
continuation.resume(throwing: error)
}
func resume(returning value: NSAppleEventDescriptor?) {
lock.lock()
defer { lock.unlock() }
guard !hasResumed else { return }
hasResumed = true
continuation.resume(returning: SendableDescriptor(descriptor: value))
}
}
return try await withTaskCancellationHandler {
let sendableResult: SendableDescriptor = try await withCheckedThrowingContinuation { continuation in
let wrapper = ContinuationWrapper(continuation: continuation)
Task { @MainActor in
// Small delay to ensure we're not in a SwiftUI action context
do {
@ -137,20 +137,20 @@ final class AppleScriptExecutor {
wrapper.resume(throwing: error)
return
}
var error: NSDictionary?
guard let scriptObject = NSAppleScript(source: script) else {
logger.error("Failed to create NSAppleScript object")
wrapper.resume(throwing: AppleScriptError.scriptCreationFailed)
return
}
let result = scriptObject.executeAndReturnError(&error)
if let error = error {
if let error {
let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
let errorNumber = error["NSAppleScriptErrorNumber"] as? Int
logger.error("AppleScript execution failed:")
logger.error(" Error code: \(errorNumber ?? -1)")
logger.error(" Error message: \(errorMessage)")
@ -160,7 +160,7 @@ final class AppleScriptExecutor {
if let errorBriefMessage = error["NSAppleScriptErrorBriefMessage"] as? String {
logger.error(" Brief message: \(errorBriefMessage)")
}
wrapper.resume(throwing: AppleScriptError.executionFailed(
message: errorMessage,
errorCode: errorNumber
@ -170,7 +170,7 @@ final class AppleScriptExecutor {
wrapper.resume(returning: result)
}
}
// Set up timeout
Task {
do {
@ -187,7 +187,7 @@ final class AppleScriptExecutor {
// Handle cancellation if needed
}
}
/// Checks if AppleScript permission is granted by executing a simple test script.
///
/// - Returns: true if permission is granted, false otherwise
@ -197,7 +197,7 @@ final class AppleScriptExecutor {
return name of first process whose frontmost is true
end tell
"""
do {
_ = try await executeAsync(testScript)
return true
@ -221,24 +221,24 @@ enum AppleScriptError: LocalizedError {
case executionFailed(message: String, errorCode: Int?)
case permissionDenied
case timeout
var errorDescription: String? {
switch self {
case .scriptCreationFailed:
return "Failed to create AppleScript object"
"Failed to create AppleScript object"
case .executionFailed(let message, let errorCode):
if let code = errorCode {
return "AppleScript error \(code): \(message)"
"AppleScript error \(code): \(message)"
} else {
return "AppleScript error: \(message)"
"AppleScript error: \(message)"
}
case .permissionDenied:
return "AppleScript permission denied. Please grant permission in System Settings."
"AppleScript permission denied. Please grant permission in System Settings."
case .timeout:
return "AppleScript execution timed out"
"AppleScript execution timed out"
}
}
var failureReason: String? {
switch self {
case .permissionDenied:
@ -246,13 +246,13 @@ enum AppleScriptError: LocalizedError {
case .executionFailed(_, let errorCode):
if let code = errorCode {
switch code {
case -1743:
case -1_743:
return "User permission is required to control other applications."
case -1728:
case -1_728:
return "The application is not running or cannot be controlled."
case -1708:
case -1_708:
return "The event was not handled by the target application."
case -2741:
case -2_741:
return "AppleScript syntax error - check for unescaped quotes or invalid identifiers."
default:
return nil
@ -263,25 +263,25 @@ enum AppleScriptError: LocalizedError {
return nil
}
}
/// Checks if this error represents a permission denial
var isPermissionError: Bool {
switch self {
case .permissionDenied:
return true
true
case .executionFailed(_, let errorCode):
return errorCode == -1743
errorCode == -1_743
default:
return false
false
}
}
/// Converts this error to a TerminalLauncherError if appropriate
func toTerminalLauncherError() -> TerminalLauncherError {
if isPermissionError {
return .appleScriptPermissionDenied
}
switch self {
case .executionFailed(let message, let errorCode):
return .appleScriptExecutionFailed(message, errorCode: errorCode)
@ -289,4 +289,4 @@ enum AppleScriptError: LocalizedError {
return .appleScriptExecutionFailed(self.localizedDescription, errorCode: nil)
}
}
}
}

View file

@ -1,5 +1,5 @@
import Foundation
import AppKit
import Foundation
import OSLog
/// Manages AppleScript automation permissions for VibeTunnel.
@ -10,42 +10,42 @@ import OSLog
@MainActor
final class AppleScriptPermissionManager: ObservableObject {
static let shared = AppleScriptPermissionManager()
@Published private(set) var hasPermission = false
@Published private(set) var isChecking = false
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "VibeTunnel",
category: "AppleScriptPermissions"
)
private var monitoringTask: Task<Void, Never>?
private init() {
// Start monitoring immediately
startMonitoring()
}
deinit {
monitoringTask?.cancel()
}
/// Checks if we have AppleScript automation permissions.
func checkPermission() async -> Bool {
isChecking = true
defer { isChecking = false }
let permitted = await AppleScriptExecutor.shared.checkPermission()
hasPermission = permitted
logger.info("AppleScript permission status: \(permitted)")
return permitted
}
/// Requests AppleScript automation permissions by triggering the permission dialog.
func requestPermission() {
logger.info("Requesting AppleScript automation permissions")
// First, execute an AppleScript to trigger the automation permission dialog
// This ensures VibeTunnel appears in the Automation settings
Task {
@ -55,38 +55,38 @@ final class AppleScriptPermissionManager: ObservableObject {
exists
end tell
"""
do {
// Use a longer timeout when triggering Terminal for the first time
_ = try await AppleScriptExecutor.shared.executeAsync(triggerScript, timeout: 15.0)
} catch {
logger.info("Permission dialog triggered (expected error: \(error))")
}
// After a short delay, open System Settings to Privacy & Security > Automation
// This gives the system time to register the permission request
try? await Task.sleep(for: .milliseconds(500))
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") {
NSWorkspace.shared.open(url)
}
}
// Continue monitoring more frequently after request
startMonitoring(interval: 1.0)
}
/// Starts monitoring permission status continuously.
private func startMonitoring(interval: TimeInterval = 2.0) {
monitoringTask?.cancel()
monitoringTask = Task {
while !Task.isCancelled {
_ = await checkPermission()
// Wait before next check
try? await Task.sleep(for: .seconds(interval))
// If we have permission, reduce check frequency
if hasPermission && interval < 10.0 {
startMonitoring(interval: 10.0)
@ -95,4 +95,4 @@ final class AppleScriptPermissionManager: ObservableObject {
}
}
}
}
}

View file

@ -20,30 +20,33 @@ final class DashboardKeychain {
/// Get the dashboard password from keychain
func getPassword() -> String? {
#if DEBUG
// In debug builds, skip keychain access to avoid authorization dialogs
logger.info("Debug mode: Skipping keychain password retrieval. Password will only persist during current app session.")
return nil
#else
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let password = String(data: data, encoding: .utf8)
else {
logger.debug("No password found in keychain")
// In debug builds, skip keychain access to avoid authorization dialogs
logger
.info(
"Debug mode: Skipping keychain password retrieval. Password will only persist during current app session."
)
return nil
}
#else
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true
]
logger.debug("Password retrieved from keychain")
return password
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let password = String(data: data, encoding: .utf8)
else {
logger.debug("No password found in keychain")
return nil
}
logger.debug("Password retrieved from keychain")
return password
#endif
}
@ -98,13 +101,16 @@ final class DashboardKeychain {
let success = status == errSecSuccess
logger.info("Password \(success ? "saved to" : "failed to save to") keychain")
#if DEBUG
if success {
logger.info("Debug mode: Password saved to keychain but will not persist across app restarts. The password will only be available during this session to avoid keychain authorization dialogs during development.")
}
if success {
logger
.info(
"Debug mode: Password saved to keychain but will not persist across app restarts. The password will only be available during this session to avoid keychain authorization dialogs during development."
)
}
#endif
return success
}

View file

@ -165,7 +165,7 @@ final class RustServer: ServerProtocol {
// Build command to run tty-fwd through login shell
// Use bind address from ServerManager to control server accessibility
let bindAddress = ServerManager.shared.bindAddress
var ttyFwdCommand = "\"\(binaryPath)\" --static-path \"\(staticPath)\" --serve \(bindAddress):\(port)"
// Add password flag if password protection is enabled

View file

@ -4,38 +4,38 @@ import os.log
/// Service that listens for terminal spawn requests via Unix domain socket using POSIX APIs
final class TerminalSpawnService: @unchecked Sendable {
static let shared = TerminalSpawnService()
private let logger = Logger(subsystem: "sh.vibetunnel.VibeTunnel", category: "TerminalSpawnService")
private let socketPath = "/tmp/vibetunnel-terminal.sock"
private let lock = NSLock()
private var serverSocket: Int32 = -1
private var listenQueue: DispatchQueue?
private var shouldStop = false
private init() {}
/// Start listening for terminal spawn requests
func start() {
lock.lock()
defer { lock.unlock() }
// Clean up any existing socket
unlink(socketPath)
// Create socket
serverSocket = socket(AF_UNIX, SOCK_STREAM, 0)
guard serverSocket >= 0 else {
logger.error("Failed to create socket: \(String(cString: strerror(errno)))")
return
}
// Set socket options
var reuseAddr: Int32 = 1
setsockopt(serverSocket, SOL_SOCKET, SO_REUSEADDR, &reuseAddr, socklen_t(MemoryLayout<Int32>.size))
// Bind to socket path
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
// Copy socket path to sun_path
socketPath.withCString { pathCString in
withUnsafeMutableBytes(of: &addr.sun_path) { sunPathPtr in
@ -43,20 +43,20 @@ final class TerminalSpawnService: @unchecked Sendable {
strncpy(baseAddress, pathCString, sunPathPtr.count - 1)
}
}
let bindResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
bind(serverSocket, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard bindResult == 0 else {
logger.error("Failed to bind socket: \(String(cString: strerror(errno)))")
close(serverSocket)
serverSocket = -1
return
}
// Listen for connections
guard listen(serverSocket, 5) == 0 else {
logger.error("Failed to listen on socket: \(String(cString: strerror(errno)))")
@ -64,9 +64,9 @@ final class TerminalSpawnService: @unchecked Sendable {
serverSocket = -1
return
}
logger.info("Terminal spawn service listening on \(self.socketPath)")
// Start accepting connections on background queue
shouldStop = false
listenQueue = DispatchQueue(label: "sh.vibetunnel.terminal-spawn", qos: .userInitiated)
@ -74,42 +74,42 @@ final class TerminalSpawnService: @unchecked Sendable {
self?.acceptConnectionsAsync()
}
}
/// Stop the service
func stop() {
lock.lock()
defer { lock.unlock() }
logger.info("Stopping terminal spawn service")
shouldStop = true
if serverSocket >= 0 {
close(serverSocket)
serverSocket = -1
}
unlink(socketPath)
listenQueue = nil
}
private func acceptConnectionsAsync() {
while true {
lock.lock()
let shouldContinue = !shouldStop && serverSocket >= 0
let socket = serverSocket
lock.unlock()
if !shouldContinue {
break
}
var clientAddr = sockaddr_un()
var clientAddrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
let clientSocket = withUnsafeMutablePointer(to: &clientAddr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
accept(socket, sockaddrPtr, &clientAddrLen)
}
}
if clientSocket < 0 {
if errno != EINTR {
lock.lock()
@ -121,50 +121,50 @@ final class TerminalSpawnService: @unchecked Sendable {
}
continue
}
// Handle connection on separate queue
handleConnectionAsync(clientSocket)
}
}
private func handleConnectionAsync(_ clientSocket: Int32) {
defer { close(clientSocket) }
// Read request data
var buffer = [UInt8](repeating: 0, count: 65536)
var buffer = [UInt8](repeating: 0, count: 65_536)
let bytesRead = recv(clientSocket, &buffer, buffer.count, 0)
guard bytesRead > 0 else {
logger.error("Failed to read from client socket")
return
}
let requestData = Data(bytes: buffer, count: bytesRead)
// Parse and handle the request
let responseData = handleRequestSync(requestData)
sendResponse(responseData, to: clientSocket)
}
private func handleRequestSync(_ data: Data) -> Data {
struct SpawnRequest: Codable {
let ttyFwdPath: String? // Optional: if provided, use this path instead of bundled one
let ttyFwdPath: String? // Optional: if provided, use this path instead of bundled one
let workingDir: String
let sessionId: String
let command: String // Already properly formatted command (not array)
let terminal: String? // Optional: preferred terminal (e.g. "ghostty", "terminal")
let command: String // Already properly formatted command (not array)
let terminal: String? // Optional: preferred terminal (e.g. "ghostty", "terminal")
}
struct SpawnResponse: Codable {
let success: Bool
let error: String?
let sessionId: String?
}
do {
let request = try JSONDecoder().decode(SpawnRequest.self, from: data)
logger.info("Received spawn request for session \(request.sessionId)")
// Use DispatchQueue.main.sync to call TerminalLauncher on main thread
var launchError: Error?
DispatchQueue.main.sync {
@ -175,14 +175,14 @@ final class TerminalSpawnService: @unchecked Sendable {
originalTerminal = UserDefaults.standard.string(forKey: "preferredTerminal")
UserDefaults.standard.set(requestedTerminal, forKey: "preferredTerminal")
}
defer {
// Restore original terminal preference if we changed it
if let original = originalTerminal {
UserDefaults.standard.set(original, forKey: "preferredTerminal")
}
}
try TerminalLauncher.shared.launchOptimizedTerminalSession(
workingDirectory: request.workingDir,
command: request.command,
@ -193,24 +193,24 @@ final class TerminalSpawnService: @unchecked Sendable {
launchError = error
}
}
if let error = launchError {
throw error
}
let response = SpawnResponse(success: true, error: nil, sessionId: request.sessionId)
return try JSONEncoder().encode(response)
} catch {
logger.error("Failed to handle spawn request: \(error)")
let response = SpawnResponse(success: false, error: error.localizedDescription, sessionId: nil)
return (try? JSONEncoder().encode(response)) ?? Data()
}
}
private func sendResponse(_ data: Data, to clientSocket: Int32) {
data.withUnsafeBytes { bytes in
_ = send(clientSocket, bytes.baseAddress, data.count, 0)
}
}
}
}

View file

@ -716,7 +716,7 @@ public final class TunnelServer {
let command: [String]
let workingDir: String?
let term: String?
let spawn_terminal: Bool?
let spawnTerminal: Bool?
}
let sessionRequest = try JSONDecoder().decode(CreateSessionRequest.self, from: requestData)
@ -724,26 +724,32 @@ public final class TunnelServer {
if sessionRequest.command.isEmpty {
return errorResponse(message: "Command array is required and cannot be empty", status: .badRequest)
}
// Handle terminal spawning if requested
if sessionRequest.spawn_terminal ?? false {
if sessionRequest.spawnTerminal ?? false {
logger.info("Spawn terminal requested for command: \(sessionRequest.command.joined(separator: " "))")
let sessionId = UUID().uuidString
let workingDir = resolvePath(sessionRequest.workingDir ?? "~/", fallback: FileManager.default.homeDirectoryForCurrentUser.path)
let workingDir = resolvePath(
sessionRequest.workingDir ?? "~/",
fallback: FileManager.default.homeDirectoryForCurrentUser.path
)
_ = sessionRequest.command.joined(separator: " ")
// Connect to the terminal spawn service via Unix socket
let socketPath = "/tmp/vibetunnel-terminal.sock"
let socketFd = socket(AF_UNIX, SOCK_STREAM, 0)
guard socketFd >= 0 else {
logger.error("Failed to create socket")
return errorResponse(message: "Failed to create socket for terminal spawning", status: .internalServerError)
return errorResponse(
message: "Failed to create socket for terminal spawning",
status: .internalServerError
)
}
defer { close(socketFd) }
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
socketPath.withCString { ptr in
@ -751,74 +757,87 @@ public final class TunnelServer {
_ = strcpy(dst, ptr)
}
}
let connectResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
connect(socketFd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard connectResult == 0 else {
logger.error("Failed to connect to terminal spawn service: \(String(cString: strerror(errno)))")
return errorResponse(message: "Terminal spawn service not available", status: .serviceUnavailable)
}
// Send the spawn request
struct SpawnRequest: Codable {
let command: [String]
let workingDir: String
let sessionId: String
}
let spawnRequest = SpawnRequest(
command: sessionRequest.command,
workingDir: workingDir,
sessionId: sessionId
)
do {
let requestData = try JSONEncoder().encode(spawnRequest)
let sendResult = send(socketFd, requestData.withUnsafeBytes { $0.baseAddress }, requestData.count, 0)
let sendResult = send(
socketFd,
requestData.withUnsafeBytes { $0.baseAddress },
requestData.count,
0
)
guard sendResult == requestData.count else {
throw NSError(domain: "TerminalSpawn", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to send complete request"])
throw NSError(
domain: "TerminalSpawn",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Failed to send complete request"]
)
}
// Read the response
var responseBuffer = [UInt8](repeating: 0, count: 4096)
var responseBuffer = [UInt8](repeating: 0, count: 4_096)
let bytesRead = recv(socketFd, &responseBuffer, responseBuffer.count, 0)
guard bytesRead > 0 else {
throw NSError(domain: "TerminalSpawn", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to read response"])
throw NSError(
domain: "TerminalSpawn",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Failed to read response"]
)
}
let responseData = Data(bytes: responseBuffer, count: bytesRead)
struct SpawnResponse: Codable {
let success: Bool
let error: String?
let sessionId: String?
}
let spawnResponse = try JSONDecoder().decode(SpawnResponse.self, from: responseData)
if spawnResponse.success {
logger.info("Terminal spawned successfully with session ID: \(sessionId)")
struct CreateSessionResponse: Encodable {
let sessionId: String
let message: String
}
let response = CreateSessionResponse(
sessionId: sessionId,
message: "Terminal spawned successfully"
)
let data = try JSONEncoder().encode(response)
var buffer = ByteBuffer()
buffer.writeData(data)
return Response(
status: .ok,
headers: [.contentType: "application/json"],
@ -827,11 +846,17 @@ public final class TunnelServer {
} else {
let errorMsg = spawnResponse.error ?? "Unknown error"
logger.error("Failed to spawn terminal: \(errorMsg)")
return errorResponse(message: "Failed to spawn terminal: \(errorMsg)", status: .internalServerError)
return errorResponse(
message: "Failed to spawn terminal: \(errorMsg)",
status: .internalServerError
)
}
} catch {
logger.error("Failed to communicate with terminal spawn service: \(error)")
return errorResponse(message: "Failed to spawn terminal: \(error.localizedDescription)", status: .internalServerError)
return errorResponse(
message: "Failed to spawn terminal: \(error.localizedDescription)",
status: .internalServerError
)
}
}

View file

@ -60,7 +60,7 @@ struct AdvancedSettingsView: View {
Text("Integration")
.font(.headline)
}
// Terminal preference section
TerminalPreferenceSection()
@ -72,7 +72,7 @@ struct AdvancedSettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
// Show in Dock
VStack(alignment: .leading, spacing: 4) {
Toggle("Show in Dock", isOn: showInDockBinding)
@ -85,7 +85,7 @@ struct AdvancedSettingsView: View {
.foregroundStyle(.secondary)
}
}
// Debug mode toggle
VStack(alignment: .leading, spacing: 4) {
Toggle("Debug mode", isOn: $debugMode)
@ -106,7 +106,7 @@ struct AdvancedSettingsView: View {
cliInstaller.checkInstallationStatus()
}
}
private var showInDockBinding: Binding<Bool> {
Binding(
get: { showInDock },
@ -153,7 +153,7 @@ private struct TerminalPreferenceSection: View {
Text("Select which terminal application to use when creating new sessions")
.font(.caption)
.foregroundStyle(.secondary)
// Test button
HStack {
Text("Test Terminal")
@ -165,34 +165,41 @@ private struct TerminalPreferenceSection: View {
} catch {
// Log the error
print("Failed to launch terminal test: \(error)")
// Set up alert content based on error type
if let terminalError = error as? TerminalLauncherError {
switch terminalError {
case .appleScriptPermissionDenied:
errorTitle = "Permission Denied"
errorMessage = "VibeTunnel needs permission to control terminal applications.\n\nPlease grant Automation permission in System Settings > Privacy & Security > Automation."
errorMessage =
"VibeTunnel needs permission to control terminal applications.\n\nPlease grant Automation permission in System Settings > Privacy & Security > Automation."
case .accessibilityPermissionDenied:
errorTitle = "Accessibility Permission Required"
errorMessage = "VibeTunnel needs Accessibility permission to send keystrokes to \(Terminal(rawValue: preferredTerminal)?.displayName ?? "terminal").\n\nPlease grant permission in System Settings > Privacy & Security > Accessibility."
errorMessage =
"VibeTunnel needs Accessibility permission to send keystrokes to \(Terminal(rawValue: preferredTerminal)?.displayName ?? "terminal").\n\nPlease grant permission in System Settings > Privacy & Security > Accessibility."
case .terminalNotFound:
errorTitle = "Terminal Not Found"
errorMessage = "The selected terminal application could not be found. Please select a different terminal."
errorMessage =
"The selected terminal application could not be found. Please select a different terminal."
case .appleScriptExecutionFailed(let details, let errorCode):
if let code = errorCode {
switch code {
case -1_743:
errorTitle = "Permission Denied"
errorMessage = "VibeTunnel needs permission to control terminal applications.\n\nPlease grant Automation permission in System Settings > Privacy & Security > Automation."
errorMessage =
"VibeTunnel needs permission to control terminal applications.\n\nPlease grant Automation permission in System Settings > Privacy & Security > Automation."
case -1_728:
errorTitle = "Terminal Not Available"
errorMessage = "The terminal application is not running or cannot be controlled.\n\nDetails: \(details)"
errorMessage =
"The terminal application is not running or cannot be controlled.\n\nDetails: \(details)"
case -1_708:
errorTitle = "Terminal Communication Error"
errorMessage = "The terminal did not respond to the command.\n\nDetails: \(details)"
case -25211:
errorMessage =
"The terminal did not respond to the command.\n\nDetails: \(details)"
case -25_211:
errorTitle = "Accessibility Permission Required"
errorMessage = "System Events requires Accessibility permission to send keystrokes.\n\nPlease grant permission in System Settings > Privacy & Security > Accessibility."
errorMessage =
"System Events requires Accessibility permission to send keystrokes.\n\nPlease grant permission in System Settings > Privacy & Security > Accessibility."
default:
errorTitle = "Terminal Launch Failed"
errorMessage = "AppleScript error \(code): \(details)"
@ -209,7 +216,7 @@ private struct TerminalPreferenceSection: View {
errorTitle = "Terminal Launch Failed"
errorMessage = error.localizedDescription
}
showingError = true
}
}
@ -232,10 +239,12 @@ private struct TerminalPreferenceSection: View {
.multilineTextAlignment(.center)
}
.alert(errorTitle, isPresented: $showingError) {
Button("OK") { }
Button("OK") {}
if errorTitle == "Permission Denied" {
Button("Open System Settings") {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") {
if let url =
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation")
{
NSWorkspace.shared.open(url)
}
}

View file

@ -20,7 +20,7 @@ struct DashboardSettingsView: View {
@State private var showPasswordFields = false
@State private var passwordError: String?
@State private var passwordSaved = false
@StateObject private var permissionManager = AppleScriptPermissionManager.shared
@State private var ngrokAuthToken = ""
@ -822,4 +822,3 @@ private struct NgrokErrorView: View {
}
}
}

View file

@ -323,9 +323,9 @@ private struct ServerSection: View {
.font(.caption)
.foregroundStyle(.red)
}
Divider()
// Server Mode Configuration
HStack {
Text("Server Mode")
@ -369,10 +369,12 @@ private struct ServerSection: View {
Text("HTTP Server")
.font(.headline)
} footer: {
Text("The HTTP server provides REST API endpoints for terminal session management. Choose between the built-in Swift Hummingbird server or the Rust tty-fwd binary.")
.font(.caption)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
Text(
"The HTTP server provides REST API endpoints for terminal session management. Choose between the built-in Swift Hummingbird server or the Rust tty-fwd binary."
)
.font(.caption)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
}
}
}
@ -609,4 +611,3 @@ private struct DeveloperToolsSection: View {
}
}
}

View file

@ -1,5 +1,5 @@
import SwiftUI
import Combine
import SwiftUI
/// General settings tab for basic app preferences
struct GeneralSettingsView: View {
@ -130,13 +130,13 @@ struct GeneralSettingsView: View {
private struct PermissionsSection: View {
@StateObject private var appleScriptManager = AppleScriptPermissionManager.shared
@State private var accessibilityUpdateTrigger = 0
private var hasAccessibilityPermission: Bool {
// This will cause a re-read whenever accessibilityUpdateTrigger changes
_ = accessibilityUpdateTrigger
return AccessibilityPermissionManager.shared.hasPermission()
}
var body: some View {
Section {
VStack(alignment: .leading, spacing: 16) {
@ -149,9 +149,9 @@ private struct PermissionsSection: View {
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if appleScriptManager.hasPermission {
HStack {
Image(systemName: "checkmark.circle.fill")
@ -171,9 +171,9 @@ private struct PermissionsSection: View {
.controlSize(.small)
}
}
Divider()
// Accessibility permission
HStack {
VStack(alignment: .leading, spacing: 4) {
@ -183,9 +183,9 @@ private struct PermissionsSection: View {
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if hasAccessibilityPermission {
HStack {
Image(systemName: "checkmark.circle.fill")
@ -210,10 +210,12 @@ private struct PermissionsSection: View {
Text("Permissions")
.font(.headline)
} footer: {
Text("Terminal automation is required for all terminals. Accessibility is only needed for terminals that simulate keyboard input.")
.font(.caption)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
Text(
"Terminal automation is required for all terminals. Accessibility is only needed for terminals that simulate keyboard input."
)
.font(.caption)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
}
.task {
_ = await appleScriptManager.checkPermission()

View file

@ -3,15 +3,15 @@ import SwiftUI
/// Welcome onboarding view for first-time users.
///
/// Presents a multi-page onboarding experience that introduces VibeTunnel's features,
/// guides through CLI installation, requests AppleScript permissions, and explains
/// dashboard security best practices. The view tracks completion state to ensure
/// guides through CLI installation, requests AppleScript permissions, and explains
/// dashboard security best practices. The view tracks completion state to ensure
/// it's only shown once.
struct WelcomeView: View {
@State private var currentPage = 0
@Environment(\.dismiss)
private var dismiss
@AppStorage("hasSeenWelcome")
private var hasSeenWelcome = false
@AppStorage(AppConstants.UserDefaultsKeys.welcomeVersion)
private var welcomeVersion = 0
@State private var cliInstaller = CLIInstaller()
@StateObject private var permissionManager = AppleScriptPermissionManager.shared
@ -107,7 +107,7 @@ struct WelcomeView: View {
}
} else {
// Finish action - open Settings
hasSeenWelcome = true
welcomeVersion = AppConstants.currentWelcomeVersion
dismiss()
SettingsOpener.openSettings()
}
@ -246,7 +246,7 @@ private struct VTCommandPageView: View {
private struct RequestPermissionsPageView: View {
@StateObject private var appleScriptManager = AppleScriptPermissionManager.shared
@State private var hasAccessibilityPermission = false
var body: some View {
VStack(spacing: 30) {
// App icon
@ -254,12 +254,12 @@ private struct RequestPermissionsPageView: View {
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 16) {
Text("Request Permissions")
.font(.largeTitle)
.fontWeight(.semibold)
Text(
"VibeTunnel needs AppleScript automation to launch and manage terminal sessions\nand accessibility to send commands to certain terminals."
)
@ -268,7 +268,7 @@ private struct RequestPermissionsPageView: View {
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
// Permissions buttons
VStack(spacing: 16) {
// Automation permission
@ -290,7 +290,7 @@ private struct RequestPermissionsPageView: View {
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
// Accessibility permission
if hasAccessibilityPermission {
HStack {

View file

@ -1,8 +1,8 @@
import AppKit
import Foundation
import Observation
import SwiftUI
import os.log
import SwiftUI
/// Service responsible for creating symlinks to command line tools with sudo authentication.
///
@ -38,12 +38,12 @@ final class CLIInstaller {
func checkInstallationStatus() {
let targetPath = "/usr/local/bin/vt"
let installed = FileManager.default.fileExists(atPath: targetPath)
// Animate the state change for smooth UI transitions
withAnimation(.easeInOut(duration: 0.3)) {
isInstalled = installed
}
logger.info("CLIInstaller: CLI tool installed: \(self.isInstalled)")
}

View file

@ -19,23 +19,23 @@ enum SettingsOpener {
static func openSettings() {
// Store the current dock visibility preference
let showInDock = UserDefaults.standard.bool(forKey: "showInDock")
// Temporarily show dock icon to ensure settings window can be brought to front
if !showInDock {
NSApp.setActivationPolicy(.regular)
}
// Simple activation and window opening
Task { @MainActor in
// Small delay to ensure dock icon is visible
try? await Task.sleep(for: .milliseconds(50))
// Activate the app
NSApp.activate(ignoringOtherApps: true)
// Always use notification approach since we have dock icon visible
NotificationCenter.default.post(name: .openSettingsRequest, object: nil)
// we center twice to reduce jump but also be more resilient against slow systems
try? await Task.sleep(for: .milliseconds(20))
if let settingsWindow = findSettingsWindow() {
@ -45,40 +45,40 @@ enum SettingsOpener {
// Wait for window to appear
try? await Task.sleep(for: .milliseconds(200))
// Find and bring settings window to front
if let settingsWindow = findSettingsWindow() {
// Center the window
WindowCenteringHelper.centerOnActiveScreen(settingsWindow)
// Ensure window is visible and in front
settingsWindow.makeKeyAndOrderFront(nil)
settingsWindow.orderFrontRegardless()
// Temporarily raise window level to ensure it's on top
settingsWindow.level = .floating
// Reset level after a short delay
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(100))
settingsWindow.level = .normal
}
}
// Set up observer to apply dock visibility preference when settings window closes
setupDockVisibilityRestoration()
}
}
// MARK: - Dock Visibility Restoration
private static func setupDockVisibilityRestoration() {
// Remove any existing observer
if let observer = windowObserver {
NotificationCenter.default.removeObserver(observer)
windowObserver = nil
}
// Set up observer for window closing
windowObserver = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
@ -86,16 +86,18 @@ enum SettingsOpener {
queue: .main
) { [weak windowObserver] notification in
guard let window = notification.object as? NSWindow else { return }
Task { @MainActor in
guard window.title.contains("Settings") || window.identifier?.rawValue.contains(settingsWindowIdentifier) == true else {
guard window.title.contains("Settings") || window.identifier?.rawValue
.contains(settingsWindowIdentifier) == true
else {
return
}
// Window is closing, apply the current dock visibility preference
let showInDock = UserDefaults.standard.bool(forKey: "showInDock")
NSApp.setActivationPolicy(showInDock ? .regular : .accessory)
// Clean up observer
if let observer = windowObserver {
NotificationCenter.default.removeObserver(observer)
@ -104,39 +106,40 @@ enum SettingsOpener {
}
}
}
/// Finds the settings window using multiple detection methods
static func findSettingsWindow() -> NSWindow? {
// Try multiple methods to find the window
return NSApp.windows.first { window in
NSApp.windows.first { window in
// Check by identifier
if window.identifier?.rawValue == settingsWindowIdentifier {
return true
}
// Check by title
if window.isVisible && window.styleMask.contains(.titled) &&
(window.title.localizedCaseInsensitiveContains("settings") ||
window.title.localizedCaseInsensitiveContains("preferences")) {
(window.title.localizedCaseInsensitiveContains("settings") ||
window.title.localizedCaseInsensitiveContains("preferences")
)
{
return true
}
// Check by content view controller type
if let contentVC = window.contentViewController,
String(describing: type(of: contentVC)).contains("Settings") {
String(describing: type(of: contentVC)).contains("Settings")
{
return true
}
return false
}
}
/// Opens the Settings window and navigates to a specific tab
static func openSettingsTab(_ tab: SettingsTab) {
openSettings()
Task {
// Then switch to the specific tab
NotificationCenter.default.post(
@ -153,7 +156,7 @@ enum SettingsOpener {
/// This is a workaround for FB10184971.
struct HiddenWindowView: View {
@Environment(\.openSettings) private var openSettings
var body: some View {
Color.clear
.frame(width: 1, height: 1)
@ -168,4 +171,3 @@ struct HiddenWindowView: View {
extension Notification.Name {
static let openSettingsRequest = Notification.Name("openSettingsRequest")
}

View file

@ -1,35 +1,35 @@
import AppKit
import Foundation
import SwiftUI
import os.log
import SwiftUI
/// Terminal launch configuration
struct TerminalLaunchConfig {
let command: String
let workingDirectory: String?
let terminal: Terminal
var fullCommand: String {
guard let workingDirectory = workingDirectory else {
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: "\\\"")
.replacingOccurrences(of: "'", with: "'\\''")
}
var keystrokeEscapedCommand: String {
// For keystroke commands, we need to escape backslashes and quotes
// AppleScript keystroke requires double-escaping for quotes
@ -81,17 +81,17 @@ enum Terminal: String, CaseIterable {
"com.github.wez.wezterm"
}
}
/// Priority for auto-detection (higher is better, based on popularity)
var detectionPriority: Int {
switch self {
case .terminal: return 100 // Highest - macOS default, most popular
case .iTerm2: return 95 // Very popular among developers
case .warp: return 85 // Popular modern terminal
case .ghostty: return 80 // New but gaining popularity
case .alacritty: return 70 // Popular among power users
case .wezterm: return 60 // Less common but powerful
case .hyper: return 50 // Less popular Electron-based
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 .alacritty: 70 // Popular among power users
case .wezterm: 60 // Less common but powerful
case .hyper: 50 // Less popular Electron-based
}
}
@ -116,7 +116,7 @@ enum Terminal: String, CaseIterable {
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
@ -128,7 +128,7 @@ enum Terminal: String, CaseIterable {
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
@ -148,58 +148,58 @@ enum Terminal: String, CaseIterable {
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
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
case .iTerm2:
// Use unified AppleScript approach for consistency
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
case .ghostty:
// Use unified AppleScript approach
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
case .alacritty:
// Use unified AppleScript approach for consistency
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
case .warp:
// Use unified AppleScript approach
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
case .hyper:
// Use unified AppleScript approach
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
case .wezterm:
// Use unified AppleScript approach for consistency
return .appleScript(script: unifiedAppleScript(for: config))
.appleScript(script: unifiedAppleScript(for: config))
}
}
/// Process name for AppleScript typing
var processName: String {
switch self {
case .terminal: return "Terminal"
case .iTerm2: return "iTerm"
case .ghostty: return "Ghostty"
case .warp: return "Warp"
case .alacritty: return "Alacritty"
case .hyper: return "Hyper"
case .wezterm: return "WezTerm"
case .terminal: "Terminal"
case .iTerm2: "iTerm"
case .ghostty: "Ghostty"
case .warp: "Warp"
case .alacritty: "Alacritty"
case .hyper: "Hyper"
case .wezterm: "WezTerm"
}
}
/// Whether this terminal requires keystroke-based input (needs Accessibility permission)
var requiresKeystrokeInput: Bool {
// All terminals now use keystroke-based input
return true
true
}
}
@ -217,22 +217,22 @@ enum TerminalLauncherError: LocalizedError {
var errorDescription: String? {
switch self {
case .terminalNotFound:
return "Selected terminal application not found"
"Selected terminal application not found"
case .appleScriptPermissionDenied:
return "AppleScript permission denied. Please grant permission in System Settings."
"AppleScript permission denied. Please grant permission in System Settings."
case .accessibilityPermissionDenied:
return "Accessibility permission required to send keystrokes. Please grant permission in System Settings."
"Accessibility permission required to send keystrokes. Please grant permission in System Settings."
case .appleScriptExecutionFailed(let message, let errorCode):
if let code = errorCode {
return "AppleScript error \(code): \(message)"
"AppleScript error \(code): \(message)"
} else {
return "AppleScript error: \(message)"
"AppleScript error: \(message)"
}
case .processLaunchFailed(let message):
return "Failed to launch process: \(message)"
"Failed to launch process: \(message)"
}
}
var failureReason: String? {
switch self {
case .appleScriptPermissionDenied:
@ -248,7 +248,7 @@ enum TerminalLauncherError: LocalizedError {
return "The application is not running or cannot be controlled."
case -1_708:
return "The event was not handled by the target application."
case -25211:
case -25_211:
return "Accessibility permission is required to send keystrokes."
default:
return nil
@ -269,7 +269,7 @@ enum TerminalLauncherError: LocalizedError {
@MainActor
final class TerminalLauncher {
static let shared = TerminalLauncher()
private let logger = Logger(subsystem: "sh.vibetunnel.VibeTunnel", category: "TerminalLauncher")
@AppStorage("preferredTerminal")
@ -280,14 +280,12 @@ final class TerminalLauncher {
performFirstRunAutoDetection()
logger.info("TerminalLauncher initialized successfully")
}
func launchCommand(_ command: String) throws {
let terminal = getValidTerminal()
let config = TerminalLaunchConfig(command: command, workingDirectory: nil, terminal: terminal)
try launchWithConfig(config)
}
func verifyPreferredTerminal() {
let terminal = Terminal(rawValue: preferredTerminal) ?? .terminal
@ -295,16 +293,16 @@ final class TerminalLauncher {
preferredTerminal = Terminal.terminal.rawValue
}
}
// 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() {
preferredTerminal = detectedTerminal.rawValue
logger.info("Auto-detected and set preferred terminal to: \(detectedTerminal.rawValue)")
@ -313,50 +311,56 @@ final class TerminalLauncher {
let installedTerminals = Terminal.installed.filter { $0 != .terminal }
if let bestTerminal = installedTerminals.max(by: { $0.detectionPriority < $1.detectionPriority }) {
preferredTerminal = bestTerminal.rawValue
logger.info("No running terminals found, set preferred terminal to most popular installed: \(bestTerminal.rawValue)")
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 {
if 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(by: { $0.detectionPriority < $1.detectionPriority })
}
private func getValidTerminal() -> Terminal {
let terminal = Terminal(rawValue: preferredTerminal) ?? .terminal
let actualTerminal = terminal.isInstalled ? terminal : .terminal
if actualTerminal != terminal {
// Update preference to fallback
preferredTerminal = actualTerminal.rawValue
logger.warning("Preferred terminal \(terminal.rawValue) not installed, falling back to \(actualTerminal.rawValue)")
logger
.warning(
"Preferred terminal \(terminal.rawValue) not installed, falling back to \(actualTerminal.rawValue)"
)
}
return actualTerminal
}
private func launchWithConfig(_ config: TerminalLaunchConfig) throws {
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)
switch method {
case .appleScript(let script):
logger.debug("Generated AppleScript:\n\(script)")
@ -365,35 +369,35 @@ final class TerminalLauncher {
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")
}
@ -403,31 +407,32 @@ final class TerminalLauncher {
}
}
}
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)")
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
@ -436,15 +441,19 @@ final class TerminalLauncher {
} catch let error as AppleScriptError {
// Check if this is a permission error
if case .executionFailed(_, let errorCode) = error,
let code = errorCode {
let code = errorCode
{
switch code {
case -25211, -1719:
case -25_211, -1_719:
// These error codes indicate accessibility permission issues
throw TerminalLauncherError.accessibilityPermissionDenied
case -2741:
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)
throw TerminalLauncherError.appleScriptExecutionFailed(
"AppleScript syntax error - likely unescaped quotes in command",
errorCode: code
)
default:
break
}
@ -456,28 +465,27 @@ final class TerminalLauncher {
throw TerminalLauncherError.appleScriptExecutionFailed(error.localizedDescription, errorCode: nil)
}
}
// MARK: - Terminal Session Launching
func launchTerminalSession(workingDirectory: String, command: String, sessionId: String) throws {
// Find tty-fwd binary path
let ttyFwdPath = findTTYFwdBinary()
// 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 with cd && tty-fwd && exit pattern
// tty-fwd will use TTY_SESSION_ID from environment or generate one
let fullCommand = "cd \"\(escapedWorkingDir)\" && TTY_SESSION_ID=\"\(sessionId)\" \(ttyFwdPath) -- \(command) && exit"
let fullCommand =
"cd \"\(escapedWorkingDir)\" && TTY_SESSION_ID=\"\(sessionId)\" \(ttyFwdPath) -- \(command) && 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,
@ -486,20 +494,27 @@ final class TerminalLauncher {
)
try launchWithConfig(config)
}
/// Optimized terminal session launching that receives pre-formatted command from Rust
func launchOptimizedTerminalSession(workingDirectory: String, command: String, sessionId: String, ttyFwdPath: String? = nil) throws {
func launchOptimizedTerminalSession(
workingDirectory: String,
command: String,
sessionId: String,
ttyFwdPath: String? = nil
)
throws
{
// Expand tilde in working directory path
let expandedWorkingDir = (workingDirectory as NSString).expandingTildeInPath
// Use provided tty-fwd path or find bundled one
_ = ttyFwdPath ?? findTTYFwdBinary()
// The command comes pre-formatted from Rust, just launch it
// Pass the working directory separately to avoid double-escaping issues
// Get the preferred terminal or fallback
let terminal = getValidTerminal()
// Launch with configuration - let TerminalLaunchConfig handle the escaping
let config = TerminalLaunchConfig(
command: command,
@ -508,14 +523,14 @@ final class TerminalLauncher {
)
try launchWithConfig(config)
}
private func findTTYFwdBinary() -> String {
// Look for bundled tty-fwd binary (shipped with the app)
if let bundledTTYFwd = Bundle.main.path(forResource: "tty-fwd", ofType: nil) {
logger.info("Using bundled tty-fwd at: \(bundledTTYFwd)")
return bundledTTYFwd
}
logger.error("No tty-fwd binary found in app bundle, command will fail")
return "echo 'VibeTunnel: tty-fwd binary not found in app bundle'; false"
}

View file

@ -9,7 +9,7 @@ struct VibeTunnelApp: App {
var appDelegate
@State private var sessionMonitor = SessionMonitor.shared
@State private var serverMonitor = ServerMonitor.shared
init() {
// No special initialization needed
}
@ -103,9 +103,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let showInDock = UserDefaults.standard.bool(forKey: "showInDock")
NSApp.setActivationPolicy(showInDock ? .regular : .accessory)
// Show welcome screen on first launch
let hasSeenWelcome = UserDefaults.standard.bool(forKey: "hasSeenWelcome")
if !hasSeenWelcome && !isRunningInTests && !isRunningInPreview {
// Show welcome screen when version changes
let storedWelcomeVersion = UserDefaults.standard.integer(forKey: AppConstants.UserDefaultsKeys.welcomeVersion)
// Show welcome if version is different from current
if storedWelcomeVersion < AppConstants.currentWelcomeVersion && !isRunningInTests && !isRunningInPreview {
showWelcomeScreen()
}
@ -122,7 +124,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Start the terminal spawn service
TerminalSpawnService.shared.start()
// Initialize and start HTTP server using ServerManager
Task {
do {
@ -217,7 +219,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillTerminate(_ notification: Notification) {
// Stop session monitoring
sessionMonitor.stopMonitoring()
// Stop terminal spawn service
TerminalSpawnService.shared.stop()