AppCommand.swiftโข26.8 kB
import AppKit
import ApplicationServices
import ArgumentParser
import AXorcist
import Foundation
import PeekabooCore
import PeekabooFoundation
/// Control macOS applications
struct AppCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "app",
abstract: "Control applications - launch, quit, hide, show, and switch between apps",
discussion: """
EXAMPLES:
# Launch an application
peekaboo app launch "Visual Studio Code"
peekaboo app launch --bundle-id com.microsoft.VSCode --wait-until-ready
# Quit applications
peekaboo app quit --app Safari
peekaboo app quit --all --except "Finder,Terminal"
# Hide/show applications
peekaboo app hide --app Slack
peekaboo app unhide --app Slack
# Switch between applications
peekaboo app switch --to Terminal
peekaboo app switch --cycle # Cmd+Tab equivalent
# Relaunch applications
peekaboo app relaunch Safari
peekaboo app relaunch "Visual Studio Code" --wait 3 --wait-until-ready
""",
subcommands: [
LaunchSubcommand.self,
QuitSubcommand.self,
RelaunchSubcommand.self,
HideSubcommand.self,
UnhideSubcommand.self,
SwitchSubcommand.self,
ListSubcommand.self,
]
)
// MARK: - Launch Application
struct LaunchSubcommand: AsyncParsableCommand, ErrorHandlingCommand, OutputFormattable {
static let configuration = CommandConfiguration(
commandName: "launch",
abstract: "Launch an application"
)
@Argument(help: "Application name or path")
var app: String
@Option(help: "Launch by bundle identifier instead of name")
var bundleId: String?
@Flag(help: "Wait for the application to be ready")
var waitUntilReady = false
@Flag(help: "Output in JSON format")
var jsonOutput = false
func run() async throws {
Logger.shared.setJsonOutputMode(self.jsonOutput)
Logger.shared.verbose("Launching application: \(self.app)")
do {
let launchedApp: NSRunningApplication
if let bundleId {
// Launch by bundle ID
guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) else {
throw NotFoundError.application("Bundle ID: \(bundleId)")
}
launchedApp = try await self.launchApplication(at: url, name: bundleId)
} else {
// Try to find app by name
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: app) {
// It's actually a bundle ID
launchedApp = try await self.launchApplication(at: url, name: self.app)
} else if let url = findApplicationByName(app) {
// Found by name
launchedApp = try await self.launchApplication(at: url, name: self.app)
} else if self.app.contains("/") {
// It's a path
let url = URL(fileURLWithPath: app)
launchedApp = try await self.launchApplication(at: url, name: self.app)
} else {
throw NotFoundError.application(self.app)
}
}
// Wait until ready if requested
if self.waitUntilReady {
try await self.waitForApplicationReady(launchedApp)
}
struct LaunchResult: Codable {
let action: String
let app_name: String
let bundle_id: String
let pid: Int32
let is_ready: Bool
}
let data = LaunchResult(
action: "launch",
app_name: launchedApp.localizedName ?? self.app,
bundle_id: launchedApp.bundleIdentifier ?? "unknown",
pid: launchedApp.processIdentifier,
is_ready: launchedApp.isFinishedLaunching
)
output(data) {
print("โ Launched \(launchedApp.localizedName ?? self.app) (PID: \(launchedApp.processIdentifier))")
}
} catch {
handleError(error)
throw ExitCode(1)
}
}
private func findApplicationByName(_ name: String) -> URL? {
_ = NSWorkspace.shared
// Check common application directories
let searchPaths = [
"/Applications",
"/System/Applications",
"~/Applications",
"/Applications/Utilities"
].map { NSString(string: $0).expandingTildeInPath }
for path in searchPaths {
let appPath = "\(path)/\(name).app"
if FileManager.default.fileExists(atPath: appPath) {
return URL(fileURLWithPath: appPath)
}
}
return nil
}
private func launchApplication(at url: URL, name: String) async throws -> NSRunningApplication {
let configuration = NSWorkspace.OpenConfiguration()
configuration.activates = true
do {
let app = try await NSWorkspace.shared.openApplication(at: url, configuration: configuration)
return app
} catch {
throw PeekabooError.commandFailed("Failed to launch \(name): \(error.localizedDescription)")
}
}
private func waitForApplicationReady(_ app: NSRunningApplication, timeout: TimeInterval = 10) async throws {
let startTime = Date()
while !app.isFinishedLaunching {
if Date().timeIntervalSince(startTime) > timeout {
throw PeekabooError.timeout("Application did not become ready within \(Int(timeout)) seconds")
}
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 second
}
}
}
// MARK: - Quit Application
struct QuitSubcommand: AsyncParsableCommand, ErrorHandlingCommand, OutputFormattable, ApplicationResolvable,
ApplicationResolver {
static let configuration = CommandConfiguration(
commandName: "quit",
abstract: "Quit one or more applications"
)
@Option(help: "Application to quit")
var app: String?
@Option(name: .long, help: "Target application by process ID")
var pid: Int32?
@Flag(help: "Quit all applications")
var all = false
@Option(help: "Comma-separated list of apps to exclude when using --all")
var except: String?
@Flag(help: "Force quit (doesn't save changes)")
var force = false
@Flag(help: "Output in JSON format")
var jsonOutput = false
func run() async throws {
Logger.shared.setJsonOutputMode(self.jsonOutput)
do {
var quitApps: [(String, NSRunningApplication)] = []
if self.all {
// Get all apps except system/excluded ones
let excluded = Set((except ?? "").split(separator: ",")
.map { String($0).trimmingCharacters(in: .whitespaces) }
)
let systemApps = Set(["Finder", "Dock", "SystemUIServer", "WindowServer"])
let runningApps = NSWorkspace.shared.runningApplications
for runningApp in runningApps {
guard let name = runningApp.localizedName,
runningApp.activationPolicy == .regular,
!systemApps.contains(name),
!excluded.contains(name) else { continue }
quitApps.append((name, runningApp))
}
} else if let appName = app {
// Find specific app
let appInfo = try await resolveApplication(appName)
let runningApps = NSWorkspace.shared.runningApplications
if let runningApp = runningApps
.first(where: { $0.processIdentifier == appInfo.processIdentifier }) {
quitApps.append((appInfo.name, runningApp))
} else {
throw NotFoundError.application(appName)
}
} else {
throw ValidationError("Either --app or --all must be specified")
}
// Quit the apps
struct AppQuitInfo: Codable {
let app_name: String
let pid: Int32
let success: Bool
}
var results: [AppQuitInfo] = []
for (name, runningApp) in quitApps {
let success = self.force ? runningApp.forceTerminate() : runningApp.terminate()
results.append(AppQuitInfo(
app_name: name,
pid: runningApp.processIdentifier,
success: success
))
// Log additional debug info when quit fails
if !success && !self.jsonOutput {
// Check if app might be in a modal state or have unsaved changes
if !self.force {
Logger.shared
.debug(
"Quit failed for \(name) (PID: \(runningApp.processIdentifier)). The app may have unsaved changes or be showing a dialog. Try --force to force quit."
)
} else {
Logger.shared
.debug(
"Force quit failed for \(name) (PID: \(runningApp.processIdentifier)). The app may be unresponsive or protected."
)
}
}
}
struct QuitResult: Codable {
let action: String
let force: Bool
let results: [AppQuitInfo]
}
let data = QuitResult(
action: "quit",
force: force,
results: results
)
output(data) {
for result in results {
if result.success {
print("โ Quit \(result.app_name)")
} else {
print("โ Failed to quit \(result.app_name) (PID: \(result.pid))")
if !self.force {
print(
" ๐ก Tip: The app may have unsaved changes or be showing a dialog. Try --force to force quit."
)
}
}
}
}
} catch {
handleError(error)
throw ExitCode(1)
}
}
}
// MARK: - Hide Application
struct HideSubcommand: AsyncParsableCommand, ErrorHandlingCommand, OutputFormattable,
ApplicationResolvablePositional, ApplicationResolver {
static let configuration = CommandConfiguration(
commandName: "hide",
abstract: "Hide an application"
)
@Option(help: "Application to hide")
var app: String
@Option(name: .long, help: "Target application by process ID")
var pid: Int32?
@Flag(help: "Output in JSON format")
var jsonOutput = false
func run() async throws {
Logger.shared.setJsonOutputMode(self.jsonOutput)
do {
let appIdentifier = try self.resolveApplicationIdentifier()
let appInfo = try await resolveApplication(appIdentifier)
await MainActor.run {
let element = Element(AXUIElementCreateApplication(appInfo.processIdentifier))
_ = element.hideApplication()
}
let data = [
"action": "hide",
"app_name": appInfo.name,
"bundle_id": appInfo.bundleIdentifier ?? "unknown"
]
output(data) {
print("โ Hidden \(appInfo.name)")
}
} catch {
handleError(error)
throw ExitCode(1)
}
}
}
// MARK: - Unhide Application
struct UnhideSubcommand: AsyncParsableCommand, ErrorHandlingCommand, OutputFormattable,
ApplicationResolvablePositional, ApplicationResolver {
static let configuration = CommandConfiguration(
commandName: "unhide",
abstract: "Show a hidden application"
)
@Option(help: "Application to unhide")
var app: String
@Option(name: .long, help: "Target application by process ID")
var pid: Int32?
@Flag(help: "Bring to front after unhiding")
var activate = false
@Flag(help: "Output in JSON format")
var jsonOutput = false
func run() async throws {
Logger.shared.setJsonOutputMode(self.jsonOutput)
do {
let appIdentifier = try self.resolveApplicationIdentifier()
let appInfo = try await resolveApplication(appIdentifier)
await MainActor.run {
let element = Element(AXUIElementCreateApplication(appInfo.processIdentifier))
_ = element.unhideApplication()
}
// Activate if requested
if self.activate {
let runningApps = NSWorkspace.shared.runningApplications
if let runningApp = runningApps
.first(where: { $0.processIdentifier == appInfo.processIdentifier }) {
runningApp.activate()
}
}
struct UnhideResult: Codable {
let action: String
let app_name: String
let bundle_id: String
let activated: Bool
}
let data = UnhideResult(
action: "unhide",
app_name: appInfo.name,
bundle_id: appInfo.bundleIdentifier ?? "unknown",
activated: self.activate
)
output(data) {
print("โ Shown \(appInfo.name)")
}
} catch {
handleError(error)
throw ExitCode(1)
}
}
}
// MARK: - Switch Application
struct SwitchSubcommand: AsyncParsableCommand, ErrorHandlingCommand, OutputFormattable, ApplicationResolver {
static let configuration = CommandConfiguration(
commandName: "switch",
abstract: "Switch to another application"
)
@Option(help: "Switch to this application")
var to: String?
@Flag(help: "Cycle to next app (Cmd+Tab)")
var cycle = false
@Flag(help: "Output in JSON format")
var jsonOutput = false
func run() async throws {
Logger.shared.setJsonOutputMode(self.jsonOutput)
do {
if self.cycle {
// Simulate Cmd+Tab
let source = CGEventSource(stateID: .hidSystemState)
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x30, keyDown: true) // Tab
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x30, keyDown: false)
keyDown?.flags = .maskCommand
keyUp?.flags = .maskCommand
keyDown?.post(tap: .cghidEventTap)
keyUp?.post(tap: .cghidEventTap)
struct CycleResult: Codable {
let action: String
let success: Bool
}
let data = CycleResult(action: "cycle", success: true)
output(data) {
print("โ Cycled to next application")
}
} else if let targetApp = to {
let appInfo = try await resolveApplication(targetApp)
// Find and activate the app
let runningApps = NSWorkspace.shared.runningApplications
guard let runningApp = runningApps
.first(where: { $0.processIdentifier == appInfo.processIdentifier }) else {
throw NotFoundError.application(targetApp)
}
let success = runningApp.activate()
struct SwitchResult: Codable {
let action: String
let app_name: String
let bundle_id: String
let success: Bool
}
let data = SwitchResult(
action: "switch",
app_name: appInfo.name,
bundle_id: appInfo.bundleIdentifier ?? "unknown",
success: success
)
output(data) {
print("โ Switched to \(appInfo.name)")
}
} else {
throw ValidationError("Either --to or --cycle must be specified")
}
} catch {
handleError(error)
throw ExitCode(1)
}
}
}
// MARK: - List Applications
struct ListSubcommand: AsyncParsableCommand, ErrorHandlingCommand, OutputFormattable {
static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List running applications"
)
@Flag(help: "Include hidden apps")
var includeHidden = false
@Flag(help: "Include background apps")
var includeBackground = false
@Flag(help: "Output in JSON format")
var jsonOutput = false
func run() async throws {
Logger.shared.setJsonOutputMode(self.jsonOutput)
do {
let appsOutput = try await PeekabooServices.shared.applications.listApplications()
// Filter based on flags
let filtered = appsOutput.data.applications.filter { app in
if !self.includeHidden && app.isHidden { return false }
if !self.includeBackground && app.name.isEmpty { return false }
return true
}
struct AppInfo: Codable {
let name: String
let bundle_id: String
let pid: Int32
let is_active: Bool
let is_hidden: Bool
}
struct ListResult: Codable {
let count: Int
let apps: [AppInfo]
}
let data = ListResult(
count: filtered.count,
apps: filtered.map { app in
AppInfo(
name: app.name,
bundle_id: app.bundleIdentifier ?? "unknown",
pid: app.processIdentifier,
is_active: app.isActive,
is_hidden: app.isHidden
)
}
)
output(data) {
print("Running Applications (\(filtered.count)):")
for app in filtered {
let status = app.isActive ? " [active]" : app.isHidden ? " [hidden]" : ""
print(" โข \(app.name)\(status)")
print(" Bundle: \(app.bundleIdentifier ?? "unknown")")
print(" PID: \(app.processIdentifier)")
}
}
} catch {
handleError(error)
throw ExitCode(1)
}
}
}
// MARK: - Relaunch Application
struct RelaunchSubcommand: AsyncParsableCommand, ErrorHandlingCommand, OutputFormattable,
ApplicationResolvablePositional, ApplicationResolver {
static let configuration = CommandConfiguration(
commandName: "relaunch",
abstract: "Quit and relaunch an application"
)
@Argument(help: "Application name, bundle ID, or 'PID:12345' for process ID")
var app: String
@Option(name: .long, help: "Target application by process ID")
var pid: Int32?
@Option(help: "Wait time in seconds between quit and launch (default: 2)")
var wait: TimeInterval = 2.0
@Flag(help: "Force quit (doesn't save changes)")
var force = false
@Flag(help: "Wait until the app is ready after launch")
var waitUntilReady = false
@Flag(help: "Output in JSON format")
var jsonOutput = false
func run() async throws {
Logger.shared.setJsonOutputMode(self.jsonOutput)
do {
// Find the application first
let appIdentifier = try self.resolveApplicationIdentifier()
let appInfo = try await resolveApplication(appIdentifier)
let originalPID = appInfo.processIdentifier
// Step 1: Quit the app
let runningApps = NSWorkspace.shared.runningApplications
guard let runningApp = runningApps.first(where: { $0.processIdentifier == originalPID }) else {
throw NotFoundError.application(self.app)
}
let quitSuccess = self.force ? runningApp.forceTerminate() : runningApp.terminate()
if !quitSuccess {
throw PeekabooError
.commandFailed(
"Failed to quit \(appInfo.name) (PID: \(originalPID)). The app may have unsaved changes."
)
}
// Wait for the app to actually terminate
var terminateWaitTime = 0.0
while runningApp.isTerminated == false && terminateWaitTime < 5.0 {
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
terminateWaitTime += 0.1
}
if !runningApp.isTerminated {
throw PeekabooError.timeout("App \(appInfo.name) did not terminate within 5 seconds")
}
// Step 2: Wait the specified duration
if self.wait > 0 {
try await Task.sleep(nanoseconds: UInt64(self.wait * 1_000_000_000))
}
// Step 3: Launch the app
let workspace = NSWorkspace.shared
let newApp: NSRunningApplication?
if let bundleId = appInfo.bundleIdentifier {
let config = NSWorkspace.OpenConfiguration()
config.activates = true
if let url = workspace.urlForApplication(withBundleIdentifier: bundleId) {
newApp = try await workspace.openApplication(at: url, configuration: config)
} else {
throw NotFoundError.application("Could not find application URL for bundle ID: \(bundleId)")
}
} else if let bundlePath = appInfo.bundlePath {
let url = URL(fileURLWithPath: bundlePath)
let config = NSWorkspace.OpenConfiguration()
config.activates = true
newApp = try await workspace.openApplication(at: url, configuration: config)
} else {
throw PeekabooError.commandFailed("No bundle ID or path available to relaunch \(appInfo.name)")
}
guard let launchedApp = newApp else {
throw PeekabooError.commandFailed("Failed to launch application")
}
// Wait until ready if requested
if self.waitUntilReady {
var readyWaitTime = 0.0
while !launchedApp.isFinishedLaunching && readyWaitTime < 10.0 {
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
readyWaitTime += 0.1
}
}
struct RelaunchResult: Codable {
let action: String
let app_name: String
let old_pid: Int32
let new_pid: Int32
let bundle_id: String?
let quit_forced: Bool
let wait_time: TimeInterval
let launch_success: Bool
}
let data = RelaunchResult(
action: "relaunch",
app_name: appInfo.name,
old_pid: originalPID,
new_pid: launchedApp.processIdentifier,
bundle_id: appInfo.bundleIdentifier,
quit_forced: self.force,
wait_time: self.wait,
launch_success: launchedApp.isFinishedLaunching || !self.waitUntilReady
)
output(data) {
print("โ Relaunched \(appInfo.name)")
print(" Old PID: \(originalPID) โ New PID: \(launchedApp.processIdentifier)")
if self.waitUntilReady {
print(" Status: \(launchedApp.isFinishedLaunching ? "Ready" : "Launching...")")
}
}
} catch {
handleError(error)
throw ExitCode(1)
}
}
}
}