Improve Applescript logic

This commit is contained in:
Peter Steinberger 2025-06-17 18:16:45 +02:00
parent 0e4cacc69d
commit 6a0fe1a8af
6 changed files with 155 additions and 69 deletions

View file

@ -24,11 +24,13 @@ final class AppleScriptExecutor {
/// This method defers the actual AppleScript execution to the next run loop /// This method defers the actual AppleScript execution to the next run loop
/// to prevent crashes when called from SwiftUI actions. /// to prevent crashes when called from SwiftUI actions.
/// ///
/// - Parameter script: The AppleScript source code to execute /// - Parameters:
/// - script: The AppleScript source code to execute
/// - timeout: The timeout in seconds (default: 5.0, max: 30.0)
/// - Throws: `AppleScriptError` if execution fails /// - Throws: `AppleScriptError` if execution fails
/// - Returns: The result of the AppleScript execution, if any /// - Returns: The result of the AppleScript execution, if any
@discardableResult @discardableResult
func execute(_ script: String) throws -> NSAppleEventDescriptor? { func execute(_ script: String, timeout: TimeInterval = 5.0) throws -> NSAppleEventDescriptor? {
// Create a semaphore to wait for async execution // Create a semaphore to wait for async execution
let semaphore = DispatchSemaphore(value: 0) let semaphore = DispatchSemaphore(value: 0)
var executionResult: NSAppleEventDescriptor? var executionResult: NSAppleEventDescriptor?
@ -71,11 +73,12 @@ final class AppleScriptExecutor {
semaphore.signal() semaphore.signal()
} }
// Wait for execution to complete with timeout // Wait for execution to complete with timeout (default 5 seconds, max 30 seconds)
let waitResult = semaphore.wait(timeout: .now() + 5.0) let timeoutDuration = min(timeout, 30.0)
let waitResult = semaphore.wait(timeout: .now() + timeoutDuration)
if waitResult == .timedOut { if waitResult == .timedOut {
logger.error("AppleScript execution timed out after 5 seconds") logger.error("AppleScript execution timed out after \(timeoutDuration) seconds")
throw AppleScriptError.timeout throw AppleScriptError.timeout
} }
@ -91,9 +94,11 @@ final class AppleScriptExecutor {
/// This method is useful when you don't need to wait for the result /// This method is useful when you don't need to wait for the result
/// and want to avoid blocking the current thread. /// and want to avoid blocking the current thread.
/// ///
/// - Parameter script: The AppleScript source code to execute /// - 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 /// - Returns: The result of the AppleScript execution, if any
func executeAsync(_ script: String) async throws -> NSAppleEventDescriptor? { func executeAsync(_ script: String, timeout: TimeInterval = 5.0) async throws -> NSAppleEventDescriptor? {
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
// Defer execution to next run loop to avoid crashes // Defer execution to next run loop to avoid crashes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {

View file

@ -42,13 +42,34 @@ final class AppleScriptPermissionManager: ObservableObject {
return permitted return permitted
} }
/// Requests AppleScript automation permissions by opening System Settings. /// Requests AppleScript automation permissions by triggering the permission dialog.
func requestPermission() { func requestPermission() {
logger.info("Requesting AppleScript automation permissions") logger.info("Requesting AppleScript automation permissions")
// Open System Settings to Privacy & Security > Automation // First, execute an AppleScript to trigger the automation permission dialog
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") { // This ensures VibeTunnel appears in the Automation settings
NSWorkspace.shared.open(url) Task {
let triggerScript = """
tell application "Terminal"
-- This will trigger the automation permission dialog
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 // Continue monitoring more frequently after request

View file

@ -853,22 +853,11 @@ private struct PermissionsSection: View {
} }
.font(.caption) .font(.caption)
} else { } else {
Button("Accept Permission") { Button("Grant Permission") {
permissionManager.requestPermission() permissionManager.requestPermission()
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
.disabled(permissionManager.isChecking)
}
}
if permissionManager.isChecking {
HStack {
ProgressView()
.scaleEffect(0.7)
Text("Checking permissions...")
.font(.caption)
.foregroundStyle(.secondary)
} }
} }
} }
@ -880,7 +869,7 @@ private struct PermissionsSection: View {
.font(.caption) .font(.caption)
} }
.task { .task {
await permissionManager.checkPermission() _ = await permissionManager.checkPermission()
} }
} }
} }

View file

@ -273,17 +273,11 @@ private struct RequestPermissionsPageView: View {
} }
.font(.body) .font(.body)
} else { } else {
Button("Request Permission") { Button("Grant Permission") {
permissionManager.requestPermission() permissionManager.requestPermission()
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.controlSize(.large) .controlSize(.large)
.disabled(permissionManager.isChecking)
if permissionManager.isChecking {
ProgressView()
.scaleEffect(0.8)
}
} }
} }
} }
@ -291,7 +285,7 @@ private struct RequestPermissionsPageView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding() .padding()
.task { .task {
await permissionManager.checkPermission() _ = await permissionManager.checkPermission()
} }
} }
} }

View file

@ -1,4 +1,5 @@
@preconcurrency import AppKit @preconcurrency import AppKit
import ApplicationServices
import Foundation import Foundation
import SwiftUI import SwiftUI
@ -11,23 +12,43 @@ import SwiftUI
enum SettingsOpener { enum SettingsOpener {
/// SwiftUI's hardcoded settings window identifier /// SwiftUI's hardcoded settings window identifier
private static let settingsWindowIdentifier = "com_apple_SwiftUI_Settings_window" private static let settingsWindowIdentifier = "com_apple_SwiftUI_Settings_window"
private static var windowObserver: NSObjectProtocol?
/// Opens the Settings window using the environment action via notification /// Opens the Settings window using the environment action via notification
/// This is needed for cases where we can't use SettingsLink (e.g., from notifications) /// This is needed for cases where we can't use SettingsLink (e.g., from notifications)
static func openSettings() { static func openSettings() {
// First try to open via menu item // Store the current dock visibility preference
let openedViaMenu = openSettingsViaMenuItem() let showInDock = UserDefaults.standard.bool(forKey: "showInDock")
if !openedViaMenu { // Temporarily show dock icon to ensure settings window can be brought to front
// Fallback to notification approach if !showInDock {
NotificationCenter.default.post(name: .openSettingsRequest, object: nil) NSApp.setActivationPolicy(.regular)
} }
// Use AppleScript to ensure the window is brought to front // Simple activation and window opening
Task { @MainActor in Task { @MainActor in
// Give time for window creation // Small delay to ensure dock icon is visible
try? await Task.sleep(for: .milliseconds(200)) try? await Task.sleep(for: .milliseconds(50))
bringSettingsToFrontWithAppleScript()
// Activate the app
NSApp.activate(ignoringOtherApps: true)
// Try to open via menu item first
if !openSettingsViaMenuItem() {
// Fallback to notification
NotificationCenter.default.post(name: .openSettingsRequest, object: nil)
}
// Wait for window to appear and make it key
try? await Task.sleep(for: .milliseconds(100))
if let settingsWindow = findSettingsWindow() {
settingsWindow.makeKeyAndOrderFront(nil)
}
// Set up observer to restore dock visibility when settings window closes
if !showInDock {
setupDockVisibilityRestoration()
}
} }
} }
@ -136,6 +157,43 @@ enum SettingsOpener {
} }
} }
// 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,
object: nil,
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 {
return
}
// Window is closing, restore dock visibility
let showInDock = UserDefaults.standard.bool(forKey: "showInDock")
if !showInDock {
NSApp.setActivationPolicy(.accessory)
}
// Clean up observer
if let observer = windowObserver {
NotificationCenter.default.removeObserver(observer)
Self.windowObserver = nil
}
}
}
}
/// Finds the settings window using multiple detection methods /// Finds the settings window using multiple detection methods
static func findSettingsWindow() -> NSWindow? { static func findSettingsWindow() -> NSWindow? {
// Try multiple methods to find the window // Try multiple methods to find the window
@ -181,8 +239,11 @@ enum SettingsOpener {
/// Focuses the settings window without level manipulation /// Focuses the settings window without level manipulation
static func focusSettingsWindow() { static func focusSettingsWindow() {
// Use AppleScript for most reliable focusing // With dock icon visible, simple activation is enough
bringSettingsToFrontWithAppleScript() NSApp.activate(ignoringOtherApps: true)
if let settingsWindow = findSettingsWindow() {
settingsWindow.makeKeyAndOrderFront(nil)
}
} }
/// Brings a window to front using the most reliable method /// Brings a window to front using the most reliable method
@ -212,32 +273,12 @@ enum SettingsOpener {
setupWindowCloseObserver(for: window) setupWindowCloseObserver(for: window)
} }
/// Uses AppleScript to bring the settings window to front /// Simple AppleScript approach to activate the app
/// This is a more aggressive approach that works even when other methods fail /// Now that we show the dock icon, this is much simpler
static func bringSettingsToFrontWithAppleScript() { static func bringSettingsToFrontWithAppleScript() {
let bundleIdentifier = Bundle.main.bundleIdentifier ?? "sh.vibetunnel.vibetunnel" let bundleIdentifier = Bundle.main.bundleIdentifier ?? "sh.vibetunnel.vibetunnel"
let script = """
tell application "System Events"
tell process "VibeTunnel"
set frontmost to true
-- Find and activate the settings window
if exists window "Settings" then
tell window "Settings"
perform action "AXRaise"
end tell
else if exists window 1 whose title contains "Settings" then
tell window 1 whose title contains "Settings"
perform action "AXRaise"
end tell
else if exists window 1 whose title contains "Preferences" then
tell window 1 whose title contains "Preferences"
perform action "AXRaise"
end tell
end if
end tell
end tell
-- Also activate the app itself let activateScript = """
tell application "\(bundleIdentifier)" tell application "\(bundleIdentifier)"
activate activate
end tell end tell
@ -245,9 +286,9 @@ enum SettingsOpener {
Task { @MainActor in Task { @MainActor in
do { do {
_ = try await AppleScriptExecutor.shared.executeAsync(script) _ = try await AppleScriptExecutor.shared.executeAsync(activateScript)
} catch { } catch {
print("AppleScript error bringing settings to front: \(error)") print("AppleScript error activating app: \(error)")
} }
} }
} }
@ -347,6 +388,40 @@ struct HiddenWindowView: View {
} }
} }
} }
/// Uses Accessibility API to force window to front
/// This requires accessibility permissions but is very reliable
static func bringSettingsToFrontWithAccessibility() {
guard let settingsWindow = SettingsOpener.findSettingsWindow() else { return }
// Use CGWindowListCopyWindowInfo to get window information
let windowNumber = settingsWindow.windowNumber
if let windowInfo = CGWindowListCopyWindowInfo([.optionIncludingWindow], CGWindowID(windowNumber)) as? [[String: Any]],
let firstWindow = windowInfo.first,
let pid = firstWindow[kCGWindowOwnerPID as String] as? Int32 {
// Bring the owning process to front
if let app = NSRunningApplication(processIdentifier: pid) {
if #available(macOS 14.0, *) {
app.activate(options: [.activateAllWindows])
} else {
app.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
}
}
}
// Also try manipulating window directly
settingsWindow.collectionBehavior = [NSWindow.CollectionBehavior.moveToActiveSpace, NSWindow.CollectionBehavior.canJoinAllSpaces]
settingsWindow.level = NSWindow.Level.popUpMenu
settingsWindow.orderFrontRegardless()
// Reset after a delay
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(100))
settingsWindow.level = NSWindow.Level.normal
settingsWindow.collectionBehavior = []
}
}
} }
// MARK: - Notification Extensions // MARK: - Notification Extensions

View file

@ -391,7 +391,9 @@ final class TerminalLauncher {
private func executeAppleScript(_ script: String) throws { private func executeAppleScript(_ script: String) throws {
do { do {
try AppleScriptExecutor.shared.execute(script) // Use a longer timeout (15 seconds) for terminal launch operations
// as some terminals (like Ghostty) can take longer to start up
try AppleScriptExecutor.shared.execute(script, timeout: 15.0)
} catch let error as AppleScriptError { } catch let error as AppleScriptError {
// Convert AppleScriptError to TerminalLauncherError // Convert AppleScriptError to TerminalLauncherError
throw error.toTerminalLauncherError() throw error.toTerminalLauncherError()