Skip to main content
Glama
ui-normalizer.ts7.46 kB
/** * iOS UI Normalizer * Converts iOS UI hierarchy to unified UIElement format */ import { UIElement, UIContext, UIContextOptions, calculateCenter, generateElementId, Bounds, } from '../../models/ui-context.js'; import { IOS_ELEMENT_MAP, ElementType } from '../../models/constants.js'; import { takeScreenshot, listDevices, getDevice, getBootedDevice } from './simctl.js'; import { compressScreenshot, createEmptyScreenshot } from '../../utils/image.js'; import { Errors } from '../../models/errors.js'; import { executeShell } from '../../utils/shell.js'; /** * iOS accessibility element from hierarchy */ interface IOSAccessibilityElement { type?: string; label?: string; identifier?: string; value?: string; frame?: { x: number; y: number; width: number; height: number }; enabled?: boolean; selected?: boolean; focused?: boolean; children?: IOSAccessibilityElement[]; } /** * Capture UI context from iOS simulator */ export async function captureIOSUIContext( options: UIContextOptions = {} ): Promise<UIContext> { const { deviceId, includeAllElements = false, maxDepth = 20, screenshotQuality = 50, skipScreenshot = false, elementTypes, } = options; // Find target device let targetDeviceId: string; if (deviceId) { const foundDevice = await getDevice(deviceId); if (!foundDevice) { const devices = await listDevices(); throw Errors.deviceNotFound(deviceId, devices.map((d) => `${d.id} (${d.name})`)); } targetDeviceId = foundDevice.id; } else { const bootedDevice = await getBootedDevice(); if (!bootedDevice) { throw Errors.invalidArguments('No running iOS simulator found'); } targetDeviceId = bootedDevice.id; } // Capture screenshot let screenshotData = createEmptyScreenshot(); if (!skipScreenshot) { try { const screenshotBuffer = await takeScreenshot(targetDeviceId); screenshotData = await compressScreenshot(screenshotBuffer, { quality: screenshotQuality, format: 'jpeg', }); } catch (error) { console.error('[ios-ui] Screenshot capture failed:', error); } } // For iOS, we use simctl's accessibility hierarchy if available // This is a simplified implementation - full implementation would use // XCTest or Appium for better element access const elements = await captureIOSElements(targetDeviceId, { includeAll: includeAllElements, maxDepth, elementTypes, }); // Get screen size from screenshot or default const screenSize = { width: screenshotData.width || 390, height: screenshotData.height || 844, }; return { platform: 'ios', deviceId: targetDeviceId, screenshot: screenshotData, elements, totalElementCount: elements.length, screenSize, timestamp: Date.now(), }; } /** * Capture iOS accessibility elements * Note: This is a simplified implementation. Full accessibility tree * requires XCTest or Appium integration. */ async function captureIOSElements( udid: string, _options: { includeAll?: boolean; maxDepth?: number; elementTypes?: ElementType[]; } ): Promise<UIElement[]> { // Attempt to get accessibility elements using simctl // This is limited compared to XCTest but works without additional tools try { // Use simctl io to capture accessibility info (experimental) await executeShell( 'xcrun', ['simctl', 'spawn', udid, 'launchctl', 'print', 'user/com.apple.accessibility.AccessibilityUIServer'], { silent: true, timeoutMs: 5000 } ); // If accessibility info is available, parse it // For now, return empty since this requires more complex integration console.warn('[ios-ui] Full accessibility tree requires XCTest integration'); return []; } catch { // Accessibility capture failed - this is expected without XCTest return []; } } /** * Parse iOS accessibility element to unified format */ function parseIOSElement( element: IOSAccessibilityElement, depth: number, index: number ): UIElement | null { const frame = element.frame || { x: 0, y: 0, width: 0, height: 0 }; const bounds: Bounds = { x: frame.x, y: frame.y, width: frame.width, height: frame.height, }; // Skip zero-sized elements if (bounds.width === 0 && bounds.height === 0) { return null; } const type = mapIOSElementType(element.type || ''); const id = generateElementId(index, depth, element.identifier); return { id, type, text: element.label || undefined, contentDescription: element.label, resourceId: element.identifier, className: element.type || 'Unknown', bounds, center: calculateCenter(bounds), clickable: type === 'button' || type === 'input' || type === 'switch', enabled: element.enabled !== false, focused: element.focused === true, visible: true, scrollable: type === 'scroll' || type === 'list', isPassword: element.type === 'XCUIElementTypeSecureTextField', depth, index, }; } /** * Flatten iOS element hierarchy * Exported for future XCTest integration */ export function flattenIOSHierarchy( elements: IOSAccessibilityElement[], depth = 0, maxDepth = 20 ): UIElement[] { const result: UIElement[] = []; let index = 0; function processElement(el: IOSAccessibilityElement, d: number): void { if (d > maxDepth) return; const uiElement = parseIOSElement(el, d, index++); if (uiElement) { result.push(uiElement); } if (el.children) { for (const child of el.children) { processElement(child, d + 1); } } } for (const element of elements) { processElement(element, depth); } return result; } /** * Map iOS element type to unified type */ export function mapIOSElementType(iosType: string): ElementType { // Direct mapping if (iosType in IOS_ELEMENT_MAP) { return IOS_ELEMENT_MAP[iosType]; } // Heuristic mapping const lowerType = iosType.toLowerCase(); if (lowerType.includes('button')) return 'button'; if (lowerType.includes('textfield') || lowerType.includes('textview')) return 'input'; if (lowerType.includes('statictext') || lowerType.includes('label')) return 'text'; if (lowerType.includes('image')) return 'image'; if (lowerType.includes('table') || lowerType.includes('collection')) return 'list'; if (lowerType.includes('scroll')) return 'scroll'; if (lowerType.includes('switch') || lowerType.includes('toggle')) return 'switch'; if (lowerType.includes('checkbox')) return 'checkbox'; if (lowerType.includes('cell') || lowerType.includes('other')) return 'container'; return 'other'; } /** * Create element summary for AI consumption */ export function createIOSElementSummary(elements: UIElement[]): string { const byType: Record<string, number> = {}; const interactive: string[] = []; for (const el of elements) { byType[el.type] = (byType[el.type] || 0) + 1; if (el.clickable || el.type === 'button' || el.type === 'input') { const identifier = el.resourceId || el.text || el.id; if (identifier) { interactive.push(`${el.type}: ${identifier}`); } } } const typesSummary = Object.entries(byType) .map(([type, count]) => `${type}: ${count}`) .join(', '); return `Elements: ${typesSummary}\nInteractive: ${interactive.slice(0, 10).join(', ')}${interactive.length > 10 ? '...' : ''}`; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/abd3lraouf/specter-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server