mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-15 12:55:52 +00:00
Improve Applescript logic
This commit is contained in:
parent
0e4cacc69d
commit
6a0fe1a8af
6 changed files with 155 additions and 69 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue