Real Apple TV (tvOS) automation: dedicated TvosRobot + focus-by-identity tool - #386
Real Apple TV (tvOS) automation: dedicated TvosRobot + focus-by-identity tool#386setoelkahfi wants to merge 3 commits into
Conversation
WalkthroughThe change adds CoreDevice discovery and fallback operations for iOS and tvOS devices. Device metadata now includes platform information, and Apple TV devices route to 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
tvOS simulators now surface through the MCP server. - Accept `tvos` in the platform unions and validation in the mobilecli wrapper. - Drop the hard `ios` filter on simulator discovery so tvOS simulators are returned, and report each device's real platform to analytics. - List the tvOS Siri Remote buttons (UP, DOWN, LEFT, RIGHT, SELECT, MENU, PLAY_PAUSE) in the press-button tool description. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Detect physical Apple TV units by product type and report platform tvos in mobile_list_available_devices instead of hardcoding ios. Route real tvOS devices through mobilecli (which installs and drives the tvOS runner) while iPhones and iPads keep using the WebDriverAgent-based IosRobot. Add a unit test for platform detection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a dedicated TvosRobot (mobilecli/DeviceKit-backed) for real Apple TV, keeping IosRobot/WebDriverAgent unchanged for iPhone/iPad (D2). Route real Apple TVs (platform tvos) to TvosRobot via getRobotFromDevice; iOS real devices continue to IosRobot. - TvosRobot: Siri Remote buttons (UP/DOWN/LEFT/RIGHT/SELECT/MENU/ PLAY_PAUSE); app/screenshot/view-tree/screen-size delegated to mobilecli; explicit unsupported-on-tvOS ActionableErrors for tap/doubleTap/longPress/swipe/orientation/text; best-effort openUrl - New mobile_focus_by_identifier MCP tool + Mobilecli.focusByIdentifier shelling to mobilecli io focus (accessibility-identity focus) - IosManager.listDevices retains platform so routing can distinguish Apple TV; extracted and tested robotForIosDevice helper - Device-free unit tests for routing, button mapping, unsupported ops, and focus Implements: SPECS/quality/real-tvos-device-support/SPEC.md (Milestone 5, 6) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
c29090a to
7e7c0f2
Compare
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (1)
src/server.ts (1)
185-194: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winThe tvOS branch repeats the decision that
robotForIosDevicealready makes.Lines 188-191 construct
TvosRobotdirectly, and line 194 callsrobotForIosDevice, which performs the sameplatform === "tvos"test. Keep one selection path and derive the telemetry value from the platform.♻️ Proposed simplification
if (iosDevice) { - // Real Apple TVs are enumerated over the same connection as iPhones but - // require a dedicated Siri Remote / DeviceKit-backed robot (D2). Route - // them before the platform-blind iPhone branch. - if (iosDevice.platform === "tvos") { - posthog("get_robot", { "DevicePlatform": "tvos", "DeviceType": "real" }).then(); - return new TvosRobot(deviceId); - } - - posthog("get_robot", { "DevicePlatform": "ios", "DeviceType": "real" }).then(); + // Real Apple TVs are enumerated over the same connection as iPhones but + // require a dedicated Siri Remote / DeviceKit-backed robot (D2). + posthog("get_robot", { "DevicePlatform": iosDevice.platform, "DeviceType": "real" }).then(); return robotForIosDevice(deviceId, iosDevice.platform); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/server.ts` around lines 185 - 194, Consolidate robot selection in the iOS-device branch by removing the separate tvOS condition and routing all platforms through robotForIosDevice. Derive the PostHog DevicePlatform value from iosDevice.platform while preserving DeviceType "real", then return the shared robot-selection result.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/ios.ts`:
- Around line 449-459: Update IosManager.listDevicesWithDetails, which is
reached by listDevices and getRobotFromDevice on every tool call, to avoid
repeating the full iOS and devicectl discovery sequence; add a short-lived cache
for the merged device list and reuse it until its TTL expires, refreshing only
after expiration.
- Around line 194-206: Add a shared helper in src/ios.ts that converts
CoreDeviceDetailsOutput into InfoCommandOutput, deriving DeviceClass from the
hardware product type and accepting a fallback device name. Replace the inline
mapping in IosRobot.getCoreDeviceInfo at src/ios.ts:194-206 and the fallback
mapping in IosManager.getDeviceInfo at src/ios.ts:427-447 with this helper,
passing deviceId as the fallback name at both sites.
- Around line 384-400: Update listGoIosDevicesWithDetails to isolate failures
from execFileSync and JSON.parse: catch errors from the go-ios list command or
response parsing, return an empty device list, and preserve the existing mapping
for successful responses. Ensure listDevicesWithDetails can continue to
listCoreDeviceDevicesWithDetails when go-ios is installed but unavailable or
returns invalid output.
- Around line 265-281: Update the catch fallback in launchApp so a requested
locale is never silently ignored: when locale is provided and the go-ios launch
fails, throw an ActionableError stating that locale-based launching is
unavailable; retain the existing devicectl fallback only when no locale was
requested.
In `@src/tvos.ts`:
- Around line 116-135: Update TvosRobot.launchApp, terminateApp, installApp, and
uninstallApp to validate their package or bundle identifier arguments with the
shared validators from src/utils.ts before constructing the mobilecli command;
also validate launchApp’s optional locale with validateLocale. Reuse the same
validation flow as IosRobot so values beginning with “-” are rejected
consistently.
- Around line 86-93: Validate the response status before dereferencing payload
data in getScreenSize, getInstalledApps, and dumpUI, and surface an actionable
error for non-success responses instead of allowing a TypeError. In
getScreenSize, remove the zero-size fallback and report the failure when
screenSize is absent; preserve the normal successful response behavior.
---
Nitpick comments:
In `@src/server.ts`:
- Around line 185-194: Consolidate robot selection in the iOS-device branch by
removing the separate tvOS condition and routing all platforms through
robotForIosDevice. Derive the PostHog DevicePlatform value from
iosDevice.platform while preserving DeviceType "real", then return the shared
robot-selection result.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 9d5ca8a2-ab71-43cb-94a0-90d0f7199024
📒 Files selected for processing (7)
src/ios.tssrc/mobilecli.tssrc/server.tssrc/tvos.tstest/ios-coredevice.tstest/ios-platform.tstest/tvos.ts
| private getCoreDeviceInfo(): InfoCommandOutput { | ||
| const output = this.devicectl("device", "info", "details", "--device", this.deviceId); | ||
| const json = JSON.parse(output) as CoreDeviceDetailsOutput; | ||
| return { | ||
| DeviceClass: "iPhone", | ||
| DeviceName: json.result?.deviceProperties?.name ?? this.deviceId, | ||
| ProductName: json.result?.hardwareProperties?.productType ?? "", | ||
| ProductType: json.result?.hardwareProperties?.productType ?? "", | ||
| ProductVersion: json.result?.deviceProperties?.osVersionNumber ?? "", | ||
| PhoneNumber: "", | ||
| TimeZone: "", | ||
| }; | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Duplicated CoreDevice-to-InfoCommandOutput mapping in src/ios.ts. Both call sites parse the same CoreDeviceDetailsOutput shape and build the same object, and both hardcode DeviceClass: "iPhone", so an Apple TV is reported as an iPhone. The shared root cause is one missing helper.
src/ios.ts#L194-L206: replace the inline mapping inIosRobot.getCoreDeviceInfowith a shared helper that derivesDeviceClassfrom the product type.src/ios.ts#L427-L447: replace the inline mapping in theIosManager.getDeviceInfofallback with the same shared helper, passingdeviceIdas the fallback name.
📍 Affects 1 file
src/ios.ts#L194-L206(this comment)src/ios.ts#L427-L447
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/ios.ts` around lines 194 - 206, Add a shared helper in src/ios.ts that
converts CoreDeviceDetailsOutput into InfoCommandOutput, deriving DeviceClass
from the hardware product type and accepting a fallback device name. Replace the
inline mapping in IosRobot.getCoreDeviceInfo at src/ios.ts:194-206 and the
fallback mapping in IosManager.getDeviceInfo at src/ios.ts:427-447 with this
helper, passing deviceId as the fallback name at both sites.
| public async launchApp(packageName: string, locale?: string): Promise<void> { | ||
| validatePackageName(packageName); | ||
| await this.assertTunnelRunning(); | ||
| const args = ["launch", packageName]; | ||
| if (locale) { | ||
| validateLocale(locale); | ||
| const locales = locale.split(",").map(l => l.trim()); | ||
| args.push("-AppleLanguages", `(${locales.join(", ")})`); | ||
| args.push("-AppleLocale", locales[0]); | ||
| } | ||
| try { | ||
| await this.assertTunnelRunning(); | ||
| const args = ["launch", packageName]; | ||
| if (locale) { | ||
| validateLocale(locale); | ||
| const locales = locale.split(",").map(l => l.trim()); | ||
| args.push("-AppleLanguages", `(${locales.join(", ")})`); | ||
| args.push("-AppleLocale", locales[0]); | ||
| } | ||
|
|
||
| await this.ios(...args); | ||
| await this.ios(...args); | ||
| } catch { | ||
| this.devicectl("device", "process", "launch", "--device", this.deviceId, packageName); | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
The CoreDevice fallback drops the requested locale.
launchApp validates and applies locale on the go-ios path only. If go-ios fails, the devicectl fallback launches the app without any locale arguments. The caller receives a success result while the locale request is ignored. Pass the locale to devicectl if it supports it, or fail with an ActionableError that states locale launch is unavailable.
🔧 Proposed fix: report the unsupported locale instead of ignoring it
} catch {
+ if (locale) {
+ throw new ActionableError("Launching with a locale requires go-ios; the CoreDevice fallback cannot set AppleLanguages/AppleLocale.");
+ }
+
this.devicectl("device", "process", "launch", "--device", this.deviceId, packageName);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public async launchApp(packageName: string, locale?: string): Promise<void> { | |
| validatePackageName(packageName); | |
| await this.assertTunnelRunning(); | |
| const args = ["launch", packageName]; | |
| if (locale) { | |
| validateLocale(locale); | |
| const locales = locale.split(",").map(l => l.trim()); | |
| args.push("-AppleLanguages", `(${locales.join(", ")})`); | |
| args.push("-AppleLocale", locales[0]); | |
| } | |
| try { | |
| await this.assertTunnelRunning(); | |
| const args = ["launch", packageName]; | |
| if (locale) { | |
| validateLocale(locale); | |
| const locales = locale.split(",").map(l => l.trim()); | |
| args.push("-AppleLanguages", `(${locales.join(", ")})`); | |
| args.push("-AppleLocale", locales[0]); | |
| } | |
| await this.ios(...args); | |
| await this.ios(...args); | |
| } catch { | |
| this.devicectl("device", "process", "launch", "--device", this.deviceId, packageName); | |
| } | |
| } | |
| public async launchApp(packageName: string, locale?: string): Promise<void> { | |
| validatePackageName(packageName); | |
| try { | |
| await this.assertTunnelRunning(); | |
| const args = ["launch", packageName]; | |
| if (locale) { | |
| validateLocale(locale); | |
| const locales = locale.split(",").map(l => l.trim()); | |
| args.push("-AppleLanguages", `(${locales.join(", ")})`); | |
| args.push("-AppleLocale", locales[0]); | |
| } | |
| await this.ios(...args); | |
| } catch { | |
| if (locale) { | |
| throw new ActionableError("Launching with a locale requires go-ios; the CoreDevice fallback cannot set AppleLanguages/AppleLocale."); | |
| } | |
| this.devicectl("device", "process", "launch", "--device", this.deviceId, packageName); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/ios.ts` around lines 265 - 281, Update the catch fallback in launchApp so
a requested locale is never silently ignored: when locale is provided and the
go-ios launch fails, throw an ActionableError stating that locale-based
launching is unavailable; retain the existing devicectl fallback only when no
locale was requested.
| private listGoIosDevicesWithDetails(): IosDeviceWithDetails[] { | ||
| if (!this.isGoIosInstalled()) { | ||
| console.error("go-ios is not installed, no physical iOS devices can be detected"); | ||
| return []; | ||
| } | ||
|
|
||
| const output = execFileSync(getGoIosPath(), ["list"]).toString(); | ||
| const json: ListCommandOutput = JSON.parse(output); | ||
| const devices = json.deviceList.map(device => { | ||
| return json.deviceList.map(device => { | ||
| const info = this.getDeviceInfo(device); | ||
| return { | ||
| deviceId: device, | ||
| deviceName: info.DeviceName, | ||
| version: info.ProductVersion, | ||
| platform: platformFromProductType(info.ProductType), | ||
| }; | ||
| }); | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
A go-ios failure discards all CoreDevice devices.
listGoIosDevicesWithDetails runs execFileSync(getGoIosPath(), ["list"]) and JSON.parse without error handling. isGoIosInstalled() only proves the binary answers version. If list fails or returns non-JSON, the exception propagates out of listDevicesWithDetails, so listCoreDeviceDevicesWithDetails results are never used. In getRobotFromDevice (src/server.ts, line 182) that call is not guarded, so the tool fails with a raw error instead of routing the device found by CoreDevice.
🛡️ Proposed fix: isolate the go-ios path
private listGoIosDevicesWithDetails(): IosDeviceWithDetails[] {
if (!this.isGoIosInstalled()) {
return [];
}
- const output = execFileSync(getGoIosPath(), ["list"]).toString();
- const json: ListCommandOutput = JSON.parse(output);
- return json.deviceList.map(device => {
- const info = this.getDeviceInfo(device);
- return {
- deviceId: device,
- deviceName: info.DeviceName,
- version: info.ProductVersion,
- platform: platformFromProductType(info.ProductType),
- };
- });
+ try {
+ const output = execFileSync(getGoIosPath(), ["list"]).toString();
+ const json: ListCommandOutput = JSON.parse(output);
+ return (json.deviceList ?? []).map(device => {
+ const info = this.getDeviceInfo(device);
+ return {
+ deviceId: device,
+ deviceName: info.DeviceName,
+ version: info.ProductVersion,
+ platform: platformFromProductType(info.ProductType),
+ };
+ });
+ } catch (error) {
+ return [];
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private listGoIosDevicesWithDetails(): IosDeviceWithDetails[] { | |
| if (!this.isGoIosInstalled()) { | |
| console.error("go-ios is not installed, no physical iOS devices can be detected"); | |
| return []; | |
| } | |
| const output = execFileSync(getGoIosPath(), ["list"]).toString(); | |
| const json: ListCommandOutput = JSON.parse(output); | |
| const devices = json.deviceList.map(device => { | |
| return json.deviceList.map(device => { | |
| const info = this.getDeviceInfo(device); | |
| return { | |
| deviceId: device, | |
| deviceName: info.DeviceName, | |
| version: info.ProductVersion, | |
| platform: platformFromProductType(info.ProductType), | |
| }; | |
| }); | |
| } | |
| private listGoIosDevicesWithDetails(): IosDeviceWithDetails[] { | |
| if (!this.isGoIosInstalled()) { | |
| return []; | |
| } | |
| try { | |
| const output = execFileSync(getGoIosPath(), ["list"]).toString(); | |
| const json: ListCommandOutput = JSON.parse(output); | |
| return (json.deviceList ?? []).map(device => { | |
| const info = this.getDeviceInfo(device); | |
| return { | |
| deviceId: device, | |
| deviceName: info.DeviceName, | |
| version: info.ProductVersion, | |
| platform: platformFromProductType(info.ProductType), | |
| }; | |
| }); | |
| } catch (error) { | |
| return []; | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/ios.ts` around lines 384 - 400, Update listGoIosDevicesWithDetails to
isolate failures from execFileSync and JSON.parse: catch errors from the go-ios
list command or response parsing, return an empty device list, and preserve the
existing mapping for successful responses. Ensure listDevicesWithDetails can
continue to listCoreDeviceDevicesWithDetails when go-ios is installed but
unavailable or returns invalid output.
| public listDevices(): IosDeviceWithDetails[] { | ||
| return this.listDevicesWithDetails(); | ||
| } | ||
|
|
||
| return devices; | ||
| public listDevicesWithDetails(): IosDeviceWithDetails[] { | ||
| const devices = [ | ||
| ...this.listGoIosDevicesWithDetails(), | ||
| ...this.listCoreDeviceDevicesWithDetails(), | ||
| ]; | ||
| return this.mergeUniqueDevices(devices); | ||
| } |
There was a problem hiding this comment.
🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win
Device discovery now runs on every tool call and adds a devicectl round trip.
getRobotFromDevice in src/server.ts calls IosManager.listDevices() for every MCP tool invocation. listDevicesWithDetails now spawns ios version, ios list, one ios info (with a possible devicectl fallback) per device, and xcrun devicectl list devices. xcrun devicectl list devices frequently takes several seconds because it waits for network device discovery. Every tool call pays that cost.
Cache the device list for a short time-to-live, or resolve the platform only for the requested deviceId.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/ios.ts` around lines 449 - 459, Update IosManager.listDevicesWithDetails,
which is reached by listDevices and getRobotFromDevice on every tool call, to
avoid repeating the full iOS and devicectl discovery sequence; add a short-lived
cache for the merged device list and reuse it until its TTL expires, refreshing
only after expiration.
| public async getScreenSize(): Promise<ScreenSize> { | ||
| const response = JSON.parse(this.runCommand(["device", "info"])) as DeviceInfoResponse; | ||
| if (response.data.device.screenSize) { | ||
| return response.data.device.screenSize; | ||
| } | ||
|
|
||
| return { width: 0, height: 0, scale: 1.0 }; | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Check status before you dereference data, and do not report a fake screen size.
DeviceInfoResponse, InstalledAppsResponse, and DumpUIResponse declare status: "ok" only. If mobilecli returns an error payload, response.data.device (line 88), response.data.map (line 110), and response.data.elements.map (line 173) throw a TypeError. The MCP client then receives Cannot read properties of undefined instead of an actionable message.
getScreenSize also returns { width: 0, height: 0, scale: 1.0 } when screenSize is absent. The tool then reports Screen size is 0x0 pixels and hides the real failure.
🛡️ Proposed fix: validate the response envelope
+interface MobilecliEnvelope {
+ status: "ok" | "error";
+ error?: string;
+}
+
+const parseResponse = <T>(output: string, context: string): T => {
+ const json = JSON.parse(output) as MobilecliEnvelope & { data?: unknown };
+ if (json.status !== "ok" || json.data === undefined) {
+ throw new ActionableError(json.error ?? `${context} failed on tvOS`);
+ }
+
+ return json as unknown as T;
+}; public async getScreenSize(): Promise<ScreenSize> {
- const response = JSON.parse(this.runCommand(["device", "info"])) as DeviceInfoResponse;
- if (response.data.device.screenSize) {
- return response.data.device.screenSize;
- }
-
- return { width: 0, height: 0, scale: 1.0 };
+ const response = parseResponse<DeviceInfoResponse>(this.runCommand(["device", "info"]), "device info");
+ const screenSize = response.data.device.screenSize;
+ if (!screenSize) {
+ throw new ActionableError("The tvOS device did not report a screen size.");
+ }
+
+ return screenSize;
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/tvos.ts` around lines 86 - 93, Validate the response status before
dereferencing payload data in getScreenSize, getInstalledApps, and dumpUI, and
surface an actionable error for non-success responses instead of allowing a
TypeError. In getScreenSize, remove the zero-size fallback and report the
failure when screenSize is absent; preserve the normal successful response
behavior.
| public async launchApp(packageName: string, locale?: string): Promise<void> { | ||
| const args = ["apps", "launch", packageName]; | ||
| if (locale) { | ||
| args.push("--locale", locale); | ||
| } | ||
|
|
||
| this.runCommand(args); | ||
| } | ||
|
|
||
| public async terminateApp(packageName: string): Promise<void> { | ||
| this.runCommand(["apps", "terminate", packageName]); | ||
| } | ||
|
|
||
| public async installApp(path: string): Promise<void> { | ||
| this.runCommand(["apps", "install", path]); | ||
| } | ||
|
|
||
| public async uninstallApp(bundleId: string): Promise<void> { | ||
| this.runCommand(["apps", "uninstall", bundleId]); | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Validate packageName and locale as IosRobot does.
IosRobot.launchApp calls validatePackageName and validateLocale (src/utils.ts). TvosRobot passes the raw values into the mobilecli argument list. A value that starts with - is then interpreted by mobilecli as an option instead of a bundle identifier. terminateApp, installApp, and uninstallApp have the same gap. Reuse the shared validators to keep both robots consistent.
🛡️ Proposed fix
-import { ActionableError, Button, InstalledApp, Orientation, Robot, ScreenElement, ScreenSize, SwipeDirection } from "./robot";
+import { ActionableError, Button, InstalledApp, Orientation, Robot, ScreenElement, ScreenSize, SwipeDirection } from "./robot";
+import { validateLocale, validatePackageName } from "./utils"; public async launchApp(packageName: string, locale?: string): Promise<void> {
+ validatePackageName(packageName);
const args = ["apps", "launch", packageName];
if (locale) {
+ validateLocale(locale);
args.push("--locale", locale);
}
this.runCommand(args);
}
public async terminateApp(packageName: string): Promise<void> {
+ validatePackageName(packageName);
this.runCommand(["apps", "terminate", packageName]);
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/tvos.ts` around lines 116 - 135, Update TvosRobot.launchApp,
terminateApp, installApp, and uninstallApp to validate their package or bundle
identifier arguments with the shared validators from src/utils.ts before
constructing the mobilecli command; also validate launchApp’s optional locale
with validateLocale. Reuse the same validation flow as IosRobot so values
beginning with “-” are rejected consistently.
Summary
Adds a dedicated
TvosRobottomobile-mcpso real Apple TV devices are driven through mobilecli/DeviceKit with tvOS-appropriate behavior, while leaving the existingIosRobot/WebDriverAgent path unchanged for iPhone/iPad.This is part of a three-repo upstream change across
mobile-next/mobilecli,mobile-next/devicekit-ios, andmobile-next/mobile-mcp.What's in this PR
TvosRobotfor real Apple TV routing.UP,DOWN,LEFT,RIGHT,SELECT,MENU,PLAY_PAUSE.mobile_focus_by_identifierMCP tool delegating tomobilecli io focus.IosRobot/WDA; Android and simulator routing unchanged.Validation
npm run buildnpm run linthas 0 source errorstest/tvos.tsunit tests passImplements: SPECS/quality/real-tvos-device-support/SPEC.md
Related PRs