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
/// 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
/// - Returns: The result of the AppleScript execution, if any
@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
let semaphore = DispatchSemaphore(value: 0)
var executionResult: NSAppleEventDescriptor?
@ -71,11 +73,12 @@ final class AppleScriptExecutor {
semaphore.signal()
}
// Wait for execution to complete with timeout
let waitResult = semaphore.wait(timeout: .now() + 5.0)
// 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 5 seconds")
logger.error("AppleScript execution timed out after \(timeoutDuration) seconds")
throw AppleScriptError.timeout
}
@ -91,9 +94,11 @@ final class AppleScriptExecutor {
/// This method is useful when you don't need to wait for the result
/// 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
func executeAsync(_ script: String) async throws -> NSAppleEventDescriptor? {
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) {

View file

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

View file

@ -853,22 +853,11 @@ private struct PermissionsSection: View {
}
.font(.caption)
} else {
Button("Accept Permission") {
Button("Grant Permission") {
permissionManager.requestPermission()
}
.buttonStyle(.bordered)
.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)
}
.task {
await permissionManager.checkPermission()
_ = await permissionManager.checkPermission()
}
}
}

View file

@ -273,17 +273,11 @@ private struct RequestPermissionsPageView: View {
}
.font(.body)
} else {
Button("Request Permission") {
Button("Grant Permission") {
permissionManager.requestPermission()
}
.buttonStyle(.borderedProminent)
.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)
.padding()
.task {
await permissionManager.checkPermission()
_ = await permissionManager.checkPermission()
}
}
}

View file

@ -1,4 +1,5 @@
@preconcurrency import AppKit
import ApplicationServices
import Foundation
import SwiftUI
@ -11,23 +12,43 @@ import SwiftUI
enum SettingsOpener {
/// SwiftUI's hardcoded settings window identifier
private static let settingsWindowIdentifier = "com_apple_SwiftUI_Settings_window"
private static var windowObserver: NSObjectProtocol?
/// 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)
static func openSettings() {
// First try to open via menu item
let openedViaMenu = openSettingsViaMenuItem()
// Store the current dock visibility preference
let showInDock = UserDefaults.standard.bool(forKey: "showInDock")
if !openedViaMenu {
// Fallback to notification approach
NotificationCenter.default.post(name: .openSettingsRequest, object: nil)
// Temporarily show dock icon to ensure settings window can be brought to front
if !showInDock {
NSApp.setActivationPolicy(.regular)
}
// Use AppleScript to ensure the window is brought to front
// Simple activation and window opening
Task { @MainActor in
// Give time for window creation
try? await Task.sleep(for: .milliseconds(200))
bringSettingsToFrontWithAppleScript()
// Small delay to ensure dock icon is visible
try? await Task.sleep(for: .milliseconds(50))
// 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
static func findSettingsWindow() -> NSWindow? {
// Try multiple methods to find the window
@ -181,8 +239,11 @@ enum SettingsOpener {
/// Focuses the settings window without level manipulation
static func focusSettingsWindow() {
// Use AppleScript for most reliable focusing
bringSettingsToFrontWithAppleScript()
// With dock icon visible, simple activation is enough
NSApp.activate(ignoringOtherApps: true)
if let settingsWindow = findSettingsWindow() {
settingsWindow.makeKeyAndOrderFront(nil)
}
}
/// Brings a window to front using the most reliable method
@ -212,32 +273,12 @@ enum SettingsOpener {
setupWindowCloseObserver(for: window)
}
/// Uses AppleScript to bring the settings window to front
/// This is a more aggressive approach that works even when other methods fail
/// Simple AppleScript approach to activate the app
/// Now that we show the dock icon, this is much simpler
static func bringSettingsToFrontWithAppleScript() {
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)"
activate
end tell
@ -245,9 +286,9 @@ enum SettingsOpener {
Task { @MainActor in
do {
_ = try await AppleScriptExecutor.shared.executeAsync(script)
_ = try await AppleScriptExecutor.shared.executeAsync(activateScript)
} 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

View file

@ -391,7 +391,9 @@ final class TerminalLauncher {
private func executeAppleScript(_ script: String) throws {
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 {
// Convert AppleScriptError to TerminalLauncherError
throw error.toTerminalLauncherError()