mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-26 15:07:39 +00:00
show welcome dialog on update + lint
This commit is contained in:
parent
d9d134ff2b
commit
daf455ec9a
17 changed files with 445 additions and 369 deletions
13
VibeTunnel/Core/Models/AppConstants.swift
Normal file
13
VibeTunnel/Core/Models/AppConstants.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue