mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-16 13:05:53 +00:00
Also request accessibility
This commit is contained in:
parent
ee1a48848c
commit
b4f8600ffd
8 changed files with 359 additions and 101 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue