From 63de54ed4ad2a0c466c2f05ad882803b01e08da2 Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 7 Jun 2026 16:54:33 +0530 Subject: [PATCH] feat(companion): launch and terminate apps through the automation session --- companion/Sources/AppLifecycle.swift | 51 ++++++++++++++++++++++++++++ companion/Sources/Server.swift | 9 +++++ 2 files changed, 60 insertions(+) create mode 100644 companion/Sources/AppLifecycle.swift diff --git a/companion/Sources/AppLifecycle.swift b/companion/Sources/AppLifecycle.swift new file mode 100644 index 0000000..d7f8770 --- /dev/null +++ b/companion/Sources/AppLifecycle.swift @@ -0,0 +1,51 @@ +import XCTest + +// Launches and terminates apps through the automation session. Lifecycle +// performed outside the session leaves its cached app proxies bound to dead +// processes, after which snapshots hang and typing asserts. +enum AppLifecycle { + enum LifecycleError: Error { + case failed(String) + } + + static func launch(bundleIdentifier: String, foregroundIfRunning: Bool) throws { + try onMainCatching { + let application = XCUIApplication(bundleIdentifier: bundleIdentifier) + if foregroundIfRunning { + // activate starts the app when needed and foregrounds it when + // already running, without a forced relaunch. + application.activate() + } else { + application.launch() + } + } + } + + static func terminate(bundleIdentifier: String) throws { + try onMainCatching { + let application = XCUIApplication(bundleIdentifier: bundleIdentifier) + if application.state == .notRunning { + return + } + application.terminate() + } + } + + // onMainCatching runs automation work on the main thread and converts a + // framework assertion into a thrown error so the server survives it. + private static func onMainCatching(_ work: @escaping () -> Void) throws { + var caughtError: NSError? + var completed = false + let run = { + completed = CompanionRunCatching(work, &caughtError) + } + if Thread.isMainThread { + run() + } else { + DispatchQueue.main.sync(execute: run) + } + if !completed { + throw LifecycleError.failed(caughtError?.localizedDescription ?? "unknown") + } + } +} diff --git a/companion/Sources/Server.swift b/companion/Sources/Server.swift index 754ae04..4075c8e 100644 --- a/companion/Sources/Server.swift +++ b/companion/Sources/Server.swift @@ -103,6 +103,15 @@ final class Server { return ["ok": true] case "gestureProbe": return Gesture.probe() + case "launch": + let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier + let foregroundIfRunning = params["foregroundIfRunning"] as? Bool ?? false + try AppLifecycle.launch(bundleIdentifier: bundleIdentifier, foregroundIfRunning: foregroundIfRunning) + return ["ok": true] + case "terminate": + let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier + try AppLifecycle.terminate(bundleIdentifier: bundleIdentifier) + return ["ok": true] case "typeText": let text = params["text"] as? String ?? "" let replace = params["replace"] as? Bool ?? false