xc-mcp
Version:
MCP server that wraps Xcode command-line tools for iOS/macOS development workflows
128 lines • 5.22 kB
JavaScript
import { createHash } from 'crypto';
/**
* Compute unique fingerprint for a view based on accessibility element structure
*
* Following xcode-agent recommendation:
* - Element structure hash is PRIMARY key (more stable than screenshot)
* - Includes orientation and screen bounds for device-specific layouts
* - Excludes screenshot hash by default (fragile due to status bar, animations)
*
* @param elements Accessibility elements from view
* @param screenDimensions Screen width/height
* @param orientation Device orientation
* @param config Optional fingerprinting configuration
*/
export function computeViewFingerprint(elements, screenDimensions, orientation = 'portrait', config = {}) {
const { includeScreenshotHash: _includeScreenshotHash = false, // Reserved for Phase 3
includeTimestamps = false, sortElements = true, } = config;
// Filter to hittable elements with bounds (interactive elements only)
const interactiveElements = elements.filter(el => el.bounds && el.hittable !== false && el.enabled !== false);
// Build element structure signature
// Format: "type:identifier:label:x,y,w,h"
let elementSignatures = interactiveElements.map(el => {
const type = el.type.replace('XCUIElementType', ''); // Shorten type names
const id = el.identifier || '';
const label = includeTimestamps ? el.label || '' : sanitizeLabel(el.label || '');
const bounds = el.bounds
? `${Math.round(el.bounds.x)},${Math.round(el.bounds.y)},${Math.round(el.bounds.width)},${Math.round(el.bounds.height)}`
: '';
return `${type}:${id}:${label}:${bounds}`;
});
// Sort for order-independent hashing (same elements, different order = same hash)
if (sortElements) {
elementSignatures = elementSignatures.sort();
}
// Compute element structure hash
const elementSignature = elementSignatures.join('|');
const elementStructureHash = createHash('sha256')
.update(elementSignature)
.digest('hex')
.substring(0, 16); // 16 chars = 64 bits = ~1 in 2^64 collision probability
// Extract top-level containers for debugging/logging
const topLevelContainers = [
...new Set(interactiveElements
.filter(el => el.type.includes('NavigationBar') || el.type.includes('TabBar'))
.map(el => el.label || el.identifier || el.type)),
].slice(0, 3);
return {
elementStructureHash,
orientation,
screenBounds: {
width: screenDimensions.width,
height: screenDimensions.height,
},
timestamp: new Date(),
elementCount: interactiveElements.length,
topLevelContainers,
};
}
/**
* Sanitize element labels to exclude dynamic content
* Removes timestamps, numbers, percentages that change frequently
*/
function sanitizeLabel(label) {
return (label
// Remove timestamps (e.g., "10:23 AM", "14:35")
.replace(/\d{1,2}:\d{2}\s*(AM|PM)?/gi, 'TIME')
// Remove percentages (e.g., "87%", "100%")
.replace(/\d+%/g, 'PERCENT')
// Remove standalone numbers over 2 digits (e.g., "1,234", "5678")
.replace(/\b\d{3,}[,\d]*\b/g, 'NUMBER')
// Remove currency amounts (e.g., "$12.99", "€50")
.replace(/[$€£¥]\s*\d+[.,]?\d*/g, 'CURRENCY')
// Trim whitespace
.trim());
}
/**
* Generate cache key from fingerprint, bundleId, and app version
* Following xcode-agent recommendation to include app version
*/
export function generateCacheKey(fingerprint, bundleId, appVersion) {
const versionPart = appVersion ? `:${appVersion}` : '';
return `${bundleId}:${fingerprint.orientation}:${fingerprint.elementStructureHash}${versionPart}`;
}
/**
* Check if a view is cacheable (excludes dynamic/animated content)
* Following xcode-agent recommendation to exclude uncacheable views
*/
export function isViewCacheable(elements) {
const UNCACHEABLE_PATTERNS = [
/loading/i,
/progress/i,
/animation/i,
/spinner/i,
/activity.*indicator/i,
/refreshing/i,
];
// Don't cache views with loading/animation indicators
const hasDynamicContent = elements.some(el => {
const text = `${el.label || ''} ${el.identifier || ''} ${el.type}`;
return UNCACHEABLE_PATTERNS.some(pattern => pattern.test(text));
});
if (hasDynamicContent) {
return false;
}
// Don't cache views with very few elements (likely loading state)
const interactiveElements = elements.filter(el => el.bounds && el.hittable !== false);
if (interactiveElements.length < 3) {
return false;
}
return true;
}
/**
* Compute Hamming distance between two hash strings
* Used for perceptual hash comparison in future phases
*/
export function hammingDistance(hash1, hash2) {
if (hash1.length !== hash2.length) {
throw new Error('Hash lengths must match for Hamming distance calculation');
}
let distance = 0;
for (let i = 0; i < hash1.length; i++) {
if (hash1[i] !== hash2[i]) {
distance++;
}
}
return distance;
}
//# sourceMappingURL=view-fingerprinting.js.map