Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
276 changes: 216 additions & 60 deletions src/ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,100 @@ interface InfoCommandOutput {
TimeZone: string;
}

interface CoreDeviceListOutput {
result?: {
devices?: CoreDeviceEntry[];
};
}

interface CoreDeviceDetailsOutput {
result?: {
connectionProperties?: {
transportType?: string;
};
deviceProperties?: {
name?: string;
osVersionNumber?: string;
};
hardwareProperties?: {
productType?: string;
udid?: string;
};
};
}

interface CoreDeviceAppsOutput {
result?: {
apps?: Array<{
bundleIdentifier?: string;
name?: string;
version?: string;
}>;
};
}

interface CoreDeviceEntry {
connectionProperties?: {
pairingState?: string;
transportType?: string;
tunnelState?: string;
};
deviceProperties?: {
bootState?: string;
name?: string;
osVersionNumber?: string;
};
hardwareProperties?: {
productType?: string;
reality?: string;
udid?: string;
};
}

export interface IosDevice {
deviceId: string;
deviceName: string;
}

export type IosPlatform = "ios" | "tvos";
export type IosDeviceWithDetails = IosDevice & { version: string; platform: IosPlatform };

/**
* Real Apple TV units are enumerated over the same go-ios connection as iPhones and
* iPads, so they are distinguished by their product type (e.g. "AppleTV14,1") rather
* than a separate device class.
*/
export const platformFromProductType = (productType: string): IosPlatform =>
productType.startsWith("AppleTV") ? "tvos" : "ios";

export const coreDeviceDevicesFromJson = (output: string): IosDeviceWithDetails[] => {
const json = JSON.parse(output) as CoreDeviceListOutput;
const entries = json.result?.devices ?? [];

return entries
.filter(entry => entry.hardwareProperties?.reality === "physical")
.filter(entry => entry.connectionProperties?.pairingState === "paired")
.filter(entry => {
const bootState = entry.deviceProperties?.bootState ?? "";
const tunnelState = entry.connectionProperties?.tunnelState ?? "";
const transportType = entry.connectionProperties?.transportType ?? "";
return bootState === "booted" || tunnelState === "connected" || transportType === "usb" || transportType === "localNetwork";
})
.map(entry => {
const deviceId = entry.hardwareProperties?.udid?.trim() ?? "";
const deviceName = entry.deviceProperties?.name?.trim() ?? "";
const version = entry.deviceProperties?.osVersionNumber?.trim() ?? "";
const productType = entry.hardwareProperties?.productType?.trim() ?? "";
return {
deviceId,
deviceName,
version,
platform: platformFromProductType(productType),
};
})
.filter(device => device.deviceId.length > 0);
};

const getGoIosPath = (): string => {
if (process.env.GO_IOS_PATH) {
return process.env.GO_IOS_PATH;
Expand Down Expand Up @@ -96,10 +185,34 @@ export class IosRobot implements Robot {
return execFileSync(getGoIosPath(), ["--udid", this.deviceId, ...args], {}).toString();
}

private devicectl(...args: string[]): string {
return execFileSync("xcrun", ["devicectl", ...args, "--json-output", "-"], {
stdio: ["pipe", "pipe", "ignore"],
}).toString();
}

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: "",
};
}
Comment on lines +194 to +206

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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 in IosRobot.getCoreDeviceInfo with a shared helper that derives DeviceClass from the product type.
  • src/ios.ts#L427-L447: replace the inline mapping in the IosManager.getDeviceInfo fallback with the same shared helper, passing deviceId as 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 getIosVersion(): Promise<string> {
const output = await this.ios("info");
const json = JSON.parse(output);
return json.ProductVersion;
try {
const output = await this.ios("info");
const json = JSON.parse(output);
return json.ProductVersion;
} catch {
return this.getCoreDeviceInfo().ProductVersion;
}
}

private async isTunnelRequired(): Promise<boolean> {
Expand All @@ -124,32 +237,47 @@ export class IosRobot implements Robot {
}

public async listApps(): Promise<InstalledApp[]> {
await this.assertTunnelRunning();

const output = await this.ios("apps", "--all", "--list");
return output
.split("\n")
.map(line => {
const [packageName, appName] = line.split(" ");
return {
packageName,
appName,
};
});
try {
await this.assertTunnelRunning();

const output = await this.ios("apps", "--all", "--list");
return output
.split("\n")
.map(line => {
const [packageName, appName] = line.split(" ");
return {
packageName,
appName,
};
});
} catch {
const output = this.devicectl("device", "info", "apps", "--device", this.deviceId);
const json = JSON.parse(output) as CoreDeviceAppsOutput;
return (json.result?.apps ?? [])
.filter(app => (app.bundleIdentifier ?? "").length > 0)
.map(app => ({
packageName: app.bundleIdentifier ?? "",
appName: app.name ?? "",
}));
}
}

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);
}
}
Comment on lines 265 to 281

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
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.


