Add jailbroken iPhone MCP screen app

This commit is contained in:
Adolfo Reyna
2026-06-21 21:25:55 -04:00
parent 8dbc5f4c7b
commit ad30662a48
21 changed files with 2206 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
#import <UIKit/UIKit.h>
@interface IMCAppDelegate : UIResponder <UIApplicationDelegate>
@property (nonatomic, strong) UIWindow *window;
@end
+23
View File
@@ -0,0 +1,23 @@
#import "IMCAppDelegate.h"
#import "IMCViewController.h"
@implementation IMCAppDelegate
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
application.idleTimerDisabled = YES;
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
self.window.rootViewController = [[IMCViewController alloc] init];
[self.window makeKeyAndVisible];
return YES;
}
- (void)applicationDidBecomeActive:(UIApplication *)application {
application.idleTimerDisabled = YES;
}
- (void)applicationWillTerminate:(UIApplication *)application {
application.idleTimerDisabled = NO;
}
@end
+10
View File
@@ -0,0 +1,10 @@
#import <UIKit/UIKit.h>
@interface IMCCanvasView : UIView
- (void)clearWithColor:(NSInteger)color;
- (void)drawText:(NSString *)text x:(CGFloat)x y:(CGFloat)y size:(NSInteger)size;
- (void)drawImageData:(NSData *)data x:(CGFloat)x y:(CGFloat)y;
- (void)displayMonochromeFrameBuffer:(NSData *)frameData;
- (BOOL)displayRGB565FrameBuffer:(NSData *)frameData width:(NSUInteger)width height:(NSUInteger)height;
- (NSString *)snapshotPNGBase64;
@end
+218
View File
@@ -0,0 +1,218 @@
#import "IMCCanvasView.h"
@interface IMCCanvasView ()
@property (nonatomic, assign) NSInteger clearColor;
@property (nonatomic, strong) NSMutableArray *commands;
@property (nonatomic, strong) UIImage *streamImage;
@end
@implementation IMCCanvasView
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
_clearColor = 0;
_commands = [NSMutableArray array];
self.opaque = YES;
}
return self;
}
- (void)clearWithColor:(NSInteger)color {
self.clearColor = color == 1 ? 1 : 0;
self.streamImage = nil;
[self.commands removeAllObjects];
[self setNeedsDisplay];
}
- (void)drawText:(NSString *)text x:(CGFloat)x y:(CGFloat)y size:(NSInteger)size {
if (!text) {
text = @"";
}
self.streamImage = nil;
[self.commands addObject:@{
@"type": @"text",
@"text": text,
@"x": @(x),
@"y": @(y),
@"size": @(size == 2 ? 2 : 1)
}];
[self setNeedsDisplay];
}
- (void)drawImageData:(NSData *)data x:(CGFloat)x y:(CGFloat)y {
UIImage *image = [UIImage imageWithData:data];
if (!image) {
return;
}
self.streamImage = nil;
[self.commands addObject:@{
@"type": @"image",
@"image": image,
@"x": @(x),
@"y": @(y)
}];
[self setNeedsDisplay];
}
- (void)displayMonochromeFrameBuffer:(NSData *)frameData {
if (frameData.length < 15000) {
return;
}
const NSUInteger width = 400;
const NSUInteger height = 300;
NSMutableData *rgba = [NSMutableData dataWithLength:width * height * 4];
const uint8_t *source = frameData.bytes;
uint8_t *pixels = rgba.mutableBytes;
for (NSUInteger y = 0; y < height; y++) {
NSUInteger invY = height - 1 - y;
for (NSUInteger x = 0; x < width; x++) {
NSUInteger bx = x / 2;
NSUInteger by = invY / 4;
NSUInteger index = bx * 75 + by;
NSUInteger bit = 7 - ((invY % 4) * 2 + (x % 2));
BOOL on = (source[index] & (1 << bit)) != 0;
uint8_t value = on ? 255 : 0;
NSUInteger pixelIndex = (y * width + x) * 4;
pixels[pixelIndex + 0] = value;
pixels[pixelIndex + 1] = value;
pixels[pixelIndex + 2] = value;
pixels[pixelIndex + 3] = 255;
}
}
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGDataProviderRef provider = CGDataProviderCreateWithCFData((__bridge CFDataRef)rgba);
CGImageRef cgImage = CGImageCreate(width,
height,
8,
32,
width * 4,
colorSpace,
kCGImageAlphaLast | kCGBitmapByteOrderDefault,
provider,
NULL,
false,
kCGRenderingIntentDefault);
if (cgImage) {
self.streamImage = [UIImage imageWithCGImage:cgImage];
[self.commands removeAllObjects];
[self setNeedsDisplay];
CGImageRelease(cgImage);
}
CGDataProviderRelease(provider);
CGColorSpaceRelease(colorSpace);
}
- (BOOL)displayRGB565FrameBuffer:(NSData *)frameData width:(NSUInteger)width height:(NSUInteger)height {
if (width == 0 || height == 0 || width > 2048 || height > 2048 ||
frameData.length != width * height * 2) {
return NO;
}
const uint8_t *source = frameData.bytes;
NSMutableData *rgba = [NSMutableData dataWithLength:width * height * 4];
uint8_t *pixels = rgba.mutableBytes;
for (NSUInteger i = 0; i < width * height; i++) {
// Network color frames use big-endian RGB565.
uint16_t value = ((uint16_t)source[i * 2] << 8) | source[i * 2 + 1];
uint8_t red5 = (value >> 11) & 0x1f;
uint8_t green6 = (value >> 5) & 0x3f;
uint8_t blue5 = value & 0x1f;
pixels[i * 4 + 0] = (red5 << 3) | (red5 >> 2);
pixels[i * 4 + 1] = (green6 << 2) | (green6 >> 4);
pixels[i * 4 + 2] = (blue5 << 3) | (blue5 >> 2);
pixels[i * 4 + 3] = 255;
}
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGDataProviderRef provider = CGDataProviderCreateWithCFData((__bridge CFDataRef)rgba);
CGImageRef cgImage = CGImageCreate(width,
height,
8,
32,
width * 4,
colorSpace,
kCGImageAlphaLast | kCGBitmapByteOrderDefault,
provider,
NULL,
false,
kCGRenderingIntentDefault);
if (cgImage) {
self.streamImage = [UIImage imageWithCGImage:cgImage];
[self.commands removeAllObjects];
[self setNeedsDisplay];
CGImageRelease(cgImage);
}
CGDataProviderRelease(provider);
CGColorSpaceRelease(colorSpace);
return cgImage != NULL;
}
- (NSString *)snapshotPNGBase64 {
UIGraphicsBeginImageContextWithOptions(self.bounds.size, YES, 0.0);
[self drawViewHierarchyInRect:self.bounds afterScreenUpdates:YES];
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
NSData *pngData = UIImagePNGRepresentation(image);
return [pngData base64EncodedStringWithOptions:0];
}
- (CGPoint)pointForDeviceX:(CGFloat)x y:(CGFloat)y {
CGFloat scaleX = self.bounds.size.width / 400.0;
CGFloat scaleY = self.bounds.size.height / 300.0;
return CGPointMake(x * scaleX, y * scaleY);
}
- (void)drawRect:(CGRect)rect {
UIColor *background = self.clearColor == 1 ? [UIColor blackColor] : [UIColor whiteColor];
UIColor *foreground = self.clearColor == 1 ? [UIColor whiteColor] : [UIColor blackColor];
[background setFill];
UIRectFill(self.bounds);
if (self.streamImage) {
[self.streamImage drawInRect:self.bounds];
return;
}
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSetStrokeColorWithColor(context, foreground.CGColor);
CGContextSetLineWidth(context, 2.0);
CGContextStrokeRect(context, CGRectInset(self.bounds, 1.0, 1.0));
for (NSDictionary *command in self.commands) {
NSString *type = command[@"type"];
CGFloat x = [command[@"x"] doubleValue];
CGFloat y = [command[@"y"] doubleValue];
CGPoint point = [self pointForDeviceX:x y:y];
if ([type isEqualToString:@"text"]) {
NSInteger size = [command[@"size"] integerValue];
UIFont *font = [UIFont boldSystemFontOfSize:size == 2 ? 26.0 : 15.0];
NSDictionary *attrs = @{
NSFontAttributeName: font,
NSForegroundColorAttributeName: foreground
};
[command[@"text"] drawAtPoint:point withAttributes:attrs];
} else if ([type isEqualToString:@"image"]) {
UIImage *image = command[@"image"];
CGFloat maxWidth = self.bounds.size.width - point.x;
CGFloat maxHeight = self.bounds.size.height - point.y;
CGSize imageSize = image.size;
CGFloat scale = MIN(maxWidth / MAX(imageSize.width, 1.0),
maxHeight / MAX(imageSize.height, 1.0));
scale = MIN(scale, 1.0);
CGRect imageRect = CGRectMake(point.x,
point.y,
imageSize.width * scale,
imageSize.height * scale);
[image drawInRect:imageRect];
}
}
}
@end
+8
View File
@@ -0,0 +1,8 @@
#import <Foundation/Foundation.h>
@class IMCViewController;
@interface IMCMCPServer : NSObject
- (instancetype)initWithViewController:(IMCViewController *)viewController port:(NSInteger)port;
- (void)start;
@end
+163
View File
@@ -0,0 +1,163 @@
#import "IMCMCPServer.h"
#import "IMCViewController.h"
#import <arpa/inet.h>
#import <netinet/in.h>
#import <sys/socket.h>
#import <unistd.h>
@interface IMCMCPServer ()
@property (nonatomic, weak) IMCViewController *viewController;
@property (nonatomic, assign) NSInteger port;
@property (nonatomic, assign) BOOL running;
@end
@implementation IMCMCPServer
- (instancetype)initWithViewController:(IMCViewController *)viewController port:(NSInteger)port {
self = [super init];
if (self) {
_viewController = viewController;
_port = port;
}
return self;
}
- (void)start {
if (self.running) {
return;
}
self.running = YES;
[NSThread detachNewThreadSelector:@selector(serverLoop) toTarget:self withObject:nil];
}
- (void)serverLoop {
@autoreleasepool {
int serverSocket = socket(AF_INET, SOCK_STREAM, 0);
if (serverSocket < 0) {
return;
}
int yes = 1;
setsockopt(serverSocket, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
struct sockaddr_in address;
memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons((uint16_t)self.port);
if (bind(serverSocket, (struct sockaddr *)&address, sizeof(address)) < 0) {
close(serverSocket);
return;
}
if (listen(serverSocket, 8) < 0) {
close(serverSocket);
return;
}
while (self.running) {
@autoreleasepool {
struct sockaddr_in clientAddress;
socklen_t clientLength = sizeof(clientAddress);
int clientSocket = accept(serverSocket,
(struct sockaddr *)&clientAddress,
&clientLength);
if (clientSocket >= 0) {
[self handleClientSocket:clientSocket];
close(clientSocket);
}
}
}
close(serverSocket);
}
}
- (void)handleClientSocket:(int)clientSocket {
NSMutableData *requestData = [NSMutableData data];
char buffer[2048];
while (requestData.length < 1024 * 1024) {
ssize_t count = recv(clientSocket, buffer, sizeof(buffer), 0);
if (count <= 0) {
break;
}
[requestData appendBytes:buffer length:(NSUInteger)count];
NSString *partial = [[NSString alloc] initWithData:requestData encoding:NSUTF8StringEncoding];
NSRange separator = [partial rangeOfString:@"\r\n\r\n"];
if (separator.location == NSNotFound) {
continue;
}
NSInteger contentLength = [self contentLengthFromHeader:partial];
NSUInteger bodyStart = separator.location + separator.length;
if (requestData.length >= bodyStart + MAX(contentLength, 0)) {
break;
}
}
NSString *request = [[NSString alloc] initWithData:requestData encoding:NSUTF8StringEncoding];
NSDictionary *responseObject = [self responseObjectForHTTPRequest:request];
NSData *responseJSON = [NSJSONSerialization dataWithJSONObject:responseObject options:0 error:nil];
if (!responseJSON) {
responseJSON = [@"{}" dataUsingEncoding:NSUTF8StringEncoding];
}
NSString *status = responseObject[@"httpStatus"] ?: @"200 OK";
NSMutableDictionary *bodyObject = [responseObject mutableCopy];
[bodyObject removeObjectForKey:@"httpStatus"];
responseJSON = [NSJSONSerialization dataWithJSONObject:bodyObject options:0 error:nil];
NSMutableData *responseData = [NSMutableData data];
NSString *header = [NSString stringWithFormat:
@"HTTP/1.1 %@\r\nContent-Type: application/json\r\nContent-Length: %lu\r\nConnection: close\r\n\r\n",
status,
(unsigned long)responseJSON.length];
[responseData appendData:[header dataUsingEncoding:NSUTF8StringEncoding]];
[responseData appendData:responseJSON];
send(clientSocket, responseData.bytes, responseData.length, 0);
}
- (NSDictionary *)responseObjectForHTTPRequest:(NSString *)request {
if (![request hasPrefix:@"POST /api/mcp "]) {
return @{@"httpStatus": @"404 Not Found",
@"jsonrpc": @"2.0",
@"error": @{@"code": @-32601, @"message": @"POST /api/mcp required"},
@"id": [NSNull null]};
}
NSRange separator = [request rangeOfString:@"\r\n\r\n"];
if (separator.location == NSNotFound) {
return [self parseError:@"Missing HTTP body."];
}
NSString *body = [request substringFromIndex:separator.location + separator.length];
NSData *bodyData = [body dataUsingEncoding:NSUTF8StringEncoding];
NSError *jsonError = nil;
NSDictionary *rpcRequest = [NSJSONSerialization JSONObjectWithData:bodyData options:0 error:&jsonError];
if (jsonError || ![rpcRequest isKindOfClass:NSDictionary.class]) {
return [self parseError:@"Invalid JSON-RPC object."];
}
return [self.viewController handleRPCRequest:rpcRequest];
}
- (NSDictionary *)parseError:(NSString *)message {
return @{@"jsonrpc": @"2.0",
@"error": @{@"code": @-32700, @"message": message ?: @"Parse error"},
@"id": [NSNull null]};
}
- (NSInteger)contentLengthFromHeader:(NSString *)request {
NSArray *lines = [request componentsSeparatedByString:@"\r\n"];
for (NSString *line in lines) {
if ([[line lowercaseString] hasPrefix:@"content-length:"]) {
return [[line substringFromIndex:15] integerValue];
}
}
return 0;
}
@end
+11
View File
@@ -0,0 +1,11 @@
#import <Foundation/Foundation.h>
@class IMCCanvasView;
@interface IMCVideoStreamServer : NSObject
- (instancetype)initWithCanvasView:(IMCCanvasView *)canvasView;
- (void)start;
- (NSDictionary *)streamStats;
- (void)resetStreamStats;
- (void)setDebugEnabled:(BOOL)enabled;
@end
+447
View File
@@ -0,0 +1,447 @@
#import "IMCVideoStreamServer.h"
#import "IMCCanvasView.h"
#import <arpa/inet.h>
#import <netinet/in.h>
#import <sys/socket.h>
#import <string.h>
#import <unistd.h>
static const NSInteger IMCFrameSize = 15000;
static const NSInteger IMCUDPChunkSize = 1000;
static const NSInteger IMCUDPChunkCount = 15;
static const NSInteger IMCColorStreamPort = 8083;
static const NSUInteger IMCColorHeaderSize = 16;
@interface IMCVideoStreamServer ()
@property (nonatomic, weak) IMCCanvasView *canvasView;
@property (nonatomic, assign) BOOL running;
@property (nonatomic, assign) BOOL debugEnabled;
@property (nonatomic, strong) NSDate *startedAt;
@property (nonatomic, strong) NSDate *lastFrameAt;
@property (nonatomic, copy) NSString *lastProtocol;
@property (nonatomic, assign) uint64_t tcpConnections;
@property (nonatomic, assign) uint64_t tcpFrames;
@property (nonatomic, assign) uint64_t tcpBytes;
@property (nonatomic, assign) uint64_t udpFrames;
@property (nonatomic, assign) uint64_t udpPackets;
@property (nonatomic, assign) uint64_t udpBytes;
@property (nonatomic, assign) uint64_t invalidUDPPackets;
@property (nonatomic, assign) uint64_t discoveryRequests;
@property (nonatomic, assign) uint64_t colorConnections;
@property (nonatomic, assign) uint64_t colorFrames;
@property (nonatomic, assign) uint64_t colorBytes;
@property (nonatomic, assign) NSUInteger lastColorWidth;
@property (nonatomic, assign) NSUInteger lastColorHeight;
@end
@implementation IMCVideoStreamServer
- (instancetype)initWithCanvasView:(IMCCanvasView *)canvasView {
self = [super init];
if (self) {
_canvasView = canvasView;
_startedAt = [NSDate date];
_lastProtocol = @"none";
}
return self;
}
- (void)start {
if (self.running) {
return;
}
self.running = YES;
[NSThread detachNewThreadSelector:@selector(tcpLoop) toTarget:self withObject:nil];
[NSThread detachNewThreadSelector:@selector(udpLoop) toTarget:self withObject:nil];
[NSThread detachNewThreadSelector:@selector(discoveryLoop) toTarget:self withObject:nil];
[NSThread detachNewThreadSelector:@selector(colorTCPLoop) toTarget:self withObject:nil];
}
- (BOOL)readExactly:(NSUInteger)length fromSocket:(int)socketFD intoData:(NSMutableData *)data {
[data setLength:length];
NSUInteger received = 0;
while (self.running && received < length) {
ssize_t count = recv(socketFD,
(uint8_t *)data.mutableBytes + received,
length - received,
0);
if (count <= 0) {
return NO;
}
received += (NSUInteger)count;
}
return received == length;
}
- (void)colorTCPLoop {
@autoreleasepool {
int serverSocket = socket(AF_INET, SOCK_STREAM, 0);
if (serverSocket < 0) {
return;
}
int yes = 1;
setsockopt(serverSocket, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
struct sockaddr_in address;
memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons((uint16_t)IMCColorStreamPort);
if (bind(serverSocket, (struct sockaddr *)&address, sizeof(address)) < 0 ||
listen(serverSocket, 1) < 0) {
close(serverSocket);
return;
}
while (self.running) {
@autoreleasepool {
int clientSocket = accept(serverSocket, NULL, NULL);
if (clientSocket < 0) {
continue;
}
@synchronized (self) {
self.colorConnections += 1;
}
NSMutableData *header = [NSMutableData data];
while ([self readExactly:IMCColorHeaderSize
fromSocket:clientSocket
intoData:header]) {
const uint8_t *bytes = header.bytes;
if (memcmp(bytes, "IMCR", 4) != 0 || bytes[4] != 1 || bytes[5] != 1) {
break;
}
NSUInteger width = ((NSUInteger)bytes[6] << 8) | bytes[7];
NSUInteger height = ((NSUInteger)bytes[8] << 8) | bytes[9];
uint32_t payloadLength = ((uint32_t)bytes[10] << 24) |
((uint32_t)bytes[11] << 16) |
((uint32_t)bytes[12] << 8) |
bytes[13];
if (width == 0 || height == 0 || width > 2048 || height > 2048 ||
payloadLength != width * height * 2 || payloadLength > 8 * 1024 * 1024) {
break;
}
NSMutableData *payload = [NSMutableData data];
if (![self readExactly:payloadLength
fromSocket:clientSocket
intoData:payload]) {
break;
}
NSData *snapshot = [payload copy];
@synchronized (self) {
self.colorFrames += 1;
self.colorBytes += payloadLength;
self.lastColorWidth = width;
self.lastColorHeight = height;
self.lastProtocol = @"color_tcp";
self.lastFrameAt = [NSDate date];
}
dispatch_async(dispatch_get_main_queue(), ^{
[self.canvasView displayRGB565FrameBuffer:snapshot width:width height:height];
});
}
close(clientSocket);
}
}
close(serverSocket);
}
}
- (void)tcpLoop {
@autoreleasepool {
int serverSocket = socket(AF_INET, SOCK_STREAM, 0);
if (serverSocket < 0) {
return;
}
int yes = 1;
setsockopt(serverSocket, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
struct sockaddr_in address;
memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(8081);
if (bind(serverSocket, (struct sockaddr *)&address, sizeof(address)) < 0) {
close(serverSocket);
return;
}
if (listen(serverSocket, 1) < 0) {
close(serverSocket);
return;
}
while (self.running) {
@autoreleasepool {
int clientSocket = accept(serverSocket, NULL, NULL);
if (clientSocket < 0) {
continue;
}
[self noteTCPConnection];
NSMutableData *frame = [NSMutableData dataWithLength:IMCFrameSize];
NSUInteger received = 0;
while (self.running) {
ssize_t count = recv(clientSocket,
(uint8_t *)frame.mutableBytes + received,
IMCFrameSize - received,
0);
if (count <= 0) {
break;
}
received += (NSUInteger)count;
[self noteTCPBytes:(NSUInteger)count];
if (received == IMCFrameSize) {
[self displayFrame:frame protocol:@"tcp"];
received = 0;
}
}
close(clientSocket);
}
}
close(serverSocket);
}
}
- (void)udpLoop {
@autoreleasepool {
int udpSocket = socket(AF_INET, SOCK_DGRAM, 0);
if (udpSocket < 0) {
return;
}
int yes = 1;
setsockopt(udpSocket, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
struct sockaddr_in address;
memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(8082);
if (bind(udpSocket, (struct sockaddr *)&address, sizeof(address)) < 0) {
close(udpSocket);
return;
}
NSMutableData *frame = [NSMutableData dataWithLength:IMCFrameSize];
uint8_t currentFrameID = 0;
uint16_t chunksReceived = 0;
BOOL hasFrame = NO;
uint8_t packet[1002];
while (self.running) {
ssize_t count = recvfrom(udpSocket, packet, sizeof(packet), 0, NULL, NULL);
if (count < 2) {
[self noteInvalidUDPPacket];
continue;
}
uint8_t frameID = packet[0];
uint8_t chunkIndex = packet[1];
if (chunkIndex >= IMCUDPChunkCount || count < 2 + IMCUDPChunkSize) {
[self noteInvalidUDPPacket];
continue;
}
[self noteUDPPacketBytes:(NSUInteger)count];
if (!hasFrame || frameID != currentFrameID) {
currentFrameID = frameID;
chunksReceived = 0;
hasFrame = YES;
}
memcpy((uint8_t *)frame.mutableBytes + chunkIndex * IMCUDPChunkSize,
packet + 2,
IMCUDPChunkSize);
chunksReceived |= (uint16_t)(1 << chunkIndex);
if (chunksReceived == 0x7fff) {
[self displayFrame:frame protocol:@"udp"];
chunksReceived = 0;
}
}
close(udpSocket);
}
}
- (void)discoveryLoop {
@autoreleasepool {
int udpSocket = socket(AF_INET, SOCK_DGRAM, 0);
if (udpSocket < 0) {
return;
}
int yes = 1;
setsockopt(udpSocket, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
setsockopt(udpSocket, SOL_SOCKET, SO_BROADCAST, &yes, sizeof(yes));
struct sockaddr_in address;
memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(5000);
if (bind(udpSocket, (struct sockaddr *)&address, sizeof(address)) < 0) {
close(udpSocket);
return;
}
char buffer[64];
while (self.running) {
struct sockaddr_in clientAddress;
socklen_t clientLength = sizeof(clientAddress);
ssize_t count = recvfrom(udpSocket,
buffer,
sizeof(buffer),
0,
(struct sockaddr *)&clientAddress,
&clientLength);
if (count == 15 && memcmp(buffer, "DISCOVER_SCREEN", 15) == 0) {
[self noteDiscoveryRequest];
const char *reply = "SCREEN_IP_8080";
sendto(udpSocket,
reply,
strlen(reply),
0,
(struct sockaddr *)&clientAddress,
clientLength);
}
}
close(udpSocket);
}
}
- (void)displayFrame:(NSData *)frame protocol:(NSString *)protocol {
[self noteFrameForProtocol:protocol];
NSData *snapshot = [frame copy];
dispatch_async(dispatch_get_main_queue(), ^{
[self.canvasView displayMonochromeFrameBuffer:snapshot];
});
}
- (NSDictionary *)streamStats {
@synchronized (self) {
NSTimeInterval uptime = [[NSDate date] timeIntervalSinceDate:self.startedAt ?: [NSDate date]];
NSTimeInterval secondsSinceLastFrame = self.lastFrameAt ? [[NSDate date] timeIntervalSinceDate:self.lastFrameAt] : -1.0;
return @{
@"debug_enabled": @(self.debugEnabled),
@"running": @(self.running),
@"tcp_port": @8081,
@"udp_port": @8082,
@"color_tcp_port": @(IMCColorStreamPort),
@"discovery_port": @5000,
@"frame_size_bytes": @(IMCFrameSize),
@"udp_chunk_size_bytes": @(IMCUDPChunkSize),
@"udp_chunks_per_frame": @(IMCUDPChunkCount),
@"uptime_sec": @((NSInteger)uptime),
@"tcp_connections": @(self.tcpConnections),
@"tcp_frames": @(self.tcpFrames),
@"tcp_bytes": @(self.tcpBytes),
@"udp_frames": @(self.udpFrames),
@"udp_packets": @(self.udpPackets),
@"udp_bytes": @(self.udpBytes),
@"invalid_udp_packets": @(self.invalidUDPPackets),
@"discovery_requests": @(self.discoveryRequests),
@"color_connections": @(self.colorConnections),
@"color_frames": @(self.colorFrames),
@"color_bytes": @(self.colorBytes),
@"last_color_width": @(self.lastColorWidth),
@"last_color_height": @(self.lastColorHeight),
@"last_protocol": self.lastProtocol ?: @"none",
@"seconds_since_last_frame": @(secondsSinceLastFrame)
};
}
}
- (void)resetStreamStats {
@synchronized (self) {
self.startedAt = [NSDate date];
self.lastFrameAt = nil;
self.lastProtocol = @"none";
self.tcpConnections = 0;
self.tcpFrames = 0;
self.tcpBytes = 0;
self.udpFrames = 0;
self.udpPackets = 0;
self.udpBytes = 0;
self.invalidUDPPackets = 0;
self.discoveryRequests = 0;
self.colorConnections = 0;
self.colorFrames = 0;
self.colorBytes = 0;
self.lastColorWidth = 0;
self.lastColorHeight = 0;
}
}
- (void)setDebugEnabled:(BOOL)enabled {
@synchronized (self) {
_debugEnabled = enabled;
}
}
- (void)noteTCPConnection {
@synchronized (self) {
self.tcpConnections += 1;
if (self.debugEnabled) {
NSLog(@"IPhoneMCP stream: TCP client connected");
}
}
}
- (void)noteTCPBytes:(NSUInteger)count {
@synchronized (self) {
self.tcpBytes += count;
}
}
- (void)noteUDPPacketBytes:(NSUInteger)count {
@synchronized (self) {
self.udpPackets += 1;
self.udpBytes += count;
}
}
- (void)noteInvalidUDPPacket {
@synchronized (self) {
self.invalidUDPPackets += 1;
if (self.debugEnabled) {
NSLog(@"IPhoneMCP stream: invalid UDP packet");
}
}
}
- (void)noteDiscoveryRequest {
@synchronized (self) {
self.discoveryRequests += 1;
if (self.debugEnabled) {
NSLog(@"IPhoneMCP stream: discovery request");
}
}
}
- (void)noteFrameForProtocol:(NSString *)protocol {
@synchronized (self) {
if ([protocol isEqualToString:@"tcp"]) {
self.tcpFrames += 1;
} else if ([protocol isEqualToString:@"udp"]) {
self.udpFrames += 1;
}
self.lastProtocol = protocol ?: @"none";
self.lastFrameAt = [NSDate date];
if (self.debugEnabled) {
NSLog(@"IPhoneMCP stream: %@ frame displayed", self.lastProtocol);
}
}
}
@end
+5
View File
@@ -0,0 +1,5 @@
#import <UIKit/UIKit.h>
@interface IMCViewController : UIViewController
- (NSDictionary *)handleRPCRequest:(NSDictionary *)request;
@end
+846
View File
@@ -0,0 +1,846 @@
#import "IMCViewController.h"
#import "IMCCanvasView.h"
#import "IMCMCPServer.h"
#import "IMCVideoStreamServer.h"
#import <AVFoundation/AVFoundation.h>
#import <math.h>
@interface IMCPhotoCaptureDelegate : NSObject <AVCapturePhotoCaptureDelegate>
@property (nonatomic, copy) void (^completion)(NSData *jpegData, NSError *error);
@end
@implementation IMCPhotoCaptureDelegate
- (void)captureOutput:(AVCapturePhotoOutput *)output
didFinishProcessingPhoto:(AVCapturePhoto *)photo
error:(NSError *)error {
NSData *data = error ? nil : [photo fileDataRepresentation];
if (self.completion) {
self.completion(data, error);
}
}
@end
@interface IMCViewController ()
@property (nonatomic, strong) IMCCanvasView *canvasView;
@property (nonatomic, strong) IMCMCPServer *server;
@property (nonatomic, strong) IMCVideoStreamServer *videoStreamServer;
@property (nonatomic, strong) AVAudioPlayer *audioPlayer;
@property (nonatomic, strong) AVAudioRecorder *audioRecorder;
@end
@implementation IMCViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor colorWithRed:0.03 green:0.06 blue:0.12 alpha:1.0];
self.canvasView = [[IMCCanvasView alloc] initWithFrame:CGRectZero];
self.canvasView.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:self.canvasView];
[NSLayoutConstraint activateConstraints:@[
[self.canvasView.topAnchor constraintEqualToAnchor:self.view.topAnchor],
[self.canvasView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
[self.canvasView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[self.canvasView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor]
]];
[self.canvasView clearWithColor:0];
[self.canvasView drawText:@"iPhone MCP landscape server ready" x:18 y:18 size:2];
[self.canvasView drawText:@"POST /api/mcp on port 8080" x:18 y:58 size:1];
self.server = [[IMCMCPServer alloc] initWithViewController:self port:8080];
[self.server start];
self.videoStreamServer = [[IMCVideoStreamServer alloc] initWithCanvasView:self.canvasView];
[self.videoStreamServer start];
}
- (NSDictionary *)handleRPCRequest:(NSDictionary *)request {
__block NSDictionary *response = nil;
dispatch_sync(dispatch_get_main_queue(), ^{
response = [self handleRPCRequestOnMainThread:request];
});
return response;
}
- (NSDictionary *)handleRPCRequestOnMainThread:(NSDictionary *)request {
id rpcID = request[@"id"] ?: [NSNull null];
NSString *method = request[@"method"];
if ([method isEqualToString:@"initialize"]) {
return [self result:@{
@"protocolVersion": @"2024-11-05",
@"capabilities": @{@"tools": @{}},
@"serverInfo": @{@"name": @"iphone6-mcp", @"version": @"0.0.4"}
} rpcID:rpcID];
}
if ([method isEqualToString:@"notifications/initialized"]) {
return [self result:@{} rpcID:rpcID];
}
if ([method isEqualToString:@"tools/list"]) {
return [self result:@{@"tools": [self tools]} rpcID:rpcID];
}
if ([method isEqualToString:@"tools/call"]) {
NSDictionary *params = request[@"params"] ?: @{};
NSString *name = params[@"name"];
NSDictionary *arguments = params[@"arguments"] ?: @{};
return [self callTool:name arguments:arguments rpcID:rpcID];
}
return [self errorWithCode:-32601 message:[NSString stringWithFormat:@"Method %@ not found", method] rpcID:rpcID];
}
- (NSDictionary *)callTool:(NSString *)name arguments:(NSDictionary *)arguments rpcID:(id)rpcID {
@try {
if ([name isEqualToString:@"clear_screen"]) {
NSInteger color = [arguments[@"color"] integerValue];
[self.canvasView clearWithColor:color];
return [self textResult:@"Screen cleared." rpcID:rpcID];
}
if ([name isEqualToString:@"draw_text"]) {
NSString *text = [NSString stringWithFormat:@"%@", arguments[@"text"] ?: @""];
CGFloat x = [arguments[@"x"] doubleValue];
CGFloat y = [arguments[@"y"] doubleValue];
NSInteger size = arguments[@"size"] ? [arguments[@"size"] integerValue] : 1;
[self.canvasView drawText:text x:x y:y size:size];
return [self textResult:[NSString stringWithFormat:@"Drew text '%@' at (%.0f, %.0f).", text, x, y] rpcID:rpcID];
}
if ([name isEqualToString:@"draw_image"]) {
NSString *base64 = [NSString stringWithFormat:@"%@", arguments[@"image_base64"] ?: @""];
NSRange marker = [base64 rangeOfString:@";base64,"];
if (marker.location != NSNotFound) {
base64 = [base64 substringFromIndex:marker.location + marker.length];
}
NSData *data = [[NSData alloc] initWithBase64EncodedString:base64 options:NSDataBase64DecodingIgnoreUnknownCharacters];
if (!data) {
return [self errorWithCode:-32602 message:@"Invalid image_base64." rpcID:rpcID];
}
[self.canvasView drawImageData:data
x:[arguments[@"x"] doubleValue]
y:[arguments[@"y"] doubleValue]];
return [self textResult:@"Image displayed." rpcID:rpcID];
}
if ([name isEqualToString:@"get_screenshot"]) {
NSString *png = [self.canvasView snapshotPNGBase64];
return [self result:@{@"content": @[@{@"type": @"image", @"data": png, @"mimeType": @"image/png"}]} rpcID:rpcID];
}
if ([name isEqualToString:@"get_battery"]) {
UIDevice *device = [UIDevice currentDevice];
device.batteryMonitoringEnabled = YES;
NSDictionary *battery = @{
@"level_pct": @(MAX(device.batteryLevel, 0.0) * 100.0),
@"state": [self batteryStateName:device.batteryState]
};
return [self textResult:[self jsonString:battery] rpcID:rpcID];
}
if ([name isEqualToString:@"get_sensors"] || [name isEqualToString:@"scan_ble"] || [name isEqualToString:@"set_led"]) {
return [self textResult:[NSString stringWithFormat:@"%@ is not implemented in this first iPhone build.", name] rpcID:rpcID];
}
if ([name isEqualToString:@"play_tone"]) {
NSInteger frequency = arguments[@"frequency"] ? [arguments[@"frequency"] integerValue] : 440;
NSInteger duration = arguments[@"duration_ms"] ? [arguments[@"duration_ms"] integerValue] : 1000;
NSInteger volume = arguments[@"volume"] ? [arguments[@"volume"] integerValue] : 50;
frequency = MAX(50, MIN(10000, frequency));
duration = MAX(50, MIN(5000, duration));
volume = MAX(0, MIN(100, volume));
NSData *toneData = [self wavDataForToneFrequency:frequency
durationMS:duration
volume:volume];
NSDictionary *error = [self playAudioData:toneData volume:volume];
if (error) {
return [self errorWithCode:[error[@"code"] integerValue]
message:error[@"message"]
rpcID:rpcID];
}
return [self textResult:[NSString stringWithFormat:@"Played tone of %ldHz for %ldms at volume %ld.",
(long)frequency,
(long)duration,
(long)volume]
rpcID:rpcID];
}
if ([name isEqualToString:@"play_audio_base64"]) {
NSString *base64 = [self base64PayloadFromString:[NSString stringWithFormat:@"%@", arguments[@"wav_base64"] ?: @""]];
NSData *audioData = [[NSData alloc] initWithBase64EncodedString:base64
options:NSDataBase64DecodingIgnoreUnknownCharacters];
if (!audioData) {
return [self errorWithCode:-32602 message:@"Invalid wav_base64." rpcID:rpcID];
}
NSInteger volumeArg = arguments[@"volume"] ? [arguments[@"volume"] integerValue] : 50;
volumeArg = MAX(0, MIN(100, volumeArg));
NSDictionary *error = [self playAudioData:audioData volume:volumeArg];
if (error) {
return [self errorWithCode:[error[@"code"] integerValue]
message:error[@"message"]
rpcID:rpcID];
}
return [self textResult:[NSString stringWithFormat:@"Playing base64 WAV audio (%lu bytes) at volume %ld.",
(unsigned long)audioData.length,
(long)volumeArg]
rpcID:rpcID];
}
if ([name isEqualToString:@"play_audio"]) {
NSString *filename = [NSString stringWithFormat:@"%@", arguments[@"filename"] ?: @""];
NSURL *url = [self safeFileURLForPath:filename];
if (!url) {
return [self errorWithCode:-32602
message:@"filename must be relative and cannot contain '..'."
rpcID:rpcID];
}
NSData *audioData = [NSData dataWithContentsOfURL:url];
if (!audioData) {
return [self errorWithCode:-32000
message:[NSString stringWithFormat:@"Could not read audio file '%@' from Documents/MCPFiles.", filename]
rpcID:rpcID];
}
NSInteger volumeArg = arguments[@"volume"] ? [arguments[@"volume"] integerValue] : 50;
volumeArg = MAX(0, MIN(100, volumeArg));
NSDictionary *error = [self playAudioData:audioData volume:volumeArg];
if (error) {
return [self errorWithCode:[error[@"code"] integerValue]
message:error[@"message"]
rpcID:rpcID];
}
return [self textResult:[NSString stringWithFormat:@"Playing WAV file '%@' (%lu bytes) at volume %ld.",
filename,
(unsigned long)audioData.length,
(long)volumeArg]
rpcID:rpcID];
}
if ([name isEqualToString:@"hermes_voice_turn"]) {
NSDictionary *voiceResult = [self performHermesVoiceTurn:arguments];
if (voiceResult[@"error"]) {
return [self errorWithCode:-32000 message:voiceResult[@"error"] rpcID:rpcID];
}
return [self textResult:[self jsonString:voiceResult] rpcID:rpcID];
}
if ([name isEqualToString:@"record_voice"]) {
if (![self ensureMicrophonePermission]) {
return [self errorWithCode:-32000
message:@"Microphone permission is not granted for iPhone MCP."
rpcID:rpcID];
}
NSInteger duration = arguments[@"duration_sec"] ? [arguments[@"duration_sec"] integerValue] : 4;
duration = MAX(1, MIN(15, duration));
NSString *filename = [NSString stringWithFormat:@"%@", arguments[@"filename"] ?: @"recording.wav"];
NSURL *url = [self safeFileURLForPath:filename];
if (!url) {
return [self errorWithCode:-32602
message:@"filename must be relative and cannot contain '..'."
rpcID:rpcID];
}
NSDictionary *result = [self recordVoiceToURL:url duration:duration];
if (result[@"error"]) {
return [self errorWithCode:-32000 message:result[@"error"] rpcID:rpcID];
}
return [self textResult:[self jsonString:result] rpcID:rpcID];
}
if ([name isEqualToString:@"capture_selfie"]) {
if (![self ensureCameraPermission]) {
return [self errorWithCode:-32000
message:@"Camera permission is not granted for iPhone MCP."
rpcID:rpcID];
}
NSDictionary *result = [self captureSelfiePNGBase64];
if (result[@"error"]) {
return [self errorWithCode:-32000 message:result[@"error"] rpcID:rpcID];
}
return [self result:@{@"content": @[@{@"type": @"image",
@"data": result[@"png_base64"],
@"mimeType": @"image/png"}]} rpcID:rpcID];
}
if ([name isEqualToString:@"write_file"]) {
NSString *path = [NSString stringWithFormat:@"%@", arguments[@"path"] ?: @""];
NSString *content = [NSString stringWithFormat:@"%@", arguments[@"content"] ?: @""];
NSURL *url = [self safeFileURLForPath:path];
if (!url) {
return [self errorWithCode:-32602 message:@"Path must be relative and cannot contain '..'." rpcID:rpcID];
}
[[NSFileManager defaultManager] createDirectoryAtURL:[url URLByDeletingLastPathComponent]
withIntermediateDirectories:YES
attributes:nil
error:nil];
NSError *writeError = nil;
[content writeToURL:url atomically:YES encoding:NSUTF8StringEncoding error:&writeError];
if (writeError) {
return [self errorWithCode:-32000 message:writeError.localizedDescription rpcID:rpcID];
}
return [self textResult:[NSString stringWithFormat:@"Wrote %lu characters to %@.", (unsigned long)content.length, path] rpcID:rpcID];
}
if ([name isEqualToString:@"read_file"]) {
NSString *path = [NSString stringWithFormat:@"%@", arguments[@"path"] ?: @""];
NSURL *url = [self safeFileURLForPath:path];
if (!url) {
return [self errorWithCode:-32602 message:@"Path must be relative and cannot contain '..'." rpcID:rpcID];
}
NSError *readError = nil;
NSString *content = [NSString stringWithContentsOfURL:url encoding:NSUTF8StringEncoding error:&readError];
if (readError) {
return [self errorWithCode:-32000 message:readError.localizedDescription rpcID:rpcID];
}
return [self textResult:content rpcID:rpcID];
}
if ([name isEqualToString:@"download_file"]) {
NSString *urlString = [NSString stringWithFormat:@"%@", arguments[@"url"] ?: @""];
NSString *filename = [NSString stringWithFormat:@"%@", arguments[@"filename"] ?: @""];
BOOL useSD = arguments[@"use_sd"] ? [arguments[@"use_sd"] boolValue] : NO;
NSURL *sourceURL = [NSURL URLWithString:urlString];
NSURL *destinationURL = [self safeFileURLForPath:filename];
if (!sourceURL || !destinationURL) {
return [self errorWithCode:-32602 message:@"Invalid url or filename." rpcID:rpcID];
}
NSData *data = [NSData dataWithContentsOfURL:sourceURL];
if (!data) {
return [self errorWithCode:-32000 message:@"Download failed." rpcID:rpcID];
}
[[NSFileManager defaultManager] createDirectoryAtURL:[destinationURL URLByDeletingLastPathComponent]
withIntermediateDirectories:YES
attributes:nil
error:nil];
[data writeToURL:destinationURL atomically:YES];
NSString *storageNote = useSD ? @" The iPhone build has no SD card, so use_sd was accepted and stored in app Documents instead." : @"";
return [self textResult:[NSString stringWithFormat:@"Downloaded %lu bytes to %@.%@", (unsigned long)data.length, filename, storageNote] rpcID:rpcID];
}
if ([name isEqualToString:@"get_video_streaming_instructions"]) {
NSString *protocol = [[NSString stringWithFormat:@"%@", arguments[@"protocol"] ?: @"both"] lowercaseString];
return [self textResult:[self videoStreamingInstructionsForProtocol:protocol] rpcID:rpcID];
}
if ([name isEqualToString:@"get_stream_stats"]) {
return [self textResult:[self jsonString:[self.videoStreamServer streamStats]] rpcID:rpcID];
}
if ([name isEqualToString:@"reset_stream_stats"]) {
[self.videoStreamServer resetStreamStats];
return [self textResult:[self jsonString:[self.videoStreamServer streamStats]] rpcID:rpcID];
}
if ([name isEqualToString:@"set_stream_debug"]) {
BOOL enabled = arguments[@"enabled"] ? [arguments[@"enabled"] boolValue] : NO;
[self.videoStreamServer setDebugEnabled:enabled];
return [self textResult:[self jsonString:@{@"debug_enabled": @(enabled)}] rpcID:rpcID];
}
if ([name isEqualToString:@"execute_python"]) {
return [self textResult:@"execute_python is available on the MicroPython ESP32 target, but the native iPhone app does not execute arbitrary Python code. Use write_file/read_file/download_file for files and the dedicated drawing/audio/camera tools for app actions." rpcID:rpcID];
}
return [self errorWithCode:-32602 message:[NSString stringWithFormat:@"Unknown tool: %@", name] rpcID:rpcID];
} @catch (NSException *exception) {
return [self errorWithCode:-32000 message:exception.reason ?: @"Tool failed." rpcID:rpcID];
}
}
- (NSArray *)tools {
return @[
[self tool:@"clear_screen" description:@"Clear the iPhone canvas to white (0) or black (1)." schema:@{@"type": @"object", @"properties": @{@"color": @{@"type": @"integer", @"enum": @[@0, @1]}}, @"required": @[@"color"]}],
[self tool:@"draw_text" description:@"Draw text on the iPhone canvas using 400x300 logical coordinates." schema:@{@"type": @"object", @"properties": @{@"text": @{@"type": @"string"}, @"x": @{@"type": @"integer"}, @"y": @{@"type": @"integer"}, @"size": @{@"type": @"integer", @"enum": @[@1, @2]}}, @"required": @[@"text", @"x", @"y"]}],
[self tool:@"draw_image" description:@"Draw a base64 PNG/JPEG/GIF image on the iPhone canvas." schema:@{@"type": @"object", @"properties": @{@"image_base64": @{@"type": @"string"}, @"x": @{@"type": @"integer", @"default": @0}, @"y": @{@"type": @"integer", @"default": @0}}, @"required": @[@"image_base64"]}],
[self tool:@"get_screenshot" description:@"Return the current iPhone canvas as a PNG image." schema:@{@"type": @"object", @"properties": @{}}],
[self tool:@"get_battery" description:@"Read the iPhone battery percentage and charge state." schema:@{@"type": @"object", @"properties": @{}}],
[self tool:@"play_tone" description:@"Generate and play a sine-wave tone with the requested frequency, duration, and volume." schema:@{@"type": @"object", @"properties": @{@"frequency": @{@"type": @"integer", @"default": @440}, @"duration_ms": @{@"type": @"integer", @"default": @1000}, @"volume": @{@"type": @"integer", @"default": @50}}}],
[self tool:@"play_audio_base64" description:@"Decode a base64 WAV payload and play it through the iPhone speaker." schema:@{@"type": @"object", @"properties": @{@"wav_base64": @{@"type": @"string"}, @"volume": @{@"type": @"integer", @"default": @50}}, @"required": @[@"wav_base64"]}],
[self tool:@"play_audio" description:@"Play a WAV file from the app's Documents/MCPFiles directory." schema:@{@"type": @"object", @"properties": @{@"filename": @{@"type": @"string"}, @"volume": @{@"type": @"integer", @"default": @50}}, @"required": @[@"filename"]}],
[self tool:@"hermes_voice_turn" description:@"Record a voice command, send it to the Hermes voice gateway, play the returned WAV, and return transcript/response diagnostics." schema:@{@"type": @"object", @"properties": @{@"url": @{@"type": @"string", @"default": @"http://192.168.68.126:8642/api/esp32/voice"}, @"api_token": @{@"type": @"string"}, @"device_id": @{@"type": @"string", @"default": @"iphone6"}, @"duration_sec": @{@"type": @"integer", @"default": @4}, @"volume": @{@"type": @"integer", @"default": @80}, @"instructions": @{@"type": @"string"}, @"reply_mode": @{@"type": @"string", @"enum": @[@"sync", @"ack"], @"default": @"sync"}, @"screen_url": @{@"type": @"string"}}}],
[self tool:@"record_voice" description:@"Record microphone audio to a WAV file inside Documents/MCPFiles." schema:@{@"type": @"object", @"properties": @{@"duration_sec": @{@"type": @"integer", @"default": @4}, @"filename": @{@"type": @"string", @"default": @"recording.wav"}}}],
[self tool:@"capture_selfie" description:@"Capture a still image from the front camera and return it as PNG image content." schema:@{@"type": @"object", @"properties": @{}}],
[self tool:@"write_file" description:@"Write a UTF-8 file inside the app's Documents/MCPFiles directory." schema:@{@"type": @"object", @"properties": @{@"path": @{@"type": @"string"}, @"content": @{@"type": @"string"}}, @"required": @[@"path", @"content"]}],
[self tool:@"read_file" description:@"Read a UTF-8 file from the app's Documents/MCPFiles directory." schema:@{@"type": @"object", @"properties": @{@"path": @{@"type": @"string"}}, @"required": @[@"path"]}],
[self tool:@"download_file" description:@"Download a URL into the app's Documents/MCPFiles directory. Accepts ESP32-compatible use_sd, but stores locally on iPhone." schema:@{@"type": @"object", @"properties": @{@"url": @{@"type": @"string"}, @"filename": @{@"type": @"string"}, @"use_sd": @{@"type": @"boolean", @"default": @NO}}, @"required": @[@"url", @"filename"]}],
[self tool:@"get_video_streaming_instructions" description:@"Get details for monochrome TCP/UDP streaming and RGB565 color TCP streaming." schema:@{@"type": @"object", @"properties": @{@"protocol": @{@"type": @"string", @"enum": @[@"tcp", @"udp", @"color", @"both", @"all"], @"default": @"all"}}}],
[self tool:@"get_stream_stats" description:@"Return TCP/UDP streaming counters, ports, uptime, last protocol, and debug state." schema:@{@"type": @"object", @"properties": @{}}],
[self tool:@"reset_stream_stats" description:@"Reset TCP/UDP streaming counters and last-frame state." schema:@{@"type": @"object", @"properties": @{}}],
[self tool:@"set_stream_debug" description:@"Enable or disable stream debug logging on the iPhone." schema:@{@"type": @"object", @"properties": @{@"enabled": @{@"type": @"boolean", @"default": @NO}}}],
[self tool:@"execute_python" description:@"ESP32 compatibility stub; arbitrary Python execution is intentionally not implemented in the native iPhone app." schema:@{@"type": @"object", @"properties": @{@"code": @{@"type": @"string"}}, @"required": @[@"code"]}],
[self tool:@"set_led" description:@"ESP32 compatibility stub; the iPhone 6 has no NeoPixel LED." schema:@{@"type": @"object", @"properties": @{}}],
[self tool:@"get_sensors" description:@"ESP32 compatibility stub; SHTC3 temperature/humidity is not present." schema:@{@"type": @"object", @"properties": @{}}],
[self tool:@"scan_ble" description:@"ESP32 compatibility stub; BLE scanning is not implemented in this first build." schema:@{@"type": @"object", @"properties": @{}}]
];
}
- (NSDictionary *)tool:(NSString *)name description:(NSString *)description schema:(NSDictionary *)schema {
return @{@"name": name, @"description": description, @"inputSchema": schema};
}
- (NSDictionary *)textResult:(NSString *)text rpcID:(id)rpcID {
return [self result:@{@"content": @[@{@"type": @"text", @"text": text ?: @""}]} rpcID:rpcID];
}
- (NSDictionary *)result:(NSDictionary *)result rpcID:(id)rpcID {
return @{@"jsonrpc": @"2.0", @"result": result ?: @{}, @"id": rpcID ?: [NSNull null]};
}
- (NSDictionary *)errorWithCode:(NSInteger)code message:(NSString *)message rpcID:(id)rpcID {
return @{@"jsonrpc": @"2.0",
@"error": @{@"code": @(code), @"message": message ?: @"Error"},
@"id": rpcID ?: [NSNull null]};
}
- (NSString *)jsonString:(id)object {
NSData *data = [NSJSONSerialization dataWithJSONObject:object options:0 error:nil];
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @"{}";
}
- (NSString *)base64PayloadFromString:(NSString *)string {
NSRange marker = [string rangeOfString:@";base64,"];
if (marker.location != NSNotFound) {
return [string substringFromIndex:marker.location + marker.length];
}
return string ?: @"";
}
- (NSDictionary *)playAudioData:(NSData *)audioData volume:(NSInteger)volume {
NSError *sessionError = nil;
[[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error:&sessionError];
[[AVAudioSession sharedInstance] setActive:YES error:&sessionError];
NSError *playerError = nil;
self.audioPlayer = [[AVAudioPlayer alloc] initWithData:audioData error:&playerError];
if (!self.audioPlayer || playerError) {
return @{@"code": @-32000,
@"message": playerError.localizedDescription ?: @"Failed to create audio player."};
}
self.audioPlayer.volume = MAX(0.0, MIN(1.0, volume / 100.0));
[self.audioPlayer prepareToPlay];
[self.audioPlayer play];
return nil;
}
- (NSString *)headerValue:(NSString *)name fromResponse:(NSHTTPURLResponse *)response {
for (id key in response.allHeaderFields) {
if ([[NSString stringWithFormat:@"%@", key] caseInsensitiveCompare:name] == NSOrderedSame) {
return [NSString stringWithFormat:@"%@", response.allHeaderFields[key]];
}
}
return @"";
}
- (NSDictionary *)performHermesVoiceTurn:(NSDictionary *)arguments {
if (![self ensureMicrophonePermission]) {
return @{@"error": @"Microphone permission is not granted for iPhone MCP."};
}
NSInteger duration = arguments[@"duration_sec"] ? [arguments[@"duration_sec"] integerValue] : 4;
duration = MAX(1, MIN(20, duration));
NSInteger volume = arguments[@"volume"] ? [arguments[@"volume"] integerValue] : 80;
volume = MAX(0, MIN(100, volume));
NSString *urlString = [NSString stringWithFormat:@"%@", arguments[@"url"] ?: @"http://192.168.68.126:8642/api/esp32/voice"];
NSURL *gatewayURL = [NSURL URLWithString:urlString];
if (!gatewayURL || ![@[@"http", @"https"] containsObject:gatewayURL.scheme.lowercaseString]) {
return @{@"error": @"Hermes url must be an http or https URL."};
}
NSURL *recordingURL = [self safeFileURLForPath:@"hermes/request.wav"];
NSDictionary *recording = [self recordVoiceToURL:recordingURL duration:duration];
if (recording[@"error"]) {
return recording;
}
NSData *wavData = [NSData dataWithContentsOfURL:recordingURL];
if (!wavData.length) {
return @{@"error": @"Hermes recording produced no audio data."};
}
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:gatewayURL
cachePolicy:NSURLRequestReloadIgnoringLocalCacheData
timeoutInterval:90.0];
request.HTTPMethod = @"POST";
request.HTTPBody = wavData;
[request setValue:@"audio/wav" forHTTPHeaderField:@"Content-Type"];
[request setValue:[NSString stringWithFormat:@"%@", arguments[@"device_id"] ?: @"iphone6"]
forHTTPHeaderField:@"X-Device-ID"];
[request setValue:[NSString stringWithFormat:@"%@", arguments[@"reply_mode"] ?: @"sync"]
forHTTPHeaderField:@"X-Hermes-Reply-Mode"];
NSString *instructions = [NSString stringWithFormat:@"%@", arguments[@"instructions"] ?: @""];
if (instructions.length) {
[request setValue:instructions forHTTPHeaderField:@"X-Hermes-Instructions"];
}
NSString *screenURL = [NSString stringWithFormat:@"%@", arguments[@"screen_url"] ?: @""];
if (screenURL.length) {
[request setValue:screenURL forHTTPHeaderField:@"X-Hermes-Screen-Url"];
}
NSString *token = [NSString stringWithFormat:@"%@", arguments[@"api_token"] ?: @""];
if (token.length) {
if (![token.lowercaseString hasPrefix:@"bearer "]) {
token = [@"Bearer " stringByAppendingString:token];
}
[request setValue:token forHTTPHeaderField:@"Authorization"];
}
NSURLResponse *rawResponse = nil;
NSError *requestError = nil;
NSData *responseData = [NSURLConnection sendSynchronousRequest:request
returningResponse:&rawResponse
error:&requestError];
if (requestError || !responseData) {
if ([requestError.domain isEqualToString:NSURLErrorDomain] &&
(requestError.code == NSURLErrorUserCancelledAuthentication ||
requestError.code == NSURLErrorUserAuthenticationRequired)) {
return @{@"error": @"Hermes authorization failed. Provide a valid api_token to hermes_voice_turn."};
}
return @{@"error": requestError.localizedDescription ?: @"Hermes request failed."};
}
NSHTTPURLResponse *response = (NSHTTPURLResponse *)rawResponse;
if (response.statusCode < 200 || response.statusCode >= 300) {
NSString *body = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding] ?: @"";
return @{@"error": [NSString stringWithFormat:@"Hermes returned HTTP %ld: %@",
(long)response.statusCode,
[body substringToIndex:MIN(body.length, 500)]]};
}
NSDictionary *playError = [self playAudioData:responseData volume:volume];
if (playError) {
return @{@"error": playError[@"message"] ?: @"Hermes audio playback failed."};
}
NSURL *responseURL = [self safeFileURLForPath:@"hermes/response.wav"];
[responseData writeToURL:responseURL atomically:YES];
return @{
@"status": @"playing",
@"http_status": @(response.statusCode),
@"request_bytes": @(wavData.length),
@"response_bytes": @(responseData.length),
@"transcript": [self headerValue:@"X-Hermes-Transcript" fromResponse:response],
@"response_text": [self headerValue:@"X-Hermes-Text-Response" fromResponse:response],
@"response_id": [self headerValue:@"X-Hermes-Response-Id" fromResponse:response],
@"conversation": [self headerValue:@"X-Hermes-Conversation" fromResponse:response],
@"reply_mode": [NSString stringWithFormat:@"%@", arguments[@"reply_mode"] ?: @"sync"],
@"saved_response": @"hermes/response.wav"
};
}
- (NSDictionary *)recordVoiceToURL:(NSURL *)url duration:(NSInteger)duration {
NSError *directoryError = nil;
[[NSFileManager defaultManager] createDirectoryAtURL:[url URLByDeletingLastPathComponent]
withIntermediateDirectories:YES
attributes:nil
error:&directoryError];
if (directoryError) {
return @{@"error": directoryError.localizedDescription};
}
NSError *sessionError = nil;
AVAudioSession *session = [AVAudioSession sharedInstance];
[session setCategory:AVAudioSessionCategoryPlayAndRecord error:&sessionError];
[session setActive:YES error:&sessionError];
if (sessionError) {
return @{@"error": sessionError.localizedDescription};
}
NSDictionary *settings = @{
AVFormatIDKey: @(kAudioFormatLinearPCM),
AVSampleRateKey: @16000,
AVNumberOfChannelsKey: @1,
AVLinearPCMBitDepthKey: @16,
AVLinearPCMIsFloatKey: @NO,
AVLinearPCMIsBigEndianKey: @NO
};
NSError *recordError = nil;
self.audioRecorder = [[AVAudioRecorder alloc] initWithURL:url
settings:settings
error:&recordError];
if (!self.audioRecorder || recordError) {
return @{@"error": recordError.localizedDescription ?: @"Failed to create audio recorder."};
}
[self.audioRecorder prepareToRecord];
[self.audioRecorder recordForDuration:duration];
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:duration + 0.25];
while (self.audioRecorder.recording && [deadline timeIntervalSinceNow] > 0) {
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
}
[self.audioRecorder stop];
NSDictionary *attributes = [[NSFileManager defaultManager] attributesOfItemAtPath:url.path error:nil];
NSNumber *bytes = attributes[NSFileSize] ?: @0;
return @{@"filename": url.lastPathComponent ?: @"recording.wav",
@"bytes": bytes,
@"duration_sec": @(duration)};
}
- (NSDictionary *)captureSelfiePNGBase64 {
AVCaptureDeviceDiscoverySession *discovery = [AVCaptureDeviceDiscoverySession discoverySessionWithDeviceTypes:@[
AVCaptureDeviceTypeBuiltInWideAngleCamera
]
mediaType:AVMediaTypeVideo
position:AVCaptureDevicePositionFront];
AVCaptureDevice *camera = discovery.devices.firstObject;
if (!camera) {
return @{@"error": @"Front camera not found."};
}
NSError *inputError = nil;
AVCaptureDeviceInput *input = [AVCaptureDeviceInput deviceInputWithDevice:camera error:&inputError];
if (!input || inputError) {
return @{@"error": inputError.localizedDescription ?: @"Failed to open front camera."};
}
AVCaptureSession *session = [[AVCaptureSession alloc] init];
session.sessionPreset = AVCaptureSessionPresetPhoto;
if (![session canAddInput:input]) {
return @{@"error": @"Could not add front camera input."};
}
[session addInput:input];
AVCaptureStillImageOutput *output = [[AVCaptureStillImageOutput alloc] init];
output.outputSettings = @{AVVideoCodecKey: AVVideoCodecJPEG};
if (![session canAddOutput:output]) {
return @{@"error": @"Could not add still image output."};
}
[session addOutput:output];
[session startRunning];
AVCaptureConnection *connection = [output connectionWithMediaType:AVMediaTypeVideo];
if (connection.isVideoOrientationSupported) {
connection.videoOrientation = AVCaptureVideoOrientationLandscapeRight;
}
__block NSData *jpegData = nil;
__block NSError *captureError = nil;
__block BOOL finished = NO;
[output captureStillImageAsynchronouslyFromConnection:connection
completionHandler:^(CMSampleBufferRef imageDataSampleBuffer, NSError *error) {
if (imageDataSampleBuffer) {
jpegData = [AVCaptureStillImageOutput jpegStillImageNSDataRepresentation:imageDataSampleBuffer];
}
captureError = error;
finished = YES;
}];
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:5.0];
while (!finished && [deadline timeIntervalSinceNow] > 0) {
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
}
[session stopRunning];
if (captureError || !jpegData) {
return @{@"error": captureError.localizedDescription ?: @"Selfie capture timed out."};
}
UIImage *image = [UIImage imageWithData:jpegData];
NSData *pngData = UIImagePNGRepresentation(image);
if (!pngData) {
return @{@"error": @"Failed to convert selfie to PNG."};
}
return @{@"png_base64": [pngData base64EncodedStringWithOptions:0]};
}
- (BOOL)ensureMicrophonePermission {
AVAudioSession *session = [AVAudioSession sharedInstance];
if (session.recordPermission == AVAudioSessionRecordPermissionGranted) {
return YES;
}
if (session.recordPermission == AVAudioSessionRecordPermissionDenied) {
return NO;
}
__block BOOL granted = NO;
__block BOOL finished = NO;
[session requestRecordPermission:^(BOOL didGrant) {
granted = didGrant;
finished = YES;
}];
return [self waitForPermissionResult:&finished granted:&granted];
}
- (BOOL)ensureCameraPermission {
AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
if (status == AVAuthorizationStatusAuthorized) {
return YES;
}
if (status == AVAuthorizationStatusDenied || status == AVAuthorizationStatusRestricted) {
return NO;
}
__block BOOL granted = NO;
__block BOOL finished = NO;
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo
completionHandler:^(BOOL didGrant) {
granted = didGrant;
finished = YES;
}];
return [self waitForPermissionResult:&finished granted:&granted];
}
- (BOOL)waitForPermissionResult:(BOOL *)finished granted:(BOOL *)granted {
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:30.0];
while (!*finished && [deadline timeIntervalSinceNow] > 0) {
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
}
return *finished && *granted;
}
- (NSData *)wavDataForToneFrequency:(NSInteger)frequency durationMS:(NSInteger)durationMS volume:(NSInteger)volume {
const uint32_t sampleRate = 22050;
const uint16_t channels = 1;
const uint16_t bitsPerSample = 16;
const uint16_t blockAlign = channels * bitsPerSample / 8;
const uint32_t byteRate = sampleRate * blockAlign;
uint32_t sampleCount = (uint32_t)((sampleRate * durationMS) / 1000);
uint32_t dataSize = sampleCount * blockAlign;
uint32_t riffSize = 36 + dataSize;
NSMutableData *data = [NSMutableData dataWithCapacity:44 + dataSize];
[self appendASCII:"RIFF" toData:data];
[self appendUInt32LE:riffSize toData:data];
[self appendASCII:"WAVE" toData:data];
[self appendASCII:"fmt " toData:data];
[self appendUInt32LE:16 toData:data];
[self appendUInt16LE:1 toData:data];
[self appendUInt16LE:channels toData:data];
[self appendUInt32LE:sampleRate toData:data];
[self appendUInt32LE:byteRate toData:data];
[self appendUInt16LE:blockAlign toData:data];
[self appendUInt16LE:bitsPerSample toData:data];
[self appendASCII:"data" toData:data];
[self appendUInt32LE:dataSize toData:data];
double amplitude = 32767.0 * MAX(0.0, MIN(1.0, volume / 100.0));
double phaseStep = 2.0 * M_PI * frequency / sampleRate;
for (uint32_t i = 0; i < sampleCount; i++) {
double fadeIn = MIN(1.0, i / (sampleRate * 0.01));
double fadeOut = MIN(1.0, (sampleCount - i) / (sampleRate * 0.01));
double envelope = MIN(fadeIn, fadeOut);
int16_t sample = (int16_t)(sin(i * phaseStep) * amplitude * envelope);
[self appendUInt16LE:(uint16_t)sample toData:data];
}
return data;
}
- (void)appendASCII:(const char *)string toData:(NSMutableData *)data {
[data appendBytes:string length:4];
}
- (void)appendUInt16LE:(uint16_t)value toData:(NSMutableData *)data {
uint8_t bytes[] = {value & 0xff, (value >> 8) & 0xff};
[data appendBytes:bytes length:sizeof(bytes)];
}
- (void)appendUInt32LE:(uint32_t)value toData:(NSMutableData *)data {
uint8_t bytes[] = {
value & 0xff,
(value >> 8) & 0xff,
(value >> 16) & 0xff,
(value >> 24) & 0xff
};
[data appendBytes:bytes length:sizeof(bytes)];
}
- (NSString *)videoStreamingInstructionsForProtocol:(NSString *)protocol {
NSMutableArray *lines = [NSMutableArray arrayWithArray:@[
@"### iPhone MCP Video Streaming Instructions",
@"Dimensions: 400x300, 1-bit monochrome, using the same 15,000-byte RLCD frame layout as the ESP32 server.",
@"The iPhone stretches each received frame over the full landscape screen.",
@"UDP discovery: send DISCOVER_SCREEN to port 5000; the iPhone replies SCREEN_IP_8080 because its JSON-RPC endpoint is on port 8080.",
@"Mapping logic (Python):",
@" def map_to_rlcd(pil_img):",
@" img_1bit = pil_img.convert('1', dither=1)",
@" px = img_1bit.load()",
@" buf = bytearray(15000)",
@" for y in range(300):",
@" for x in range(400):",
@" if px[x, y]:",
@" inv_y = 299 - y",
@" bx = x // 2",
@" by = inv_y // 4",
@" idx = bx * 75 + by",
@" lx, ly = x % 2, inv_y % 4",
@" bit = 7 - (ly * 2 + lx)",
@" buf[idx] |= (1 << bit)",
@" return buf"
]];
if ([protocol isEqualToString:@"tcp"] || [protocol isEqualToString:@"both"] || [protocol isEqualToString:@"all"]) {
[lines addObject:@"\n**TCP Streaming (Port 8081):**"];
[lines addObject:@"Open a TCP connection to the iPhone IP on port 8081 and send consecutive 15,000-byte frame blocks."];
}
if ([protocol isEqualToString:@"udp"] || [protocol isEqualToString:@"both"] || [protocol isEqualToString:@"all"]) {
[lines addObject:@"\n**UDP Streaming / Broadcast (Port 8082):**"];
[lines addObject:@"Split each 15,000-byte frame into 15 chunks of 1,000 bytes. Send each chunk as a 1002-byte packet: byte 0 = frame_id (0-255), byte 1 = chunk_idx (0-14), bytes 2..1001 = chunk payload."];
}
if ([protocol isEqualToString:@"color"] || [protocol isEqualToString:@"all"]) {
[lines addObject:@"\n**RGB565 Color TCP Streaming (Port 8083):**"];
[lines addObject:@"Send each frame as a 16-byte header followed by width*height*2 bytes of big-endian RGB565 pixels. Header: bytes 0..3='IMCR', byte 4=1 (version), byte 5=1 (RGB565BE), bytes 6..7=width big-endian, bytes 8..9=height big-endian, bytes 10..13=payload length big-endian, bytes 14..15=flags (zero). Frames may use any dimensions up to 2048x2048 and are scaled to the full iPhone canvas."];
}
return [lines componentsJoinedByString:@"\n"];
}
- (NSString *)batteryStateName:(UIDeviceBatteryState)state {
switch (state) {
case UIDeviceBatteryStateUnplugged:
return @"unplugged";
case UIDeviceBatteryStateCharging:
return @"charging";
case UIDeviceBatteryStateFull:
return @"full";
default:
return @"unknown";
}
}
- (NSURL *)safeFileURLForPath:(NSString *)path {
if (path.length == 0 || [path hasPrefix:@"/"]) {
return nil;
}
if ([[path pathComponents] containsObject:@".."]) {
return nil;
}
NSArray *directories = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSString *documents = directories.firstObject;
NSString *root = [documents stringByAppendingPathComponent:@"MCPFiles"];
return [NSURL fileURLWithPath:[root stringByAppendingPathComponent:path]];
}
- (BOOL)prefersStatusBarHidden {
return YES;
}
- (UIInterfaceOrientationMask)supportedInterfaceOrientations {
return UIInterfaceOrientationMaskLandscape;
}
- (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation {
return UIInterfaceOrientationLandscapeRight;
}
@end
+13
View File
@@ -0,0 +1,13 @@
TARGET := iphone:clang:12.4:12.0
ARCHS = arm64
INSTALL_TARGET_PROCESSES = IPhoneMCP
include $(THEOS)/makefiles/common.mk
APPLICATION_NAME = IPhoneMCP
IPhoneMCP_FILES = main.m IMCAppDelegate.m IMCViewController.m IMCCanvasView.m IMCMCPServer.m IMCVideoStreamServer.m
IPhoneMCP_FRAMEWORKS = UIKit Foundation AVFoundation CoreGraphics
IPhoneMCP_CFLAGS = -fobjc-arc -Wno-deprecated-declarations
include $(THEOS_MAKE_PATH)/application.mk
+174
View File
@@ -0,0 +1,174 @@
# iPhone MCP
Native UIKit MCP screen server for a jailbroken arm64 iPhone running iOS 12. It shares the ESP32 screen's MCP tool shape while using the iPhone display, speaker, microphone, battery, and front camera.
## Features
- HTTP JSON-RPC MCP endpoint: `POST http://<iphone-ip>:8080/api/mcp`
- Full-screen landscape canvas with idle sleep disabled while the app is active
- ESP32-compatible 400x300 monochrome streaming: TCP `8081`, UDP `8082`
- Framed RGB565 color streaming: TCP `8083`
- UDP discovery on `5000`: `DISCOVER_SCREEN` -> `SCREEN_IP_8080`
- True pitched tones, WAV playback, microphone recording, and selfie capture
- Hermes voice gateway turn: record -> transcribe/agent/TTS -> play response
- Stream statistics and MCP screenshots
## Requirements
- Jailbroken arm64 iPhone (tested on iPhone 6 / iOS 12.5.8)
- OpenSSH and `ldid` installed on the phone
- Theos at `/var/mobile/theos` with `iPhoneOS12.4.sdk`
- The phone and build computer on the same LAN
## Copy To The Phone
From the repository root:
```sh
rsync -av iphone_app/ root@<iphone-ip>:/var/mobile/IPhoneMCP/
```
Keep the phone unlocked while launching the app. On iOS 12, `uiopen` may refuse to launch a locked device.
## Build
The included direct build script is the most reliable path on the iPhone 6:
```sh
ssh root@<iphone-ip>
su - mobile -c 'chmod +x /var/mobile/IPhoneMCP/manual_build_iphone.sh && /var/mobile/IPhoneMCP/manual_build_iphone.sh /var/mobile/IPhoneMCP'
```
This creates and signs:
```text
/var/mobile/IPhoneMCP/.manual_build/IPhoneMCP.app
```
The regular Theos build is also supported:
```sh
su - mobile -c 'env THEOS=/var/mobile/theos PATH=/usr/bin:/bin:/usr/sbin:/sbin make -C /var/mobile/IPhoneMCP clean package SDKVERSION=12.4 INCLUDE_SDKVERSION=12.4 FAKEROOT="bash /var/mobile/theos/bin/fakeroot.sh -p /var/mobile/IPhoneMCP/.theos/fakeroot" _THEOS_PLATFORM_DPKG_DEB=dpkg-deb THEOS_PLATFORM_DEB_COMPRESSION_TYPE=gzip'
```
## Package And Install
For a direct-build bundle:
```sh
PKGROOT=/var/mobile/IPhoneMCP/.manual_pkg
DEB=/var/mobile/IPhoneMCP/packages/com.reynafamily.iphonemcp_manual_iphoneos-arm.deb
rm -rf "$PKGROOT"
mkdir -p "$PKGROOT/DEBIAN" "$PKGROOT/Applications/IPhoneMCP.app" /var/mobile/IPhoneMCP/packages
cp /var/mobile/IPhoneMCP/control "$PKGROOT/DEBIAN/control"
cp /var/mobile/IPhoneMCP/.manual_build/IPhoneMCP.app/IPhoneMCP "$PKGROOT/Applications/IPhoneMCP.app/IPhoneMCP"
cp /var/mobile/IPhoneMCP/.manual_build/IPhoneMCP.app/Info.plist "$PKGROOT/Applications/IPhoneMCP.app/Info.plist"
chmod 755 "$PKGROOT/Applications/IPhoneMCP.app/IPhoneMCP"
chown -R root:wheel "$PKGROOT"
dpkg-deb -b "$PKGROOT" "$DEB"
dpkg -i "$DEB"
uicache -p /Applications/IPhoneMCP.app
uiopen com.reynafamily.iphonemcp
```
Verify listeners:
```sh
netstat -an | grep -E '\.8080|\.8081|\.8082|\.8083|\.5000'
```
## MCP Bridge
```sh
python3 iphone_app/iphone_mcp_bridge.py --ip <iphone-ip>
```
Example client configuration:
```json
{
"mcpServers": {
"iphone6-mcp": {
"command": "python3",
"args": ["/absolute/path/to/mcp_screen/iphone_app/iphone_mcp_bridge.py", "--ip", "192.168.68.150"]
}
}
}
```
## Monochrome Streaming
- TCP `8081`: consecutive 15,000-byte ESP32 RLCD frame buffers
- UDP `8082`: fifteen 1002-byte packets per frame
- byte 0: frame id
- byte 1: chunk index 0-14
- bytes 2-1001: 1000-byte payload
The mapping is identical to `test_stream.py` in the repository.
## RGB565 Color Streaming
Color frames use TCP port `8083`. Every frame has a 16-byte big-endian header followed by RGB565 data:
| Offset | Size | Meaning |
|---|---:|---|
| 0 | 4 | ASCII `IMCR` |
| 4 | 1 | Version `1` |
| 5 | 1 | Format `1` = RGB565 big-endian |
| 6 | 2 | Width |
| 8 | 2 | Height |
| 10 | 4 | Payload length (`width * height * 2`) |
| 14 | 2 | Flags, currently `0` |
Run the included example:
```sh
python3 -m pip install Pillow
python3 iphone_app/tools/stream_color.py --ip <iphone-ip>
```
## Hermes Voice
The `hermes_voice_turn` MCP tool records a mono 16 kHz, 16-bit WAV, posts it to Hermes, plays the returned WAV, and returns the transcript and answer headers.
Default gateway:
```text
http://192.168.68.126:8642/api/esp32/voice
```
Example JSON-RPC call:
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "hermes_voice_turn",
"arguments": {
"url": "http://192.168.68.126:8642/api/esp32/voice",
"api_token": "YOUR_HERMES_API_TOKEN",
"device_id": "iphone6",
"duration_sec": 4,
"volume": 80,
"reply_mode": "sync",
"screen_url": "http://192.168.68.150:8080/api/mcp"
}
}
}
```
Do not commit the Hermes API token. It is accepted per call and is not persisted by the app.
## Implemented Tools
`clear_screen`, `draw_text`, `draw_image`, `get_screenshot`, `get_battery`, `play_tone`, `play_audio`, `play_audio_base64`, `record_voice`, `hermes_voice_turn`, `capture_selfie`, `write_file`, `read_file`, `download_file`, `get_video_streaming_instructions`, `get_stream_stats`, `reset_stream_stats`, and `set_stream_debug`.
Compatibility stubs remain for `set_led`, `get_sensors`, `scan_ble`, and `execute_python`.
## Operational Notes
- Keep the app foregrounded for reliable network service on iOS 12.
- The idle timer is disabled only while the app is active.
- Give each device a unique static DHCP reservation; duplicate IPs can make traffic reach the wrong host.
+57
View File
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>iPhone MCP</string>
<key>CFBundleExecutable</key>
<string>IPhoneMCP</string>
<key>CFBundleIdentifier</key>
<string>com.reynafamily.iphonemcp</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>IPhoneMCP</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>iPhoneOS</string>
</array>
<key>CFBundleShortVersionString</key>
<string>0.0.4</string>
<key>CFBundleVersion</key>
<string>4</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>iPhone MCP uses the camera when an MCP client calls the selfie capture tool.</string>
<key>NSMicrophoneUsageDescription</key>
<string>iPhone MCP uses the microphone when an MCP client calls the voice recording tool.</string>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIStatusBarHidden</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+8
View File
@@ -0,0 +1,8 @@
Package: com.reynafamily.iphonemcp
Name: iPhone MCP
Version: 0.0.4
Architecture: iphoneos-arm
Description: A native UIKit MCP-style HTTP server for a jailbroken iPhone 6.
Maintainer: Adolfo Reyna
Author: Adolfo Reyna
Section: Utilities
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
import argparse
import json
import sys
import urllib.request
import urllib.error
def main():
parser = argparse.ArgumentParser(description="MCP stdio bridge for the jailbroken iPhone MCP app")
parser.add_argument("--ip", required=True, help="iPhone IP address")
parser.add_argument("--port", type=int, default=8080, help="iPhone MCP HTTP port")
args = parser.parse_args()
url = f"http://{args.ip}:{args.port}/api/mcp"
sys.stderr.write(f"iPhone MCP bridge routing stdio to {url}\n")
sys.stderr.flush()
for line in sys.stdin:
try:
request = json.loads(line)
http_request = urllib.request.Request(
url,
data=json.dumps(request).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(http_request, timeout=15.0) as response:
sys.stdout.write(response.read().decode("utf-8") + "\n")
sys.stdout.flush()
except urllib.error.URLError as exc:
write_error(-32000, f"Bridge failed to reach iPhone MCP app: {exc}", request if "request" in locals() else {})
except Exception as exc:
write_error(-32603, f"Bridge error: {exc}", request if "request" in locals() else {})
def write_error(code, message, request):
sys.stdout.write(json.dumps({
"jsonrpc": "2.0",
"error": {"code": code, "message": message},
"id": request.get("id"),
}) + "\n")
sys.stdout.flush()
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
#import <UIKit/UIKit.h>
#import "IMCAppDelegate.h"
int main(int argc, char *argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil,
NSStringFromClass(IMCAppDelegate.class));
}
}
+38
View File
@@ -0,0 +1,38 @@
#!/bin/sh
set -eu
PROJECT_DIR="${1:-/var/mobile/IPhoneMCP}"
SDK="${SDK:-/var/mobile/theos/sdks/iPhoneOS12.4.sdk}"
BUILD_DIR="$PROJECT_DIR/.manual_build"
OBJ_DIR="$BUILD_DIR/obj"
APP_DIR="$BUILD_DIR/IPhoneMCP.app"
mkdir -p "$OBJ_DIR" "$APP_DIR"
cp "$PROJECT_DIR/Resources/Info.plist" "$APP_DIR/Info.plist"
CFLAGS="-arch arm64 -isysroot $SDK -miphoneos-version-min=12.0 -fobjc-arc -Wno-deprecated-declarations -I$PROJECT_DIR"
SOURCES="main.m IMCAppDelegate.m IMCViewController.m IMCCanvasView.m IMCMCPServer.m IMCVideoStreamServer.m"
OBJECTS=""
for src in $SOURCES; do
obj="$OBJ_DIR/${src%.m}.o"
echo "Compiling $src"
clang $CFLAGS -c "$PROJECT_DIR/$src" -o "$obj"
OBJECTS="$OBJECTS $obj"
done
echo "Linking IPhoneMCP"
clang -arch arm64 \
-isysroot "$SDK" \
-miphoneos-version-min=12.0 \
-o "$APP_DIR/IPhoneMCP" \
$OBJECTS \
-framework UIKit \
-framework Foundation \
-framework AVFoundation \
-framework CoreGraphics
echo "Signing IPhoneMCP"
ldid -S "$APP_DIR/IPhoneMCP"
echo "$APP_DIR"
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Minimal Hermes-compatible voice endpoint for iPhone integration testing."""
import argparse
import io
import math
import struct
import wave
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
def response_wav() -> bytes:
output = io.BytesIO()
with wave.open(output, "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(16000)
frames = bytearray()
for index in range(int(16000 * 0.3)):
sample = int(7000 * math.sin(2 * math.pi * 660 * index / 16000))
frames.extend(struct.pack("<h", sample))
wav.writeframes(frames)
return output.getvalue()
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/api/esp32/voice":
self.send_error(404)
return
length = int(self.headers.get("Content-Length", "0"))
received = self.rfile.read(length)
print(f"received {len(received)} bytes from {self.headers.get('X-Device-ID', 'unknown')}", flush=True)
payload = response_wav()
self.send_response(200)
self.send_header("Content-Type", "audio/wav")
self.send_header("Content-Length", str(len(payload)))
self.send_header("X-Hermes-Transcript", "Mock voice request received")
self.send_header("X-Hermes-Text-Response", "The iPhone Hermes integration is working")
self.send_header("X-Hermes-Response-Id", "mock-response")
self.send_header("X-Hermes-Conversation", "iphone-test")
self.end_headers()
self.wfile.write(payload)
def log_message(self, message, *args):
print(message % args, flush=True)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8765)
args = parser.parse_args()
server = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"mock Hermes gateway listening on {args.host}:{args.port}", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Stream a generated RGB565 color animation to iPhone MCP TCP port 8083."""
import argparse
import socket
import struct
import time
from PIL import Image, ImageDraw
def rgb565be(image: Image.Image) -> bytes:
image = image.convert("RGB")
output = bytearray(image.width * image.height * 2)
for index, (red, green, blue) in enumerate(image.getdata()):
value = ((red >> 3) << 11) | ((green >> 2) << 5) | (blue >> 3)
output[index * 2] = value >> 8
output[index * 2 + 1] = value & 0xFF
return bytes(output)
def send_frame(sock: socket.socket, image: Image.Image) -> None:
payload = rgb565be(image)
header = struct.pack(">4sBBHHIH", b"IMCR", 1, 1, image.width, image.height, len(payload), 0)
sock.sendall(header + payload)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--ip", required=True, help="iPhone Wi-Fi IP")
parser.add_argument("--port", type=int, default=8083)
parser.add_argument("--width", type=int, default=400)
parser.add_argument("--height", type=int, default=300)
args = parser.parse_args()
with socket.create_connection((args.ip, args.port), timeout=8) as sock:
frame = 0
while True:
hue = (frame * 4) % 256
image = Image.new("RGB", (args.width, args.height), (12, 18, 35))
draw = ImageDraw.Draw(image)
for x in range(args.width):
red = (x * 255 // max(1, args.width - 1) + hue) % 256
blue = 255 - (x * 255 // max(1, args.width - 1))
draw.line((x, 0, x, args.height - 1), fill=(red, 70, blue))
draw.rounded_rectangle((20, 20, args.width - 20, args.height - 20), radius=22,
outline=(255, 255, 255), width=4)
draw.text((40, 45), "iPhone MCP RGB565", fill=(255, 255, 255))
draw.text((40, 72), f"Frame {frame}", fill=(255, 240, 80))
send_frame(sock, image)
frame += 1
time.sleep(1 / 20)
if __name__ == "__main__":
main()