diff --git a/App/Sources/Core/Extensions/Task+timeout.swift b/App/Sources/Core/Extensions/Task+timeout.swift new file mode 100644 index 00000000..e14c8bc8 --- /dev/null +++ b/App/Sources/Core/Extensions/Task+timeout.swift @@ -0,0 +1,26 @@ +import Foundation + +extension Task where Failure == Error { + // Start a new Task with a timeout. If the timeout expires before the operation is + // completed then the task is cancelled and an error is thrown. + init(priority: TaskPriority? = nil, timeout: TimeInterval, operation: @escaping @Sendable () async throws -> Success) { + self = Task(priority: priority) { + try await withThrowingTaskGroup(of: Success.self) { group -> Success in + group.addTask(operation: operation) + group.addTask { + try await _Concurrency.Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + throw TimeoutError() + } + guard let success = try await group.next() else { + throw _Concurrency.CancellationError() + } + group.cancelAll() + return success + } + } + } +} + +private struct TimeoutError: LocalizedError { + var errorDescription: String? = "Task timed out before completion" +} diff --git a/App/Sources/Core/Runners/Applications/ApplicationCommandRunner.swift b/App/Sources/Core/Runners/Applications/ApplicationCommandRunner.swift index 00800021..f9f11f1b 100644 --- a/App/Sources/Core/Runners/Applications/ApplicationCommandRunner.swift +++ b/App/Sources/Core/Runners/Applications/ApplicationCommandRunner.swift @@ -7,6 +7,7 @@ protocol ApplicationCommandRunnerDelegate: AnyObject { final class ApplicationCommandRunner: @unchecked Sendable { private struct Plugins { let activate: ActivateApplicationPlugin + let addToStage: AddToStagePlugin let bringToFront: BringToFrontApplicationPlugin let close: CloseApplicationPlugin let hide: HideApplicationPlugin @@ -26,6 +27,7 @@ final class ApplicationCommandRunner: @unchecked Sendable { self.workspace = workspace self.plugins = Plugins( activate: ActivateApplicationPlugin(), + addToStage: AddToStagePlugin(), bringToFront: BringToFrontApplicationPlugin(scriptCommandRunner), close: CloseApplicationPlugin(workspace: workspace), hide: HideApplicationPlugin(workspace: workspace, userSpace: .shared), @@ -86,7 +88,7 @@ final class ApplicationCommandRunner: @unchecked Sendable { } else { if command.modifiers.contains(.addToStage) { - if try await AddToStagePlugin.execute(command) { + if try await plugins.addToStage.execute(command) { return } } diff --git a/App/Sources/Core/Runners/Applications/Plugins/AddToStagePlugin.swift b/App/Sources/Core/Runners/Applications/Plugins/AddToStagePlugin.swift index 65ea2c3b..d1d8682d 100644 --- a/App/Sources/Core/Runners/Applications/Plugins/AddToStagePlugin.swift +++ b/App/Sources/Core/Runners/Applications/Plugins/AddToStagePlugin.swift @@ -9,49 +9,68 @@ enum AddToStagePluginError: Error { } final class AddToStagePlugin { - static func execute(_ command: ApplicationCommand) async throws -> Bool { + func execute(_ command: ApplicationCommand) async throws -> Bool { var snapshot = await UserSpace.shared.snapshot(resolveUserEnvironment: false, refreshWindows: true) // Check if the application is already running. - if resolveRunningApplication(command.application) == nil { - let configuration = NSWorkspace.OpenConfiguration() - let url = URL(fileURLWithPath: command.application.path) - - _ = try await NSWorkspace.shared.openApplication(at: url, configuration: configuration) - - let frontMostUrl = URL(fileURLWithPath: snapshot.frontMostApplication.asApplication().path) - _ = try await NSWorkspace.shared.openApplication(at: frontMostUrl, configuration: configuration) - snapshot.frontMostApplication.ref.activate(options: .activateIgnoringOtherApps) - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - Task { - try await AddToStagePlugin.execute(command) + if Self.resolveRunningApplication(command.application) == nil { + try await Self.activateTargetApplication(command) + try await Self.activateCurrentApplication(snapshot) + + let result = try await Task(timeout: 5) { + var result: Bool = false + while result == false { + if Self.resolveRunningApplication(command.application) != nil { + result = true + } + try await Task.sleep(for: .milliseconds(100)) } - } + return result + }.value - return true + try await Self.activateCurrentApplication(snapshot) + snapshot = await UserSpace.shared.snapshot(resolveUserEnvironment: false, refreshWindows: true) } - guard let runningApplication = resolveRunningApplication(command.application) else { + guard let runningApplication = Self.resolveRunningApplication(command.application) else { return false } if runningApplication.isHidden { - runningApplication.unhide() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - Task { - try await AddToStagePlugin.execute(command) + _ = runningApplication.unhide() + let result = try await Task(timeout: 1) { + while runningApplication.isHidden { + try await Task.sleep(for: .milliseconds(100)) } - } - return true + return true + }.value + snapshot = await UserSpace.shared.snapshot(resolveUserEnvironment: false, refreshWindows: true) } let app = AppAccessibilityElement(runningApplication.processIdentifier) - guard let axWindow = try app.windows().first(where: { ($0.frame?.height ?? 0) > 20 }), - var window = resolveWindow(withId: axWindow.id, snapshot: snapshot) else { - return false + var axWindow = try app.windows().first(where: { ($0.frame?.height ?? 0) > 20 }) + + if axWindow == nil { + try await Self.activateTargetApplication(command) + try await Self.activateCurrentApplication(snapshot) + let newWindow = try await Task(timeout: 2) { + var window: WindowAccessibilityElement? + while window == nil { + window = try? app.windows().first(where: { ($0.frame?.height ?? 0) > 20 }) + } + return window + }.value + + guard let newWindow else { return false } + + axWindow = newWindow + try await Self.activateCurrentApplication(snapshot) + try await Task.sleep(for: .seconds(1)) + await snapshot = UserSpace.shared.snapshot(resolveUserEnvironment: false, refreshWindows: true) } + guard let axWindow, var window = Self.resolveWindow(withId: axWindow.id, snapshot: snapshot) else { return false } + let isInStage = axWindow.frame == window.rect if isInStage { @@ -60,13 +79,13 @@ final class AddToStagePlugin { let mouseLocation = CGEvent(source: nil)?.location if window.rect.origin.x < 0 { - window = try await findWindowOnLeft(window, axWindow: axWindow, snapshot: &snapshot) + window = try await Self.findWindowOnLeft(window, axWindow: axWindow, snapshot: &snapshot) } else if window.rect.origin.x + window.rect.width > NSScreen.main!.frame.size.width { - window = try await findWindowOnRight(window, axWindow: axWindow, snapshot: &snapshot) + window = try await Self.findWindowOnRight(window, axWindow: axWindow, snapshot: &snapshot) } - performClick(on: window, mouseDown: .leftMouseDown, - mouseUp: .leftMouseUp, withFlags: .maskShift) + Self.performClick(on: window, mouseDown: .leftMouseDown, + mouseUp: .leftMouseUp, withFlags: .maskShift) axWindow.isMinimized = false axWindow.performAction(.raise) @@ -84,6 +103,19 @@ final class AddToStagePlugin { return true } + static func activateCurrentApplication(_ snapshot: UserSpace.Snapshot) async throws { + let configuration = NSWorkspace.OpenConfiguration() + let url = URL(fileURLWithPath: snapshot.frontMostApplication.asApplication().path) + _ = try await NSWorkspace.shared.openApplication(at: url, configuration: configuration) + snapshot.frontMostApplication.ref.activate(options: .activateIgnoringOtherApps) + } + + static func activateTargetApplication(_ command: ApplicationCommand) async throws { + let configuration = NSWorkspace.OpenConfiguration() + let url = URL(fileURLWithPath: command.application.path) + _ = try await NSWorkspace.shared.openApplication(at: url, configuration: configuration) + } + static func performClick(on window: WindowModel, mouseDown: CGEventType, mouseUp: CGEventType, withFlags flags: CGEventFlags?) { let mouseEventDown = CGEvent( mouseEventSource: nil, @@ -156,8 +188,9 @@ final class AddToStagePlugin { } static func resolveRunningApplication(_ application: Application) -> NSRunningApplication? { - NSWorkspace.shared.runningApplications.first(where: { runningApplication in - runningApplication.bundleIdentifier == application.bundleIdentifier + return NSWorkspace.shared.runningApplications.first(where: { runningApplication in + runningApplication.bundleIdentifier == application.bundleIdentifier && + runningApplication.isFinishedLaunching }) } }