public async terminateApp(packageName: string): Promise<void> {
Expand Down Expand Up @@ -244,61 +372,89 @@ export class IosRobot implements Robot {

export class IosManager {

public isGoIosInstalled(): boolean {
private listCoreDeviceDevicesWithDetails(): IosDeviceWithDetails[] {
try {
const output = execFileSync(getGoIosPath(), ["version"], { stdio: ["pipe", "pipe", "ignore"] }).toString();
const json: VersionCommandOutput = JSON.parse(output);
return json.version !== undefined && (/^v?\d+\.\d+\.\d+/.test(json.version) || json.version === "local-build");
const output = execFileSync("xcrun", ["devicectl", "list", "devices", "--json-output", "-"], { stdio: ["pipe", "pipe", "ignore"] }).toString();
return coreDeviceDevicesFromJson(output);
} catch (error) {
return false;
}
}

public getDeviceName(deviceId: string): string {
const output = execFileSync(getGoIosPath(), ["info", "--udid", deviceId]).toString();
const json: InfoCommandOutput = JSON.parse(output);
return json.DeviceName;
}

public getDeviceInfo(deviceId: string): InfoCommandOutput {
const output = execFileSync(getGoIosPath(), ["info", "--udid", deviceId]).toString();
const json: InfoCommandOutput = JSON.parse(output);
return json;
}

public listDevices(): IosDevice[] {
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 => ({
deviceId: device,
deviceName: this.getDeviceName(device),
}));

return devices;
}

public listDevicesWithDetails(): Array<IosDevice & { version: string }> {
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),
};
});
}
Comment on lines +384 to +400

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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.

Suggested change
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.


private mergeUniqueDevices(devices: IosDeviceWithDetails[]): IosDeviceWithDetails[] {
const seen = new Set<string>();
return devices.filter(device => {
if (seen.has(device.deviceId)) {
return false;
}
seen.add(device.deviceId);
return true;
});
}

public isGoIosInstalled(): boolean {
try {
const output = execFileSync(getGoIosPath(), ["version"], { stdio: ["pipe", "pipe", "ignore"] }).toString();
const json: VersionCommandOutput = JSON.parse(output);
return json.version !== undefined && (/^v?\d+\.\d+\.\d+/.test(json.version) || json.version === "local-build");
} catch (error) {
return false;
}
}

public getDeviceName(deviceId: string): string {
return this.getDeviceInfo(deviceId).DeviceName;
}

public getDeviceInfo(deviceId: string): InfoCommandOutput {
try {
const output = execFileSync(getGoIosPath(), ["info", "--udid", deviceId]).toString();
const json: InfoCommandOutput = JSON.parse(output);
return json;
} catch {
const output = execFileSync("xcrun", ["devicectl", "device", "info", "details", "--device", deviceId, "--json-output", "-"], {
stdio: ["pipe", "pipe", "ignore"],
}).toString();
const json = JSON.parse(output) as CoreDeviceDetailsOutput;
return {
DeviceClass: "iPhone",
DeviceName: json.result?.deviceProperties?.name ?? deviceId,
ProductName: json.result?.hardwareProperties?.productType ?? "",
ProductType: json.result?.hardwareProperties?.productType ?? "",
ProductVersion: json.result?.deviceProperties?.osVersionNumber ?? "",
PhoneNumber: "",
TimeZone: "",
};
}
}

public listDevices(): IosDeviceWithDetails[] {
return this.listDevicesWithDetails();
}

return devices;
public listDevicesWithDetails(): IosDeviceWithDetails[] {
const devices = [
...this.listGoIosDevicesWithDetails(),
...this.listCoreDeviceDevicesWithDetails(),
];
return this.mergeUniqueDevices(devices);
}
Comment on lines +449 to 459

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 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.

}
32 changes: 29 additions & 3 deletions src/mobilecli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export interface MobilecliAgentStatusResponse {

export interface MobilecliDevicesOptions {
includeOffline?: boolean;
platform?: "ios" | "android";
platform?: "ios" | "android" | "tvos";
type?: "real" | "emulator" | "simulator";
}

Expand All @@ -56,6 +56,14 @@ export interface MobilecliDevicesResponse {
};
}

export interface MobilecliFocusResponse {
status: "ok" | "error";
data?: {
element: unknown;
};
error?: string;
}

const TIMEOUT = 30000;
const MAX_BUFFER_SIZE = 1024 * 1024 * 8;

Expand Down Expand Up @@ -177,6 +185,24 @@ export class Mobilecli {
this.executeCommand(["agent", "install", "--device", deviceId]);
}

pressButton(deviceId: string, button: string): void {
this.executeCommand(["io", "button", button, "--device", deviceId]);
}

focusByIdentifier(deviceId: string, identifier?: string, label?: string): MobilecliFocusResponse {
const args = ["io", "focus", "--device", deviceId];
if (identifier) {
args.push("--identifier", identifier);
}

if (label) {
args.push("--label", label);
}

const output = this.executeCommand(args);
return JSON.parse(output) as MobilecliFocusResponse;
}

getDevices(options?: MobilecliDevicesOptions): MobilecliDevicesResponse {
const args = ["devices"];

Expand All @@ -186,8 +212,8 @@ export class Mobilecli {
}

if (options.platform) {
if (options.platform !== "ios" && options.platform !== "android") {
throw new Error(`Invalid platform: ${options.platform}. Must be "ios" or "android"`);
if (options.platform !== "ios" && options.platform !== "android" && options.platform !== "tvos") {
throw new Error(`Invalid platform: ${options.platform}. Must be "ios", "android" or "tvos"`);
}

args.push("--platform", options.platform);
Expand Down
Loading