Also request accessibility

This commit is contained in:
Peter Steinberger 2025-06-17 22:08:21 +02:00
parent ee1a48848c
commit b4f8600ffd
8 changed files with 359 additions and 101 deletions

View file

@ -0,0 +1,48 @@
import Foundation
import AppKit
import ApplicationServices
import OSLog
/// Manages Accessibility permissions required for sending keystrokes.
///
/// This class provides methods to check and request accessibility permissions
/// 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") {
NSWorkspace.shared.open(url)
}
}
}
}
}

View file

@ -1,7 +1,12 @@
import Foundation
import AppKit
@preconcurrency import AppKit
import OSLog
/// Sendable wrapper for NSAppleEventDescriptor
private struct SendableDescriptor: @unchecked Sendable {
let descriptor: NSAppleEventDescriptor?
}
/// Safely executes AppleScript commands with proper error handling and crash prevention.
///
/// This class ensures AppleScript execution is deferred to the next run loop to avoid
@ -21,8 +26,8 @@ final class AppleScriptExecutor {
/// Executes an AppleScript synchronously with proper error handling.
///
/// This method defers the actual AppleScript execution to the next run loop
/// to prevent crashes when called from SwiftUI actions.
/// This method runs on the main thread and is suitable for use in
/// synchronous contexts where async/await is not available.
///
/// - Parameters:
/// - script: The AppleScript source code to execute
@ -31,100 +36,155 @@ final class AppleScriptExecutor {
/// - Returns: The result of the AppleScript execution, if any
@discardableResult
func execute(_ script: String, timeout: TimeInterval = 5.0) throws -> NSAppleEventDescriptor? {
// Create a semaphore to wait for async execution
let semaphore = DispatchSemaphore(value: 0)
var executionResult: NSAppleEventDescriptor?
var executionError: Error?
// Defer AppleScript execution to next run loop to avoid crashes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
// If we're already on the main thread, execute directly
if Thread.isMainThread {
// Add a small delay to avoid crashes from SwiftUI actions
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1))
var error: NSDictionary?
if let scriptObject = NSAppleScript(source: script) {
executionResult = scriptObject.executeAndReturnError(&error)
if let error = error {
let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
let errorNumber = error["NSAppleScriptErrorNumber"] as? Int
// Log all error details
self.logger.error("AppleScript execution failed:")
self.logger.error(" Error code: \(errorNumber ?? -1)")
self.logger.error(" Error message: \(errorMessage)")
if let errorRange = error["NSAppleScriptErrorRange"] as? NSRange {
self.logger.error(" Error range: \(errorRange)")
}
if let errorBriefMessage = error["NSAppleScriptErrorBriefMessage"] as? String {
self.logger.error(" Brief message: \(errorBriefMessage)")
}
// Create appropriate error
executionError = AppleScriptError.executionFailed(
message: errorMessage,
errorCode: errorNumber
)
} else {
// Log successful execution
self.logger.debug("AppleScript executed successfully")
}
} else {
self.logger.error("Failed to create NSAppleScript object")
executionError = AppleScriptError.scriptCreationFailed
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 {
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))
} catch {
result = .failure(error)
}
}
switch result! {
case .success(let value):
return value
case .failure(let error):
throw error
}
semaphore.signal()
}
// Wait for execution to complete with timeout (default 5 seconds, max 30 seconds)
let timeoutDuration = min(timeout, 30.0)
let waitResult = semaphore.wait(timeout: .now() + timeoutDuration)
if waitResult == .timedOut {
logger.error("AppleScript execution timed out after \(timeoutDuration) seconds")
throw AppleScriptError.timeout
}
if let error = executionError {
throw error
}
return executionResult
}
/// Executes an AppleScript asynchronously.
///
/// This method is useful when you don't need to wait for the result
/// and want to avoid blocking the current thread.
/// This method ensures AppleScript runs on the main thread with proper
/// timeout handling using Swift's modern concurrency features.
///
/// - Parameters:
/// - script: The AppleScript source code to execute
/// - timeout: The timeout in seconds (default: 5.0, max: 30.0)
/// - Returns: The result of the AppleScript execution, if any
func executeAsync(_ script: String, timeout: TimeInterval = 5.0) async throws -> NSAppleEventDescriptor? {
return try await withCheckedThrowingContinuation { continuation in
// Defer execution to next run loop to avoid crashes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
var error: NSDictionary?
if let scriptObject = NSAppleScript(source: script) {
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 {
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
} catch {
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 {
let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
let errorNumber = error["NSAppleScriptErrorNumber"] as? Int
self.logger.error("AppleScript execution failed: \(errorMessage) (code: \(errorNumber ?? -1))")
logger.error("AppleScript execution failed:")
logger.error(" Error code: \(errorNumber ?? -1)")
logger.error(" Error message: \(errorMessage)")
if let errorRange = error["NSAppleScriptErrorRange"] as? NSRange {
logger.error(" Error range: \(errorRange)")
}
if let errorBriefMessage = error["NSAppleScriptErrorBriefMessage"] as? String {
logger.error(" Brief message: \(errorBriefMessage)")
}
continuation.resume(throwing: AppleScriptError.executionFailed(
wrapper.resume(throwing: AppleScriptError.executionFailed(
message: errorMessage,
errorCode: errorNumber
))
} else {
self.logger.debug("AppleScript executed successfully")
continuation.resume(returning: result)
logger.debug("AppleScript executed successfully")
wrapper.resume(returning: result)
}
}
// Set up timeout
Task {
do {
try await Task.sleep(nanoseconds: UInt64(timeoutDuration * 1_000_000_000))
logger.error("AppleScript execution timed out after \(timeoutDuration) seconds")
wrapper.resume(throwing: AppleScriptError.timeout)
} catch {
// Task was cancelled, do nothing
}
} else {
self.logger.error("Failed to create NSAppleScript object")
continuation.resume(throwing: AppleScriptError.scriptCreationFailed)
}
}
return sendableResult.descriptor
} onCancel: {
// Handle cancellation if needed
}
}

View file

@ -33,5 +33,7 @@
<string>AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=</string>
<key>SUScheduledCheckInterval</key>
<integer>86400</integer>
<key>NSAppleEventsUsageDescription</key>
<string>VibeTunnel needs to control terminal applications to create new terminal sessions from the dashboard.</string>
</dict>
</plist>

View file

@ -151,6 +151,9 @@ private struct TerminalPreferenceSection: View {
case .appleScriptPermissionDenied:
errorTitle = "Permission Denied"
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."
case .terminalNotFound:
errorTitle = "Terminal Not Found"
errorMessage = "The selected terminal application could not be found. Please select a different terminal."
@ -166,6 +169,9 @@ private struct TerminalPreferenceSection: View {
case -1_708:
errorTitle = "Terminal Communication Error"
errorMessage = "The terminal did not respond to the command.\n\nDetails: \(details)"
case -25211:
errorTitle = "Accessibility Permission Required"
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)"

View file

@ -828,23 +828,25 @@ private struct NgrokErrorView: View {
// MARK: - Permissions Section
private struct PermissionsSection: View {
@StateObject private var permissionManager = AppleScriptPermissionManager.shared
@StateObject private var appleScriptManager = AppleScriptPermissionManager.shared
@State private var hasAccessibilityPermission = false
var body: some View {
Section {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 16) {
// Automation permission
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Terminal Automation")
.font(.body)
Text("Required to spawn terminal sessions from the dashboard")
Text("Required to launch and control terminal applications")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if permissionManager.hasPermission {
if appleScriptManager.hasPermission {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
@ -854,7 +856,38 @@ private struct PermissionsSection: View {
.font(.caption)
} else {
Button("Grant Permission") {
permissionManager.requestPermission()
appleScriptManager.requestPermission()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
Divider()
// Accessibility permission
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Accessibility")
.font(.body)
Text("Required for terminals that need keystroke input (Ghostty, Warp, Hyper)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if hasAccessibilityPermission {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Granted")
.foregroundColor(.secondary)
}
.font(.caption)
} else {
Button("Grant Permission") {
AccessibilityPermissionManager.shared.requestPermission()
}
.buttonStyle(.bordered)
.controlSize(.small)
@ -865,11 +898,16 @@ private struct PermissionsSection: View {
Text("Permissions")
.font(.headline)
} footer: {
Text("AppleScript permission is required to open terminal applications when creating new sessions.")
Text("Terminal automation is required for all terminals. Accessibility is only needed for terminals that simulate keyboard input.")
.font(.caption)
}
.task {
_ = await permissionManager.checkPermission()
_ = await appleScriptManager.checkPermission()
hasAccessibilityPermission = AccessibilityPermissionManager.shared.hasPermission()
}
.onReceive(Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()) { _ in
// Check accessibility permission status periodically
hasAccessibilityPermission = AccessibilityPermissionManager.shared.hasPermission()
}
}
}

View file

@ -80,9 +80,14 @@ struct GeneralSettingsView: View {
// Show in Dock
VStack(alignment: .leading, spacing: 4) {
Toggle("Show in Dock", isOn: showInDockBinding)
Text("Show VibeTunnel icon in the Dock.")
.font(.caption)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
Text("Show VibeTunnel icon in the Dock.")
.font(.caption)
.foregroundStyle(.secondary)
Text("The dock icon is always displayed when the Settings dialog is visible.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
} header: {
Text("Appearance")
@ -114,7 +119,8 @@ struct GeneralSettingsView: View {
get: { showInDock },
set: { newValue in
showInDock = newValue
NSApp.setActivationPolicy(newValue ? .regular : .accessory)
// Don't change activation policy while settings window is open
// The change will be applied when the settings window closes
}
)
}

View file

@ -236,9 +236,10 @@ private struct VTCommandPageView: View {
// MARK: - Request Permissions Page
/// Third page requesting AppleScript automation permissions.
/// Third page requesting AppleScript automation and accessibility permissions.
private struct RequestPermissionsPageView: View {
@StateObject private var permissionManager = AppleScriptPermissionManager.shared
@StateObject private var appleScriptManager = AppleScriptPermissionManager.shared
@State private var hasAccessibilityPermission = false
var body: some View {
VStack(spacing: 30) {
@ -254,7 +255,7 @@ private struct RequestPermissionsPageView: View {
.fontWeight(.semibold)
Text(
"VibeTunnel uses AppleScript to spawn a terminal when you create a new session in the dashboard."
"VibeTunnel needs permissions to launch terminal sessions and send commands to certain terminals."
)
.font(.body)
.foregroundColor(.secondary)
@ -262,30 +263,89 @@ private struct RequestPermissionsPageView: View {
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
// Permission status and button
VStack(spacing: 12) {
if permissionManager.hasPermission {
// Permissions list
VStack(spacing: 20) {
// AppleScript permission
VStack(spacing: 12) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Permission granted")
.foregroundColor(.secondary)
Image(systemName: "applescript")
.font(.title2)
VStack(alignment: .leading, spacing: 4) {
Text("Automation Permission")
.font(.headline)
Text("Required to launch and control terminal applications")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.font(.body)
} else {
Button("Grant Permission") {
permissionManager.requestPermission()
if appleScriptManager.hasPermission {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Automation permission granted")
.foregroundColor(.secondary)
}
.font(.body)
} else {
Button("Grant Automation Permission") {
appleScriptManager.requestPermission()
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
.padding()
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
// Accessibility permission
VStack(spacing: 12) {
HStack {
Image(systemName: "accessibility")
.font(.title2)
VStack(alignment: .leading, spacing: 4) {
Text("Accessibility Permission")
.font(.headline)
Text("Required for terminals like Ghostty that need keystroke input")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
if hasAccessibilityPermission {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Accessibility permission granted")
.foregroundColor(.secondary)
}
.font(.body)
} else {
Button("Grant Accessibility Permission") {
AccessibilityPermissionManager.shared.requestPermission()
}
.buttonStyle(.bordered)
.controlSize(.large)
}
}
.padding()
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.task {
_ = await permissionManager.checkPermission()
_ = await appleScriptManager.checkPermission()
hasAccessibilityPermission = AccessibilityPermissionManager.shared.hasPermission()
}
.onReceive(Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()) { _ in
// Check accessibility permission status periodically
hasAccessibilityPermission = AccessibilityPermissionManager.shared.hasPermission()
}
}
}

View file

@ -27,6 +27,14 @@ struct TerminalLaunchConfig {
fullCommand
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "'", with: "'\\''")
}
var keystrokeEscapedCommand: String {
// For keystroke commands, we need to escape quotes differently
fullCommand
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\\\\\"")
.replacingOccurrences(of: "'", with: "\\'")
}
}
@ -119,7 +127,7 @@ enum Terminal: String, CaseIterable {
end tell
delay 0.2
tell application "System Events"
keystroke "\(config.appleScriptEscapedCommand)"
keystroke "\(config.keystrokeEscapedCommand)"
key code 36
end tell
end tell
@ -138,7 +146,7 @@ enum Terminal: String, CaseIterable {
keystroke "n" using {command down}
end tell
delay 0.1
do script "\(config.command)" in front window
do script "\(config.escapedCommand)" in front window
end tell
""")
@ -156,7 +164,7 @@ enum Terminal: String, CaseIterable {
activate
create window with default profile
tell current session of current window
write text "\(config.command)"
write text "\(config.escapedCommand)"
end tell
end tell
""")
@ -209,6 +217,22 @@ enum Terminal: String, CaseIterable {
case .wezterm: return "WezTerm"
}
}
/// Whether this terminal requires keystroke-based input (needs Accessibility permission)
var requiresKeystrokeInput: Bool {
switch self {
case .terminal:
return false // Uses 'do script' command, not keystrokes
case .iTerm2:
return false // Uses URL scheme or 'write text' command
case .ghostty, .warp, .hyper:
return true // Uses keystroke-based input
case .tabby:
return true // Uses processWithTyping which requires keystrokes
case .alacritty, .wezterm:
return false // Uses command line arguments
}
}
}
/// Errors that can occur when launching terminal commands.
@ -218,6 +242,7 @@ enum Terminal: String, CaseIterable {
enum TerminalLauncherError: LocalizedError {
case terminalNotFound
case appleScriptPermissionDenied
case accessibilityPermissionDenied
case appleScriptExecutionFailed(String, errorCode: Int?)
case processLaunchFailed(String)
@ -227,6 +252,8 @@ enum TerminalLauncherError: LocalizedError {
return "Selected terminal application not found"
case .appleScriptPermissionDenied:
return "AppleScript permission denied. Please grant permission in System Settings."
case .accessibilityPermissionDenied:
return "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)"
@ -242,6 +269,8 @@ enum TerminalLauncherError: LocalizedError {
switch self {
case .appleScriptPermissionDenied:
return "VibeTunnel needs Automation permission to control terminal applications."
case .accessibilityPermissionDenied:
return "VibeTunnel needs Accessibility permission to send keystrokes to terminal applications."
case .appleScriptExecutionFailed(_, let errorCode):
if let code = errorCode {
switch code {
@ -251,6 +280,8 @@ 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:
return "Accessibility permission is required to send keystrokes."
default:
return nil
}
@ -395,6 +426,13 @@ final class TerminalLauncher {
// as some terminals (like Ghostty) can take longer to start up
try AppleScriptExecutor.shared.execute(script, timeout: 15.0)
} catch let error as AppleScriptError {
// Check if this is a keystroke permission error
if case .executionFailed(_, let errorCode) = error,
let code = errorCode,
(code == -25211 || code == -1719) {
// These error codes indicate accessibility permission issues
throw TerminalLauncherError.accessibilityPermissionDenied
}
// Convert AppleScriptError to TerminalLauncherError
throw error.toTerminalLauncherError()
} catch {