diff --git a/companion/Sources/AppLifecycle.swift b/companion/Sources/AppLifecycle.swift index d7f8770..065abc5 100644 --- a/companion/Sources/AppLifecycle.swift +++ b/companion/Sources/AppLifecycle.swift @@ -31,6 +31,29 @@ enum AppLifecycle { } } + // state reports the app's run state in the vocabulary the Go transport maps + // to a process state: foreground, background, or notRunning. On the + // simulator the hybrid never calls it; on device it backs ForegroundApp. + static func state(bundleIdentifier: String) -> String { + var result = "notRunning" + let collect = { + switch XCUIApplication(bundleIdentifier: bundleIdentifier).state { + case .runningForeground: + result = "foreground" + case .runningBackground, .runningBackgroundSuspended: + result = "background" + default: + result = "notRunning" + } + } + if Thread.isMainThread { + collect() + } else { + DispatchQueue.main.sync(execute: collect) + } + return result + } + // 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 { diff --git a/companion/Sources/Server.swift b/companion/Sources/Server.swift index 1d67f94..0f84cc4 100644 --- a/companion/Sources/Server.swift +++ b/companion/Sources/Server.swift @@ -122,6 +122,19 @@ final class Server { let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier try TextInput.type(text: text, replace: replace, bundleIdentifier: bundleIdentifier) return ["ok": true] + case "eraseText": + let count = params["count"] as? Int ?? 0 + let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier + try TextInput.erase(count: count, bundleIdentifier: bundleIdentifier) + return ["ok": true] + case "pressKey": + let key = params["key"] as? String ?? "" + let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier + try TextInput.pressKey(key: key, bundleIdentifier: bundleIdentifier) + return ["ok": true] + case "appState": + let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier + return ["state": AppLifecycle.state(bundleIdentifier: bundleIdentifier)] case "screenshot": return try screenshot() default: diff --git a/companion/Sources/TextInput.swift b/companion/Sources/TextInput.swift index 0efcf3f..2641fae 100644 --- a/companion/Sources/TextInput.swift +++ b/companion/Sources/TextInput.swift @@ -37,6 +37,40 @@ enum TextInput { } } + // erase deletes count characters from the focused field by typing that many + // delete keys. Used on device, where EraseText routes to the runner instead + // of the legacy companion's HID stream. + static func erase(count: Int, bundleIdentifier: String) throws { + guard count > 0 else { return } + let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count) + try typeOnFocus(deletes, bundleIdentifier: bundleIdentifier) + } + + // pressKey types a single logical key into the focused field. Only return is + // supported, matching the simulator companion's key surface. + static func pressKey(key: String, bundleIdentifier: String) throws { + switch key { + case "return", "enter", "Return", "Enter": + try typeOnFocus(XCUIKeyboardKey.return.rawValue, bundleIdentifier: bundleIdentifier) + default: + throw TextInputError.typingFailed("unsupported key \(key)") + } + } + + private static func typeOnFocus(_ payload: String, bundleIdentifier: String) throws { + let application = XCUIApplication(bundleIdentifier: bundleIdentifier) + var caughtError: NSError? + var completed = false + runOnMain { + completed = CompanionRunCatching({ + application.typeText(payload) + }, &caughtError) + } + if !completed { + throw TextInputError.typingFailed(caughtError?.localizedDescription ?? "unknown") + } + } + private static func runOnMain(_ work: () -> Void) { if Thread.isMainThread { work()