import AppKit
import Commander
import Foundation
import PeekabooCore
import PeekabooFoundation
@available(macOS 14.0, *)
@MainActor
struct OpenCommand: ParsableCommand, OutputFormattable, ErrorHandlingCommand, RuntimeOptionsConfigurable {
@MainActor
static var launcher: any ApplicationLaunching = ApplicationLaunchEnvironment.launcher
@MainActor
static var resolver: any ApplicationURLResolving = ApplicationURLResolverEnvironment.resolver
nonisolated(unsafe) static var commandDescription: CommandDescription {
MainActorCommandDescription.describe {
CommandDescription(
commandName: "open",
abstract: "Open a URL or file with its default (or specified) application",
discussion: """
Mirrors macOS `open` but adds Peekaboo’s quality-of-life features:
- `--app` / `--bundle-id` to force a handler
- `--wait-until-ready` to block until the app reports it has finished launching
- `--no-focus` to keep the handler in the background
- `--json-output` for structured scripting
EXAMPLES:
peekaboo open https://example.com --json-output
peekaboo open ~/Documents/report.pdf --app "Preview"
peekaboo open myfile.txt --bundle-id com.apple.TextEdit --wait-until-ready
peekaboo open ~/Desktop --app Finder --no-focus
""",
showHelpOnEmptyInvocation: true
)
}
}
@Argument(help: "URL or file path to open")
var target: String
@Option(help: "Explicit application (name or path) to handle the target")
var app: String?
@Option(help: "Bundle identifier of the application to handle the target")
var bundleId: String?
@Flag(help: "Wait until the handling application finishes launching")
var waitUntilReady = false
@Flag(name: .customLong("no-focus"), help: "Do not bring the handling application to the foreground")
var noFocus = false
@RuntimeStorage private var runtime: CommandRuntime?
var runtimeOptions = CommandRuntimeOptions()
private var resolvedRuntime: CommandRuntime {
guard let runtime else {
preconditionFailure("CommandRuntime must be configured before accessing runtime resources")
}
return runtime
}
private var logger: Logger { self.resolvedRuntime.logger }
var outputLogger: Logger { self.logger }
var jsonOutput: Bool { self.runtime?.configuration.jsonOutput ?? self.runtimeOptions.jsonOutput }
private var shouldFocus: Bool { !self.noFocus }
@MainActor
mutating func run(using runtime: CommandRuntime) async throws {
self.prepare(using: runtime)
do {
let targetURL = try Self.resolveTarget(self.target)
let handlerURL = try self.resolveHandlerApplication()
let appInstance = try await self.openTarget(targetURL: targetURL, handlerURL: handlerURL)
try await self.waitIfNeeded(for: appInstance)
let didFocus = self.activateIfNeeded(appInstance)
self.renderSuccess(app: appInstance, targetURL: targetURL, didFocus: didFocus)
} catch {
self.handleError(error)
throw ExitCode.failure
}
}
private mutating func prepare(using runtime: CommandRuntime) {
self.runtime = runtime
self.logger.setJsonOutputMode(self.jsonOutput)
}
static func resolveTarget(_ target: String, cwd: String = FileManager.default.currentDirectoryPath) throws -> URL {
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw ValidationError("Target must not be empty")
}
if let url = URL(string: trimmed), let scheme = url.scheme, !scheme.isEmpty {
return url
}
let expanded = NSString(string: trimmed).expandingTildeInPath
let absolutePath: String = if expanded.hasPrefix("/") {
expanded
} else {
NSString(string: cwd).appendingPathComponent(expanded)
}
return URL(fileURLWithPath: absolutePath)
}
private func resolveHandlerApplication() throws -> URL? {
if let bundleId {
return try Self.resolver.resolveBundleIdentifier(bundleId)
}
if let app {
return try Self.resolver.resolveApplication(appIdentifier: app, bundleId: nil)
}
return nil
}
private func openTarget(targetURL: URL, handlerURL: URL?) async throws -> any RunningApplicationHandle {
try await Self.launcher.openTarget(targetURL, handlerURL: handlerURL, activates: self.shouldFocus)
}
private func waitIfNeeded(for app: any RunningApplicationHandle) async throws {
guard self.waitUntilReady else { return }
try await self.waitForApplicationReady(app)
}
private func activateIfNeeded(_ app: any RunningApplicationHandle) -> Bool {
guard self.shouldFocus else { return false }
if app.isActive {
return true
}
let activated = app.activate(options: [])
if !activated {
self.logger.warn("Open succeeded but failed to focus \(app.localizedName ?? "application")")
}
return activated
}
private func renderSuccess(app: any RunningApplicationHandle, targetURL: URL, didFocus: Bool) {
let result = OpenResult(
success: true,
action: "open",
target: self.target,
resolved_target: self.normalizedTargetString(for: targetURL),
handler_app: app.localizedName ?? app.bundleIdentifier ?? "unknown",
bundle_id: app.bundleIdentifier,
pid: app.processIdentifier,
is_ready: app.isFinishedLaunching,
focused: didFocus && self.shouldFocus
)
AutomationEventLogger.log(
.open,
"target=\(result.resolved_target) handler=\(result.handler_app) "
+ "bundle=\(result.bundle_id ?? "unknown") focused=\(result.focused)"
)
output(result) {
let handler = app.localizedName ?? app.bundleIdentifier ?? "application"
print("✅ Opened \(result.resolved_target) with \(handler)")
}
}
private func waitForApplicationReady(_ app: any RunningApplicationHandle, timeout: TimeInterval = 10) async throws {
let start = Date()
while !app.isFinishedLaunching {
if Date().timeIntervalSince(start) > timeout {
throw PeekabooError.timeout("Application did not become ready within \(Int(timeout)) seconds")
}
try await Task.sleep(nanoseconds: 100_000_000)
}
}
private func normalizedTargetString(for url: URL) -> String {
url.isFileURL ? url.path : url.absoluteString
}
}
struct OpenResult: Codable {
let success: Bool
let action: String
let target: String
let resolved_target: String
let handler_app: String
let bundle_id: String?
let pid: Int32
let is_ready: Bool
let focused: Bool
}
@MainActor
extension OpenCommand: AsyncRuntimeCommand {}
@MainActor
extension OpenCommand: CommanderBindableCommand {
mutating func applyCommanderValues(_ values: CommanderBindableValues) throws {
self.target = try values.decodePositional(0, label: "target", as: String.self)
self.app = values.singleOption("app")
self.bundleId = values.singleOption("bundleId")
self.waitUntilReady = values.flag("waitUntilReady")
self.noFocus = values.flag("noFocus")
}
}