From 6a0fe1a8afbce1ce707333880a9f2aef239eacce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 17 Jun 2025 18:16:45 +0200 Subject: [PATCH] Improve Applescript logic --- .../Core/Services/AppleScriptExecutor.swift | 19 ++- .../AppleScriptPermissionManager.swift | 29 +++- .../Settings/DashboardSettingsView.swift | 15 +- .../Presentation/Views/WelcomeView.swift | 10 +- VibeTunnel/Utilities/SettingsOpener.swift | 147 +++++++++++++----- VibeTunnel/Utilities/TerminalLauncher.swift | 4 +- 6 files changed, 155 insertions(+), 69 deletions(-) diff --git a/VibeTunnel/Core/Services/AppleScriptExecutor.swift b/VibeTunnel/Core/Services/AppleScriptExecutor.swift index e2d48882..1728c4eb 100644 --- a/VibeTunnel/Core/Services/AppleScriptExecutor.swift +++ b/VibeTunnel/Core/Services/AppleScriptExecutor.swift @@ -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) { diff --git a/VibeTunnel/Core/Services/AppleScriptPermissionManager.swift b/VibeTunnel/Core/Services/AppleScriptPermissionManager.swift index f54fe6ce..a52b2526 100644 --- a/VibeTunnel/Core/Services/AppleScriptPermissionManager.swift +++ b/VibeTunnel/Core/Services/AppleScriptPermissionManager.swift @@ -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 diff --git a/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift b/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift index 9601afed..36227f61 100644 --- a/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift +++ b/VibeTunnel/Presentation/Views/Settings/DashboardSettingsView.swift @@ -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() } } } diff --git a/VibeTunnel/Presentation/Views/WelcomeView.swift b/VibeTunnel/Presentation/Views/WelcomeView.swift index 311e743d..5bf68678 100644 --- a/VibeTunnel/Presentation/Views/WelcomeView.swift +++ b/VibeTunnel/Presentation/Views/WelcomeView.swift @@ -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() } } } diff --git a/VibeTunnel/Utilities/SettingsOpener.swift b/VibeTunnel/Utilities/SettingsOpener.swift index a97c7ac6..c145be7c 100644 --- a/VibeTunnel/Utilities/SettingsOpener.swift +++ b/VibeTunnel/Utilities/SettingsOpener.swift @@ -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 diff --git a/VibeTunnel/Utilities/TerminalLauncher.swift b/VibeTunnel/Utilities/TerminalLauncher.swift index e1353be3..a54eebc0 100644 --- a/VibeTunnel/Utilities/TerminalLauncher.swift +++ b/VibeTunnel/Utilities/TerminalLauncher.swift @@ -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()