mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +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 Foundation
|
||||||
import AppKit
|
@preconcurrency import AppKit
|
||||||
import OSLog
|
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.
|
/// 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
|
/// 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.
|
/// Executes an AppleScript synchronously with proper error handling.
|
||||||
///
|
///
|
||||||
/// This method defers the actual AppleScript execution to the next run loop
|
/// This method runs on the main thread and is suitable for use in
|
||||||
/// to prevent crashes when called from SwiftUI actions.
|
/// synchronous contexts where async/await is not available.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - script: The AppleScript source code to execute
|
/// - script: The AppleScript source code to execute
|
||||||
|
|
@ -31,100 +36,155 @@ final class AppleScriptExecutor {
|
||||||
/// - Returns: The result of the AppleScript execution, if any
|
/// - Returns: The result of the AppleScript execution, if any
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func execute(_ script: String, timeout: TimeInterval = 5.0) throws -> NSAppleEventDescriptor? {
|
func execute(_ script: String, timeout: TimeInterval = 5.0) throws -> NSAppleEventDescriptor? {
|
||||||
// Create a semaphore to wait for async execution
|
// If we're already on the main thread, execute directly
|
||||||
let semaphore = DispatchSemaphore(value: 0)
|
if Thread.isMainThread {
|
||||||
var executionResult: NSAppleEventDescriptor?
|
// Add a small delay to avoid crashes from SwiftUI actions
|
||||||
var executionError: Error?
|
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1))
|
||||||
|
|
||||||
// Defer AppleScript execution to next run loop to avoid crashes
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
||||||
var error: NSDictionary?
|
var error: NSDictionary?
|
||||||
if let scriptObject = NSAppleScript(source: script) {
|
guard let scriptObject = NSAppleScript(source: script) else {
|
||||||
executionResult = scriptObject.executeAndReturnError(&error)
|
logger.error("Failed to create NSAppleScript object")
|
||||||
|
throw AppleScriptError.scriptCreationFailed
|
||||||
if let error = error {
|
}
|
||||||
let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
|
|
||||||
let errorNumber = error["NSAppleScriptErrorNumber"] as? Int
|
let result = scriptObject.executeAndReturnError(&error)
|
||||||
|
|
||||||
// Log all error details
|
if let error = error {
|
||||||
self.logger.error("AppleScript execution failed:")
|
let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
|
||||||
self.logger.error(" Error code: \(errorNumber ?? -1)")
|
let errorNumber = error["NSAppleScriptErrorNumber"] as? Int
|
||||||
self.logger.error(" Error message: \(errorMessage)")
|
|
||||||
if let errorRange = error["NSAppleScriptErrorRange"] as? NSRange {
|
logger.error("AppleScript execution failed: \(errorMessage) (code: \(errorNumber ?? -1))")
|
||||||
self.logger.error(" Error range: \(errorRange)")
|
|
||||||
}
|
throw AppleScriptError.executionFailed(
|
||||||
if let errorBriefMessage = error["NSAppleScriptErrorBriefMessage"] as? String {
|
message: errorMessage,
|
||||||
self.logger.error(" Brief message: \(errorBriefMessage)")
|
errorCode: errorNumber
|
||||||
}
|
)
|
||||||
|
}
|
||||||
// Create appropriate error
|
|
||||||
executionError = AppleScriptError.executionFailed(
|
logger.debug("AppleScript executed successfully")
|
||||||
message: errorMessage,
|
return result
|
||||||
errorCode: errorNumber
|
} else {
|
||||||
)
|
// If on background thread, dispatch to main and wait
|
||||||
} else {
|
var result: Result<NSAppleEventDescriptor?, Error>?
|
||||||
// Log successful execution
|
|
||||||
self.logger.debug("AppleScript executed successfully")
|
DispatchQueue.main.sync {
|
||||||
}
|
do {
|
||||||
} else {
|
result = .success(try execute(script, timeout: timeout))
|
||||||
self.logger.error("Failed to create NSAppleScript object")
|
} catch {
|
||||||
executionError = AppleScriptError.scriptCreationFailed
|
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.
|
/// Executes an AppleScript asynchronously.
|
||||||
///
|
///
|
||||||
/// This method is useful when you don't need to wait for the result
|
/// This method ensures AppleScript runs on the main thread with proper
|
||||||
/// and want to avoid blocking the current thread.
|
/// timeout handling using Swift's modern concurrency features.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - script: The AppleScript source code to execute
|
/// - script: The AppleScript source code to execute
|
||||||
/// - timeout: The timeout in seconds (default: 5.0, max: 30.0)
|
/// - timeout: The timeout in seconds (default: 5.0, max: 30.0)
|
||||||
/// - Returns: The result of the AppleScript execution, if any
|
/// - Returns: The result of the AppleScript execution, if any
|
||||||
func executeAsync(_ script: String, timeout: TimeInterval = 5.0) async throws -> NSAppleEventDescriptor? {
|
func executeAsync(_ script: String, timeout: TimeInterval = 5.0) async throws -> NSAppleEventDescriptor? {
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
let timeoutDuration = min(timeout, 30.0)
|
||||||
// Defer execution to next run loop to avoid crashes
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
// Use a class with NSLock to ensure thread-safe access
|
||||||
var error: NSDictionary?
|
final class ContinuationWrapper: @unchecked Sendable {
|
||||||
if let scriptObject = NSAppleScript(source: script) {
|
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)
|
let result = scriptObject.executeAndReturnError(&error)
|
||||||
|
|
||||||
if let error = error {
|
if let error = error {
|
||||||
let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
|
let errorMessage = error["NSAppleScriptErrorMessage"] as? String ?? "Unknown error"
|
||||||
let errorNumber = error["NSAppleScriptErrorNumber"] as? Int
|
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,
|
message: errorMessage,
|
||||||
errorCode: errorNumber
|
errorCode: errorNumber
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
self.logger.debug("AppleScript executed successfully")
|
logger.debug("AppleScript executed successfully")
|
||||||
continuation.resume(returning: result)
|
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>
|
<string>AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=</string>
|
||||||
<key>SUScheduledCheckInterval</key>
|
<key>SUScheduledCheckInterval</key>
|
||||||
<integer>86400</integer>
|
<integer>86400</integer>
|
||||||
|
<key>NSAppleEventsUsageDescription</key>
|
||||||
|
<string>VibeTunnel needs to control terminal applications to create new terminal sessions from the dashboard.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,9 @@ private struct TerminalPreferenceSection: View {
|
||||||
case .appleScriptPermissionDenied:
|
case .appleScriptPermissionDenied:
|
||||||
errorTitle = "Permission Denied"
|
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."
|
||||||
case .terminalNotFound:
|
case .terminalNotFound:
|
||||||
errorTitle = "Terminal Not Found"
|
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."
|
||||||
|
|
@ -166,6 +169,9 @@ private struct TerminalPreferenceSection: View {
|
||||||
case -1_708:
|
case -1_708:
|
||||||
errorTitle = "Terminal Communication Error"
|
errorTitle = "Terminal Communication Error"
|
||||||
errorMessage = "The terminal did not respond to the command.\n\nDetails: \(details)"
|
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:
|
default:
|
||||||
errorTitle = "Terminal Launch Failed"
|
errorTitle = "Terminal Launch Failed"
|
||||||
errorMessage = "AppleScript error \(code): \(details)"
|
errorMessage = "AppleScript error \(code): \(details)"
|
||||||
|
|
|
||||||
|
|
@ -828,23 +828,25 @@ private struct NgrokErrorView: View {
|
||||||
// MARK: - Permissions Section
|
// MARK: - Permissions Section
|
||||||
|
|
||||||
private struct PermissionsSection: View {
|
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 {
|
var body: some View {
|
||||||
Section {
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
// Automation permission
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Terminal Automation")
|
Text("Terminal Automation")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
Text("Required to spawn terminal sessions from the dashboard")
|
Text("Required to launch and control terminal applications")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if permissionManager.hasPermission {
|
if appleScriptManager.hasPermission {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundColor(.green)
|
.foregroundColor(.green)
|
||||||
|
|
@ -854,7 +856,38 @@ private struct PermissionsSection: View {
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
} else {
|
} else {
|
||||||
Button("Grant Permission") {
|
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)
|
.buttonStyle(.bordered)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
|
|
@ -865,11 +898,16 @@ private struct PermissionsSection: View {
|
||||||
Text("Permissions")
|
Text("Permissions")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
} footer: {
|
} 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)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
.task {
|
.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
|
// Show in Dock
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Toggle("Show in Dock", isOn: showInDockBinding)
|
Toggle("Show in Dock", isOn: showInDockBinding)
|
||||||
Text("Show VibeTunnel icon in the Dock.")
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
.font(.caption)
|
Text("Show VibeTunnel icon in the Dock.")
|
||||||
.foregroundStyle(.secondary)
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("The dock icon is always displayed when the Settings dialog is visible.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Appearance")
|
Text("Appearance")
|
||||||
|
|
@ -114,7 +119,8 @@ struct GeneralSettingsView: View {
|
||||||
get: { showInDock },
|
get: { showInDock },
|
||||||
set: { newValue in
|
set: { newValue in
|
||||||
showInDock = newValue
|
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
|
// MARK: - Request Permissions Page
|
||||||
|
|
||||||
/// Third page requesting AppleScript automation permissions.
|
/// Third page requesting AppleScript automation and accessibility permissions.
|
||||||
private struct RequestPermissionsPageView: View {
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 30) {
|
VStack(spacing: 30) {
|
||||||
|
|
@ -254,7 +255,7 @@ private struct RequestPermissionsPageView: View {
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
Text(
|
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)
|
.font(.body)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
@ -262,30 +263,89 @@ private struct RequestPermissionsPageView: View {
|
||||||
.frame(maxWidth: 480)
|
.frame(maxWidth: 480)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
// Permission status and button
|
// Permissions list
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 20) {
|
||||||
if permissionManager.hasPermission {
|
// AppleScript permission
|
||||||
|
VStack(spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "applescript")
|
||||||
.foregroundColor(.green)
|
.font(.title2)
|
||||||
Text("Permission granted")
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
.foregroundColor(.secondary)
|
Text("Automation Permission")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Required to launch and control terminal applications")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
.font(.body)
|
|
||||||
} else {
|
if appleScriptManager.hasPermission {
|
||||||
Button("Grant Permission") {
|
HStack {
|
||||||
permissionManager.requestPermission()
|
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)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
.task {
|
.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
|
fullCommand
|
||||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||||
.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: "\\'")
|
.replacingOccurrences(of: "'", with: "\\'")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +127,7 @@ enum Terminal: String, CaseIterable {
|
||||||
end tell
|
end tell
|
||||||
delay 0.2
|
delay 0.2
|
||||||
tell application "System Events"
|
tell application "System Events"
|
||||||
keystroke "\(config.appleScriptEscapedCommand)"
|
keystroke "\(config.keystrokeEscapedCommand)"
|
||||||
key code 36
|
key code 36
|
||||||
end tell
|
end tell
|
||||||
end tell
|
end tell
|
||||||
|
|
@ -138,7 +146,7 @@ enum Terminal: String, CaseIterable {
|
||||||
keystroke "n" using {command down}
|
keystroke "n" using {command down}
|
||||||
end tell
|
end tell
|
||||||
delay 0.1
|
delay 0.1
|
||||||
do script "\(config.command)" in front window
|
do script "\(config.escapedCommand)" in front window
|
||||||
end tell
|
end tell
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
@ -156,7 +164,7 @@ enum Terminal: String, CaseIterable {
|
||||||
activate
|
activate
|
||||||
create window with default profile
|
create window with default profile
|
||||||
tell current session of current window
|
tell current session of current window
|
||||||
write text "\(config.command)"
|
write text "\(config.escapedCommand)"
|
||||||
end tell
|
end tell
|
||||||
end tell
|
end tell
|
||||||
""")
|
""")
|
||||||
|
|
@ -209,6 +217,22 @@ enum Terminal: String, CaseIterable {
|
||||||
case .wezterm: return "WezTerm"
|
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.
|
/// Errors that can occur when launching terminal commands.
|
||||||
|
|
@ -218,6 +242,7 @@ enum Terminal: String, CaseIterable {
|
||||||
enum TerminalLauncherError: LocalizedError {
|
enum TerminalLauncherError: LocalizedError {
|
||||||
case terminalNotFound
|
case terminalNotFound
|
||||||
case appleScriptPermissionDenied
|
case appleScriptPermissionDenied
|
||||||
|
case accessibilityPermissionDenied
|
||||||
case appleScriptExecutionFailed(String, errorCode: Int?)
|
case appleScriptExecutionFailed(String, errorCode: Int?)
|
||||||
case processLaunchFailed(String)
|
case processLaunchFailed(String)
|
||||||
|
|
||||||
|
|
@ -227,6 +252,8 @@ enum TerminalLauncherError: LocalizedError {
|
||||||
return "Selected terminal application not found"
|
return "Selected terminal application not found"
|
||||||
case .appleScriptPermissionDenied:
|
case .appleScriptPermissionDenied:
|
||||||
return "AppleScript permission denied. Please grant permission in System Settings."
|
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):
|
case .appleScriptExecutionFailed(let message, let errorCode):
|
||||||
if let code = errorCode {
|
if let code = errorCode {
|
||||||
return "AppleScript error \(code): \(message)"
|
return "AppleScript error \(code): \(message)"
|
||||||
|
|
@ -242,6 +269,8 @@ enum TerminalLauncherError: LocalizedError {
|
||||||
switch self {
|
switch self {
|
||||||
case .appleScriptPermissionDenied:
|
case .appleScriptPermissionDenied:
|
||||||
return "VibeTunnel needs Automation permission to control terminal applications."
|
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):
|
case .appleScriptExecutionFailed(_, let errorCode):
|
||||||
if let code = errorCode {
|
if let code = errorCode {
|
||||||
switch code {
|
switch code {
|
||||||
|
|
@ -251,6 +280,8 @@ enum TerminalLauncherError: LocalizedError {
|
||||||
return "The application is not running or cannot be controlled."
|
return "The application is not running or cannot be controlled."
|
||||||
case -1_708:
|
case -1_708:
|
||||||
return "The event was not handled by the target application."
|
return "The event was not handled by the target application."
|
||||||
|
case -25211:
|
||||||
|
return "Accessibility permission is required to send keystrokes."
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -395,6 +426,13 @@ final class TerminalLauncher {
|
||||||
// as some terminals (like Ghostty) can take longer to start up
|
// as some terminals (like Ghostty) can take longer to start up
|
||||||
try AppleScriptExecutor.shared.execute(script, timeout: 15.0)
|
try AppleScriptExecutor.shared.execute(script, timeout: 15.0)
|
||||||
} catch let error as AppleScriptError {
|
} 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
|
// Convert AppleScriptError to TerminalLauncherError
|
||||||
throw error.toTerminalLauncherError()
|
throw error.toTerminalLauncherError()
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue