basant307/AI_Governance_Project
048
1import { execFileSync } from 'node:child_process';2import { mkdtempSync, readdirSync, rmSync } from 'node:fs';3import { tmpdir } from 'node:os';4import { join, basename, extname } from 'node:path';5 6import { trace } from './logger';7import { WebDriverAgent } from './webdriver-agent';8import {9 ActionableError,10 Button,11 InstalledApp,12 InstallOptions,13 Robot,14 ScreenElement,15 ScreenSize,16 SwipeDirection,17 Orientation,18} from './robot';19import { validatePackageName, validateLocale } from './utils';20 21export interface Simulator {22 name: string;23 uuid: string;24 state: string;25}26 27interface AppInfo {28 ApplicationType: string;29 Bundle: string;30 CFBundleDisplayName: string;31 CFBundleExecutable: string;32 CFBundleIdentifier: string;33 CFBundleName: string;34 CFBundleVersion: string;35 DataContainer: string;36 Path: string;37}38 39const TIMEOUT = 30000;40const WDA_PORT = 8100;41const MAX_BUFFER_SIZE = 1024 * 1024 * 8;42 43export class Simctl implements Robot {44 constructor(private readonly simulatorUuid: string) {}45 46 private async isWdaInstalled(): Promise<boolean> {47 const apps = await this.listApps();48 return apps49 .map((app) => app.packageName)50 .includes('com.facebook.WebDriverAgentRunner.xctrunner');51 }52 53 private async startWda(): Promise<void> {54 if (!(await this.isWdaInstalled())) {55 // wda is not even installed, won't attempt to start it56 return;57 }58 59 trace('Starting WebDriverAgent');60 const webdriverPackageName = 'com.facebook.WebDriverAgentRunner.xctrunner';61 this.simctl('launch', this.simulatorUuid, webdriverPackageName);62 63 // now we wait for wda to have a successful status64 const wda = new WebDriverAgent('localhost', WDA_PORT);65 66 // wait up to 10 seconds for wda to start67 const timeout = +new Date() + 10 * 1000;68 while (+new Date() < timeout) {69 // cross fingers and see if wda is already running70 if (await wda.isRunning()) {71 trace('WebDriverAgent is now running');72 return;73 }74 75 // wait 100ms before trying again76 await new Promise((resolve) => setTimeout(resolve, 100));77 }78 79 trace('Could not start WebDriverAgent in time, giving up');80 }81 82 private async wda(): Promise<WebDriverAgent> {83 const wda = new WebDriverAgent('localhost', WDA_PORT);84 85 if (!(await wda.isRunning())) {86 await this.startWda();87 if (!(await wda.isRunning())) {88 throw new ActionableError(89 'WebDriverAgent is not running on simulator, please see https://github.com/mobile-next/mobile-mcp/wiki/',90 );91 }92 93 // was successfully started94 }95 96 return wda;97 }98 99 private simctl(...args: string[]): Buffer {100 return execFileSync('xcrun', ['simctl', ...args], {101 timeout: TIMEOUT,102 maxBuffer: MAX_BUFFER_SIZE,103 });104 }105 106 public async getScreenshot(): Promise<Buffer> {107 const wda = await this.wda();108 return await wda.getScreenshot();109 // alternative: return this.simctl("io", this.simulatorUuid, "screenshot", "-");110 }111 112 public async openUrl(url: string) {113 const wda = await this.wda();114 await wda.openUrl(url);115 // alternative: this.simctl("openurl", this.simulatorUuid, url);116 }117 118 public async launchApp(packageName: string, locale?: string) {119 validatePackageName(packageName);120 const args = ['launch', this.simulatorUuid, packageName];121 if (locale) {122 validateLocale(locale);123 const locales = locale.split(',').map((l) => l.trim());124 args.push('-AppleLanguages', `(${locales.join(', ')})`);125 args.push('-AppleLocale', locales[0]);126 }127 128 this.simctl(...args);129 }130 131 public async terminateApp(packageName: string) {132 validatePackageName(packageName);133 this.simctl('terminate', this.simulatorUuid, packageName);134 }135 136 private findAppBundle(dir: string): string | null {137 const entries = readdirSync(dir, { withFileTypes: true });138 139 for (const entry of entries) {140 if (entry.isDirectory() && entry.name.endsWith('.app')) {141 return join(dir, entry.name);142 }143 }144 145 return null;146 }147 148 private validateZipPaths(zipPath: string): void {149 const output = execFileSync('/usr/bin/zipinfo', ['-1', zipPath], {150 timeout: TIMEOUT,151 maxBuffer: MAX_BUFFER_SIZE,152 }).toString();153 154 const invalidPath = output155 .split('\n')156 .map((s) => s.trim())157 .filter((s) => s)158 .find((s) => s.startsWith('/') || s.includes('..'));159 160 if (invalidPath) {161 throw new ActionableError(162 `Security violation: File path '${invalidPath}' contains invalid characters`,163 );164 }165 }166 167 public async installApp(168 path: string,169 _options?: InstallOptions,170 ): Promise<void> {171 let tempDir: string | null = null;172 let installPath = path;173 174 try {175 // zip files need to be extracted prior to installation176 if (extname(path).toLowerCase() === '.zip') {177 trace(`Detected .zip file, validating contents`);178 179 // before extracting, let's make sure there's no zip-slip bombs here180 this.validateZipPaths(path);181 182 tempDir = mkdtempSync(join(tmpdir(), 'ios-app-'));183 184 try {185 execFileSync('unzip', ['-q', path, '-d', tempDir], {186 timeout: TIMEOUT,187 });188 } catch (error: any) {189 throw new ActionableError(`Failed to unzip file: ${error.message}`);190 }191 192 const appBundle = this.findAppBundle(tempDir);193 if (!appBundle) {194 throw new ActionableError(195 'No .app bundle found in the .zip file, please visit wiki at https://github.com/mobile-next/mobile-mcp/wiki for assistance.',196 );197 }198 199 installPath = appBundle;200 trace(`Found .app bundle at: ${basename(appBundle)}`);201 }202 203 // continue with installation204 this.simctl('install', this.simulatorUuid, installPath);205 } catch (error: any) {206 const stdout = error.stdout ? error.stdout.toString() : '';207 const stderr = error.stderr ? error.stderr.toString() : '';208 const output = (stdout + stderr).trim();209 throw new ActionableError(output || error.message);210 } finally {211 // Clean up temporary directory if it was created212 if (tempDir) {213 try {214 trace(`Cleaning up temporary directory`);215 rmSync(tempDir, { recursive: true, force: true });216 } catch (cleanupError) {217 trace(218 `Warning: Failed to cleanup temporary directory: ${cleanupError}`,219 );220 }221 }222 }223 }224 225 public async uninstallApp(bundleId: string): Promise<void> {226 try {227 this.simctl('uninstall', this.simulatorUuid, bundleId);228 } catch (error: any) {229 const stdout = error.stdout ? error.stdout.toString() : '';230 const stderr = error.stderr ? error.stderr.toString() : '';231 const output = (stdout + stderr).trim();232 throw new ActionableError(output || error.message);233 }234 }235 236 public async listApps(): Promise<InstalledApp[]> {237 const text = this.simctl('listapps', this.simulatorUuid).toString();238 const result = execFileSync(239 'plutil',240 ['-convert', 'json', '-o', '-', '-r', '-'],241 {242 input: text,243 },244 );245 246 const output = JSON.parse(result.toString()) as Record<string, AppInfo>;247 return Object.values(output).map((app) => ({248 packageName: app.CFBundleIdentifier,249 appName: app.CFBundleDisplayName,250 }));251 }252 253 public async getScreenSize(): Promise<ScreenSize> {254 const wda = await this.wda();255 return wda.getScreenSize();256 }257 258 public async sendKeys(keys: string) {259 const wda = await this.wda();260 return wda.sendKeys(keys);261 }262 263 public async swipe(direction: SwipeDirection): Promise<void> {264 const wda = await this.wda();265 return wda.swipe(direction);266 }267 268 public async swipeFromCoordinate(269 x: number,270 y: number,271 direction: SwipeDirection,272 distance?: number,273 ): Promise<void> {274 const wda = await this.wda();275 return wda.swipeFromCoordinate(x, y, direction, distance);276 }277 278 public async tap(x: number, y: number) {279 const wda = await this.wda();280 return wda.tap(x, y);281 }282 283 public async doubleTap(x: number, y: number): Promise<void> {284 const wda = await this.wda();285 await wda.doubleTap(x, y);286 }287 288 public async longPress(x: number, y: number, duration: number) {289 const wda = await this.wda();290 return wda.longPress(x, y, duration);291 }292 293 public async pressButton(button: Button) {294 const wda = await this.wda();295 return wda.pressButton(button);296 }297 298 public async getElementsOnScreen(): Promise<ScreenElement[]> {299 const wda = await this.wda();300 return wda.getElementsOnScreen();301 }302 303 public async setOrientation(orientation: Orientation): Promise<void> {304 const wda = await this.wda();305 return wda.setOrientation(orientation);306 }307 308 public async getOrientation(): Promise<Orientation> {309 const wda = await this.wda();310 return wda.getOrientation();311 }312}313 