diff --git a/Makefile b/Makefile index b394575..5626037 100644 --- a/Makefile +++ b/Makefile @@ -176,22 +176,12 @@ vm_new: boot: bundle vphoned cd $(VM_DIR) && "$(CURDIR)/$(BUNDLE_BIN)" \ - --config ./config.plist \ - --rom ./AVPBooter.vresearch1.bin \ - --disk ./Disk.img \ - --nvram ./nvram.bin \ - --sep-rom ./AVPSEPBooter.vresearch1.bin \ - --sep-storage ./SEPStorage + --config ./config.plist boot_dfu: build cd $(VM_DIR) && "$(CURDIR)/$(BINARY)" \ --config ./config.plist \ - --rom ./AVPBooter.vresearch1.bin \ - --disk ./Disk.img \ - --nvram ./nvram.bin \ - --sep-rom ./AVPSEPBooter.vresearch1.bin \ - --sep-storage ./SEPStorage \ - --no-graphics --dfu + --dfu # ═══════════════════════════════════════════════════════════════════ # Firmware pipeline diff --git a/scripts/vphoned/entitlements.plist b/scripts/vphoned/entitlements.plist index 5226176..bee6eb6 100644 --- a/scripts/vphoned/entitlements.plist +++ b/scripts/vphoned/entitlements.plist @@ -505,6 +505,8 @@ IOSurfaceRootUserClient RootDomainUserClient + com.apple.springboard.debugapplications + com.apple.springboard.launchapplications com.apple.springboard.launchapplicationswithoptions diff --git a/scripts/vphoned/vphoned.m b/scripts/vphoned/vphoned.m index 0805b65..b90f7d3 100644 --- a/scripts/vphoned/vphoned.m +++ b/scripts/vphoned/vphoned.m @@ -13,362 +13,458 @@ * make vphoned */ -#import #include +#import #include #include #include #include -#import "vphoned_protocol.h" -#import "vphoned_hid.h" +#import "vphoned_accessibility.h" +#import "vphoned_apps.h" +#import "vphoned_clipboard.h" #import "vphoned_devmode.h" -#import "vphoned_location.h" #import "vphoned_files.h" +#import "vphoned_hid.h" #import "vphoned_install.h" #import "vphoned_keychain.h" +#import "vphoned_location.h" +#import "vphoned_protocol.h" +#import "vphoned_settings.h" +#import "vphoned_url.h" #ifndef AF_VSOCK #define AF_VSOCK 40 #endif -#define VMADDR_CID_ANY 0xFFFFFFFF -#define VPHONED_PORT 1337 +#define VMADDR_CID_ANY 0xFFFFFFFF +#define VPHONED_PORT 1337 #ifndef VPHONED_BUILD_HASH #define VPHONED_BUILD_HASH "unknown" #endif +static BOOL gClipboardAvailable = NO; +static BOOL gAppsAvailable = NO; + #define INSTALL_PATH "/usr/bin/vphoned" -#define CACHE_PATH "/var/root/Library/Caches/vphoned" -#define CACHE_DIR "/var/root/Library/Caches" +#define CACHE_PATH "/var/root/Library/Caches/vphoned" +#define CACHE_DIR "/var/root/Library/Caches" struct sockaddr_vm { - __uint8_t svm_len; - sa_family_t svm_family; - __uint16_t svm_reserved1; - __uint32_t svm_port; - __uint32_t svm_cid; + __uint8_t svm_len; + sa_family_t svm_family; + __uint16_t svm_reserved1; + __uint32_t svm_port; + __uint32_t svm_cid; }; // MARK: - Self-hash static NSString *sha256_of_file(const char *path) { - int fd = open(path, O_RDONLY); - if (fd < 0) return nil; + int fd = open(path, O_RDONLY); + if (fd < 0) + return nil; - CC_SHA256_CTX ctx; - CC_SHA256_Init(&ctx); + CC_SHA256_CTX ctx; + CC_SHA256_Init(&ctx); - uint8_t buf[32768]; - ssize_t n; - while ((n = read(fd, buf, sizeof(buf))) > 0) - CC_SHA256_Update(&ctx, buf, (CC_LONG)n); - close(fd); + uint8_t buf[32768]; + ssize_t n; + while ((n = read(fd, buf, sizeof(buf))) > 0) + CC_SHA256_Update(&ctx, buf, (CC_LONG)n); + close(fd); - unsigned char digest[CC_SHA256_DIGEST_LENGTH]; - CC_SHA256_Final(digest, &ctx); + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256_Final(digest, &ctx); - NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2]; - for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) - [hex appendFormat:@"%02x", digest[i]]; - return hex; + NSMutableString *hex = + [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2]; + for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) + [hex appendFormat:@"%02x", digest[i]]; + return hex; } static const char *self_executable_path(void) { - static char path[4096]; - uint32_t size = sizeof(path); - if (_NSGetExecutablePath(path, &size) != 0) return NULL; - return path; + static char path[4096]; + uint32_t size = sizeof(path); + if (_NSGetExecutablePath(path, &size) != 0) + return NULL; + return path; } // MARK: - Auto-update /// Receive raw binary from host, write to CACHE_PATH, chmod +x. static BOOL receive_update(int fd, NSUInteger size) { - mkdir(CACHE_DIR, 0755); + mkdir(CACHE_DIR, 0755); - char tmp_path[] = CACHE_DIR "/vphoned.XXXXXX"; - int tmp_fd = mkstemp(tmp_path); - if (tmp_fd < 0) { - NSLog(@"vphoned: mkstemp failed: %s", strerror(errno)); - return NO; + char tmp_path[] = CACHE_DIR "/vphoned.XXXXXX"; + int tmp_fd = mkstemp(tmp_path); + if (tmp_fd < 0) { + NSLog(@"vphoned: mkstemp failed: %s", strerror(errno)); + return NO; + } + + uint8_t buf[32768]; + NSUInteger remaining = size; + while (remaining > 0) { + size_t chunk = remaining < sizeof(buf) ? remaining : sizeof(buf); + if (!vp_read_fully(fd, buf, chunk)) { + NSLog(@"vphoned: update read failed at %lu/%lu", + (unsigned long)(size - remaining), (unsigned long)size); + close(tmp_fd); + unlink(tmp_path); + return NO; } - - uint8_t buf[32768]; - NSUInteger remaining = size; - while (remaining > 0) { - size_t chunk = remaining < sizeof(buf) ? remaining : sizeof(buf); - if (!vp_read_fully(fd, buf, chunk)) { - NSLog(@"vphoned: update read failed at %lu/%lu", - (unsigned long)(size - remaining), (unsigned long)size); - close(tmp_fd); - unlink(tmp_path); - return NO; - } - if (write(tmp_fd, buf, chunk) != (ssize_t)chunk) { - NSLog(@"vphoned: update write failed: %s", strerror(errno)); - close(tmp_fd); - unlink(tmp_path); - return NO; - } - remaining -= chunk; + if (write(tmp_fd, buf, chunk) != (ssize_t)chunk) { + NSLog(@"vphoned: update write failed: %s", strerror(errno)); + close(tmp_fd); + unlink(tmp_path); + return NO; } - close(tmp_fd); - chmod(tmp_path, 0755); + remaining -= chunk; + } + close(tmp_fd); + chmod(tmp_path, 0755); - if (rename(tmp_path, CACHE_PATH) != 0) { - NSLog(@"vphoned: rename to cache failed: %s", strerror(errno)); - unlink(tmp_path); - return NO; - } + if (rename(tmp_path, CACHE_PATH) != 0) { + NSLog(@"vphoned: rename to cache failed: %s", strerror(errno)); + unlink(tmp_path); + return NO; + } - NSLog(@"vphoned: update written to %s (%lu bytes)", CACHE_PATH, (unsigned long)size); - return YES; + NSLog(@"vphoned: update written to %s (%lu bytes)", CACHE_PATH, + (unsigned long)size); + return YES; } // MARK: - Command Dispatch static NSDictionary *handle_command(NSDictionary *msg) { - NSString *type = msg[@"t"]; - id reqId = msg[@"id"]; + NSString *type = msg[@"t"]; + id reqId = msg[@"id"]; - if ([type isEqualToString:@"hid"]) { - uint32_t page = [msg[@"page"] unsignedIntValue]; - uint32_t usage = [msg[@"usage"] unsignedIntValue]; - NSNumber *downVal = msg[@"down"]; - if (downVal != nil) { - vp_hid_key(page, usage, [downVal boolValue]); - } else { - vp_hid_press(page, usage); - } - return vp_make_response(@"ok", reqId); + if ([type isEqualToString:@"hid"]) { + uint32_t page = [msg[@"page"] unsignedIntValue]; + uint32_t usage = [msg[@"usage"] unsignedIntValue]; + NSNumber *downVal = msg[@"down"]; + if (downVal != nil) { + vp_hid_key(page, usage, [downVal boolValue]); + } else { + vp_hid_press(page, usage); } + return vp_make_response(@"ok", reqId); + } - if ([type isEqualToString:@"devmode"]) { - if (!vp_devmode_available()) { - NSMutableDictionary *r = vp_make_response(@"err", reqId); - r[@"msg"] = @"XPC not available"; - return r; - } - NSString *action = msg[@"action"]; - if ([action isEqualToString:@"status"]) { - BOOL enabled = vp_devmode_status(); - NSMutableDictionary *r = vp_make_response(@"ok", reqId); - r[@"enabled"] = @(enabled); - return r; - } - if ([action isEqualToString:@"enable"]) { - BOOL alreadyEnabled = NO; - BOOL ok = vp_devmode_arm(&alreadyEnabled); - NSMutableDictionary *r = vp_make_response(ok ? @"ok" : @"err", reqId); - if (ok) { - r[@"already_enabled"] = @(alreadyEnabled); - r[@"msg"] = alreadyEnabled - ? @"developer mode already enabled" - : @"developer mode armed, reboot to activate"; - } else { - r[@"msg"] = @"failed to arm developer mode"; - } - return r; - } - NSMutableDictionary *r = vp_make_response(@"err", reqId); - r[@"msg"] = [NSString stringWithFormat:@"unknown devmode action: %@", action]; - return r; + if ([type isEqualToString:@"devmode"]) { + if (!vp_devmode_available()) { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = @"XPC not available"; + return r; } - - if ([type isEqualToString:@"ping"]) { - return vp_make_response(@"pong", reqId); + NSString *action = msg[@"action"]; + if ([action isEqualToString:@"status"]) { + BOOL enabled = vp_devmode_status(); + NSMutableDictionary *r = vp_make_response(@"ok", reqId); + r[@"enabled"] = @(enabled); + return r; } - - if ([type isEqualToString:@"location"]) { - double lat = [msg[@"lat"] doubleValue]; - double lon = [msg[@"lon"] doubleValue]; - double alt = [msg[@"alt"] doubleValue]; - double hacc = [msg[@"hacc"] doubleValue]; - double vacc = [msg[@"vacc"] doubleValue]; - double speed = [msg[@"speed"] doubleValue]; - double course = [msg[@"course"] doubleValue]; - vp_location_simulate(lat, lon, alt, hacc, vacc, speed, course); - return vp_make_response(@"ok", reqId); + if ([action isEqualToString:@"enable"]) { + BOOL alreadyEnabled = NO; + BOOL ok = vp_devmode_arm(&alreadyEnabled); + NSMutableDictionary *r = vp_make_response(ok ? @"ok" : @"err", reqId); + if (ok) { + r[@"already_enabled"] = @(alreadyEnabled); + r[@"msg"] = alreadyEnabled + ? @"developer mode already enabled" + : @"developer mode armed, reboot to activate"; + } else { + r[@"msg"] = @"failed to arm developer mode"; + } + return r; } - - if ([type isEqualToString:@"location_stop"]) { - vp_location_clear(); - return vp_make_response(@"ok", reqId); - } - - if ([type isEqualToString:@"version"]) { - NSMutableDictionary *r = vp_make_response(@"version", reqId); - r[@"hash"] = @VPHONED_BUILD_HASH; - return r; - } - - if ([type isEqualToString:@"ipa_install"]) { - return vp_handle_custom_install(msg); - } - NSMutableDictionary *r = vp_make_response(@"err", reqId); - r[@"msg"] = [NSString stringWithFormat:@"unknown type: %@", type]; + r[@"msg"] = + [NSString stringWithFormat:@"unknown devmode action: %@", action]; return r; + } + + if ([type isEqualToString:@"ping"]) { + return vp_make_response(@"pong", reqId); + } + + if ([type isEqualToString:@"location"]) { + double lat = [msg[@"lat"] doubleValue]; + double lon = [msg[@"lon"] doubleValue]; + double alt = [msg[@"alt"] doubleValue]; + double hacc = [msg[@"hacc"] doubleValue]; + double vacc = [msg[@"vacc"] doubleValue]; + double speed = [msg[@"speed"] doubleValue]; + double course = [msg[@"course"] doubleValue]; + vp_location_simulate(lat, lon, alt, hacc, vacc, speed, course); + return vp_make_response(@"ok", reqId); + } + + if ([type isEqualToString:@"location_stop"]) { + vp_location_clear(); + return vp_make_response(@"ok", reqId); + } + + if ([type isEqualToString:@"version"]) { + NSMutableDictionary *r = vp_make_response(@"version", reqId); + r[@"hash"] = @VPHONED_BUILD_HASH; + return r; + } + + if ([type isEqualToString:@"ipa_install"]) { + return vp_handle_custom_install(msg); + } + + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = [NSString stringWithFormat:@"unknown type: %@", type]; + return r; } // MARK: - Client Session /// Returns YES if daemon should exit for restart (after update). static BOOL handle_client(int fd) { - BOOL should_restart = NO; - @autoreleasepool { - NSDictionary *hello = vp_read_message(fd); - if (!hello) { close(fd); return NO; } - - NSInteger version = [hello[@"v"] integerValue]; - NSString *type = hello[@"t"]; - - if (![type isEqualToString:@"hello"]) { - NSLog(@"vphoned: expected hello, got %@", type); - close(fd); - return NO; - } - - if (version != PROTOCOL_VERSION) { - NSLog(@"vphoned: version mismatch (client v%ld, daemon v%d)", - (long)version, PROTOCOL_VERSION); - vp_write_message(fd, @{@"v": @PROTOCOL_VERSION, @"t": @"err", - @"msg": @"version mismatch"}); - close(fd); - return NO; - } - - // Hash comparison for auto-update - NSString *hostHash = hello[@"bin_hash"]; - BOOL needUpdate = NO; - if (hostHash.length > 0) { - const char *selfPath = self_executable_path(); - NSString *selfHash = selfPath ? sha256_of_file(selfPath) : nil; - if (selfHash && ![selfHash isEqualToString:hostHash]) { - NSLog(@"vphoned: hash mismatch (self=%@ host=%@)", selfHash, hostHash); - needUpdate = YES; - } else if (selfHash) { - NSLog(@"vphoned: hash OK"); - } - } - - // Build capabilities list - NSMutableArray *caps = [NSMutableArray arrayWithObjects:@"hid", @"devmode", @"file", @"keychain", nil]; - if (vp_location_available()) [caps addObject:@"location"]; - if (vp_custom_installer_available()) [caps addObject:@"ipa_install"]; - - NSMutableDictionary *helloResp = [@{ - @"v": @PROTOCOL_VERSION, - @"t": @"hello", - @"name": @"vphoned", - @"caps": caps, - } mutableCopy]; - if (needUpdate) helloResp[@"need_update"] = @YES; - - if (!vp_write_message(fd, helloResp)) { close(fd); return NO; } - NSLog(@"vphoned: client connected (v%d)%s", - PROTOCOL_VERSION, needUpdate ? " [update pending]" : ""); - - NSDictionary *msg; - while ((msg = vp_read_message(fd)) != nil) { - @autoreleasepool { - NSString *t = msg[@"t"]; - NSLog(@"vphoned: recv cmd: %@", t); - - if ([t isEqualToString:@"update"]) { - NSUInteger size = [msg[@"size"] unsignedIntegerValue]; - id reqId = msg[@"id"]; - NSLog(@"vphoned: receiving update (%lu bytes)", (unsigned long)size); - if (size > 0 && size < 10 * 1024 * 1024 && receive_update(fd, size)) { - NSMutableDictionary *r = vp_make_response(@"ok", reqId); - r[@"msg"] = @"updated, restarting"; - vp_write_message(fd, r); - should_restart = YES; - break; - } else { - NSMutableDictionary *r = vp_make_response(@"err", reqId); - r[@"msg"] = @"update failed"; - vp_write_message(fd, r); - } - continue; - } - - // File operations (need fd for inline binary transfer) - if ([t hasPrefix:@"file_"]) { - NSDictionary *resp = vp_handle_file_command(fd, msg); - if (resp && !vp_write_message(fd, resp)) break; - continue; - } - - // Keychain operations - if ([t hasPrefix:@"keychain_"]) { - NSDictionary *resp = vp_handle_keychain_command(msg); - if (resp && !vp_write_message(fd, resp)) break; - continue; - } - - NSDictionary *resp = handle_command(msg); - if (resp && !vp_write_message(fd, resp)) break; - } - } - - NSLog(@"vphoned: client disconnected%s", should_restart ? " (restarting for update)" : ""); - close(fd); + BOOL should_restart = NO; + @autoreleasepool { + NSDictionary *hello = vp_read_message(fd); + if (!hello) { + close(fd); + return NO; } - return should_restart; + + NSInteger version = [hello[@"v"] integerValue]; + NSString *type = hello[@"t"]; + + if (![type isEqualToString:@"hello"]) { + NSLog(@"vphoned: expected hello, got %@", type); + close(fd); + return NO; + } + + if (version != PROTOCOL_VERSION) { + NSLog(@"vphoned: version mismatch (client v%ld, daemon v%d)", + (long)version, PROTOCOL_VERSION); + vp_write_message( + fd, @{ + @"v" : @PROTOCOL_VERSION, + @"t" : @"err", + @"msg" : @"version mismatch" + }); + close(fd); + return NO; + } + + // Hash comparison for auto-update + NSString *hostHash = hello[@"bin_hash"]; + BOOL needUpdate = NO; + if (hostHash.length > 0) { + const char *selfPath = self_executable_path(); + NSString *selfHash = selfPath ? sha256_of_file(selfPath) : nil; + if (selfHash && ![selfHash isEqualToString:hostHash]) { + NSLog(@"vphoned: hash mismatch (self=%@ host=%@)", selfHash, hostHash); + needUpdate = YES; + } else if (selfHash) { + NSLog(@"vphoned: hash OK"); + } + } + + // Build capabilities list + NSMutableArray *caps = [NSMutableArray + arrayWithObjects:@"hid", @"devmode", @"file", @"keychain", nil]; + if (vp_location_available()) + [caps addObject:@"location"]; + if (vp_custom_installer_available()) + [caps addObject:@"ipa_install"]; + if (gClipboardAvailable) + [caps addObject:@"clipboard"]; + if (gAppsAvailable) + [caps addObject:@"apps"]; + [caps addObject:@"url"]; + [caps addObject:@"settings"]; + + NSMutableDictionary *helloResp = [@{ + @"v" : @PROTOCOL_VERSION, + @"t" : @"hello", + @"name" : @"vphoned", + @"caps" : caps, + } mutableCopy]; + if (needUpdate) + helloResp[@"need_update"] = @YES; + + if (!vp_write_message(fd, helloResp)) { + close(fd); + return NO; + } + NSLog(@"vphoned: client connected (v%d)%s", PROTOCOL_VERSION, + needUpdate ? " [update pending]" : ""); + + NSDictionary *msg; + while ((msg = vp_read_message(fd)) != nil) { + @autoreleasepool { + NSString *t = msg[@"t"]; + NSLog(@"vphoned: recv cmd: %@", t); + + if ([t isEqualToString:@"update"]) { + NSUInteger size = [msg[@"size"] unsignedIntegerValue]; + id reqId = msg[@"id"]; + NSLog(@"vphoned: receiving update (%lu bytes)", (unsigned long)size); + if (size > 0 && size < 10 * 1024 * 1024 && receive_update(fd, size)) { + NSMutableDictionary *r = vp_make_response(@"ok", reqId); + r[@"msg"] = @"updated, restarting"; + vp_write_message(fd, r); + should_restart = YES; + break; + } else { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = @"update failed"; + vp_write_message(fd, r); + } + continue; + } + + // File operations (need fd for inline binary transfer) + if ([t hasPrefix:@"file_"]) { + NSDictionary *resp = vp_handle_file_command(fd, msg); + if (resp && !vp_write_message(fd, resp)) + break; + continue; + } + + // Keychain operations + if ([t hasPrefix:@"keychain_"]) { + NSDictionary *resp = vp_handle_keychain_command(msg); + if (resp && !vp_write_message(fd, resp)) + break; + continue; + } + + // Clipboard operations (need fd for inline binary transfer) + if ([t hasPrefix:@"clipboard_"]) { + NSDictionary *resp = vp_handle_clipboard_command(fd, msg); + if (resp && !vp_write_message(fd, resp)) + break; + continue; + } + + // App management operations + if ([t hasPrefix:@"app_"]) { + NSDictionary *resp = vp_handle_apps_command(msg); + if (resp && !vp_write_message(fd, resp)) + break; + continue; + } + + // URL opening + if ([t isEqualToString:@"open_url"]) { + NSDictionary *resp = vp_handle_url_command(msg); + if (resp && !vp_write_message(fd, resp)) + break; + continue; + } + + // Settings operations + if ([t hasPrefix:@"settings_"]) { + NSDictionary *resp = vp_handle_settings_command(msg); + if (resp && !vp_write_message(fd, resp)) + break; + continue; + } + + // Accessibility tree + if ([t isEqualToString:@"accessibility_tree"]) { + NSDictionary *resp = vp_handle_accessibility_command(msg); + if (resp && !vp_write_message(fd, resp)) + break; + continue; + } + + NSDictionary *resp = handle_command(msg); + if (resp && !vp_write_message(fd, resp)) + break; + } + } + + NSLog(@"vphoned: client disconnected%s", + should_restart ? " (restarting for update)" : ""); + close(fd); + } + return should_restart; } // MARK: - Main int main(int argc, char *argv[]) { - @autoreleasepool { - // Bootstrap: if running from install path and a cached update exists, exec it - const char *selfPath = self_executable_path(); - if (selfPath && strcmp(selfPath, INSTALL_PATH) == 0 && access(CACHE_PATH, X_OK) == 0) { - NSLog(@"vphoned: found cached binary at %s, exec'ing", CACHE_PATH); - execv(CACHE_PATH, argv); - NSLog(@"vphoned: execv failed: %s — continuing with installed binary", strerror(errno)); - unlink(CACHE_PATH); - } - - NSLog(@"vphoned: starting (pid=%d, path=%s)", getpid(), selfPath ?: "?"); - - if (!vp_hid_load()) return 1; - if (!vp_devmode_load()) NSLog(@"vphoned: XPC unavailable, devmode disabled"); - vp_location_load(); - - int sock = socket(AF_VSOCK, SOCK_STREAM, 0); - if (sock < 0) { perror("vphoned: socket(AF_VSOCK)"); return 1; } - - int one = 1; - setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); - - struct sockaddr_vm addr = { - .svm_len = sizeof(struct sockaddr_vm), - .svm_family = AF_VSOCK, - .svm_port = VPHONED_PORT, - .svm_cid = VMADDR_CID_ANY, - }; - - if (bind(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) { - perror("vphoned: bind"); close(sock); return 1; - } - if (listen(sock, 2) < 0) { - perror("vphoned: listen"); close(sock); return 1; - } - - NSLog(@"vphoned: listening on vsock port %d", VPHONED_PORT); - - for (;;) { - int client = accept(sock, NULL, NULL); - if (client < 0) { perror("vphoned: accept"); sleep(1); continue; } - if (handle_client(client)) { - NSLog(@"vphoned: exiting for update restart"); - close(sock); - return 0; - } - } + @autoreleasepool { + // Bootstrap: if running from install path and a cached update exists, exec + // it + const char *selfPath = self_executable_path(); + if (selfPath && strcmp(selfPath, INSTALL_PATH) == 0 && + access(CACHE_PATH, X_OK) == 0) { + NSLog(@"vphoned: found cached binary at %s, exec'ing", CACHE_PATH); + execv(CACHE_PATH, argv); + NSLog(@"vphoned: execv failed: %s — continuing with installed binary", + strerror(errno)); + unlink(CACHE_PATH); } + + NSLog(@"vphoned: starting (pid=%d, path=%s)", getpid(), selfPath ?: "?"); + + if (!vp_hid_load()) + return 1; + if (!vp_devmode_load()) + NSLog(@"vphoned: XPC unavailable, devmode disabled"); + vp_location_load(); + + gClipboardAvailable = vp_clipboard_load(); + gAppsAvailable = vp_apps_load(); + + int sock = socket(AF_VSOCK, SOCK_STREAM, 0); + if (sock < 0) { + perror("vphoned: socket(AF_VSOCK)"); + return 1; + } + + int one = 1; + setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + + struct sockaddr_vm addr = { + .svm_len = sizeof(struct sockaddr_vm), + .svm_family = AF_VSOCK, + .svm_port = VPHONED_PORT, + .svm_cid = VMADDR_CID_ANY, + }; + + if (bind(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + perror("vphoned: bind"); + close(sock); + return 1; + } + if (listen(sock, 2) < 0) { + perror("vphoned: listen"); + close(sock); + return 1; + } + + NSLog(@"vphoned: listening on vsock port %d", VPHONED_PORT); + + for (;;) { + int client = accept(sock, NULL, NULL); + if (client < 0) { + perror("vphoned: accept"); + sleep(1); + continue; + } + if (handle_client(client)) { + NSLog(@"vphoned: exiting for update restart"); + close(sock); + return 0; + } + } + } } diff --git a/scripts/vphoned/vphoned_accessibility.h b/scripts/vphoned/vphoned_accessibility.h new file mode 100644 index 0000000..37874fb --- /dev/null +++ b/scripts/vphoned/vphoned_accessibility.h @@ -0,0 +1,12 @@ +/* + * vphoned_accessibility — Accessibility tree query over vsock. + * + * Handles accessibility_tree. Currently a stub — requires XPC research + * to properly query the accessibility tree from a daemon context. + */ + +#pragma once +#import + +/// Handle an accessibility_tree command. Returns a response dict. +NSDictionary *vp_handle_accessibility_command(NSDictionary *msg); diff --git a/scripts/vphoned/vphoned_accessibility.m b/scripts/vphoned/vphoned_accessibility.m new file mode 100644 index 0000000..295bdee --- /dev/null +++ b/scripts/vphoned/vphoned_accessibility.m @@ -0,0 +1,21 @@ +/* + * vphoned_accessibility — Accessibility tree query (stub). + * + * TODO: Implement proper accessibility tree retrieval. + * Options under investigation: + * 1. XPC to com.apple.accessibility.AXRuntime + * 2. AXUIElement private API (may not be available on iOS) + * 3. Dylib injection into SpringBoard + * 4. Direct UIAccessibility traversal via task_for_pid + */ + +#import "vphoned_accessibility.h" +#import "vphoned_protocol.h" + +NSDictionary *vp_handle_accessibility_command(NSDictionary *msg) { + id reqId = msg[@"id"]; + + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = @"accessibility_tree not yet implemented — requires XPC research"; + return r; +} diff --git a/scripts/vphoned/vphoned_apps.h b/scripts/vphoned/vphoned_apps.h new file mode 100644 index 0000000..5272b9c --- /dev/null +++ b/scripts/vphoned/vphoned_apps.h @@ -0,0 +1,15 @@ +/* + * vphoned_apps — App lifecycle management over vsock. + * + * Handles app_list, app_launch, app_terminate, app_foreground using + * private APIs: LSApplicationWorkspace, FBSSystemService, SpringBoardServices. + */ + +#pragma once +#import + +/// Load private framework symbols for app management. Returns NO on failure. +BOOL vp_apps_load(void); + +/// Handle an app command. Returns a response dict. +NSDictionary *vp_handle_apps_command(NSDictionary *msg); diff --git a/scripts/vphoned/vphoned_apps.m b/scripts/vphoned/vphoned_apps.m new file mode 100644 index 0000000..a714202 --- /dev/null +++ b/scripts/vphoned/vphoned_apps.m @@ -0,0 +1,213 @@ +/* + * vphoned_apps — App lifecycle management via private APIs. + * + * Uses LSApplicationWorkspace (CoreServices) and FBSSystemService + * (FrontBoardServices). + */ + +#import "vphoned_apps.h" +#import "vphoned_protocol.h" +#include +#include +#include +#include + +// MARK: - Private API Declarations + +@interface LSApplicationProxy : NSObject +@property(readonly) NSString *bundleIdentifier; +@property(readonly) NSString *localizedName; +@property(readonly) NSString *shortVersionString; +@property(readonly) NSString *applicationType; +@property(readonly) NSURL *bundleURL; +@property(readonly) NSURL *dataContainerURL; +@end + +@interface LSApplicationWorkspace : NSObject ++ (instancetype)defaultWorkspace; +- (NSArray *)allInstalledApplications; +- (BOOL)openApplicationWithBundleID:(NSString *)bundleID; +@end + +// FBSSystemService loaded via dlsym +static Class gFBSSystemServiceClass = Nil; + +static BOOL gAppsLoaded = NO; + +BOOL vp_apps_load(void) { + // FrontBoardServices + void *fbs = dlopen("/System/Library/PrivateFrameworks/" + "FrontBoardServices.framework/FrontBoardServices", + RTLD_LAZY); + if (fbs) { + gFBSSystemServiceClass = NSClassFromString(@"FBSSystemService"); + if (!gFBSSystemServiceClass) { + NSLog(@"vphoned: FBSSystemService class not found"); + } + } else { + NSLog(@"vphoned: dlopen FrontBoardServices failed: %s", dlerror()); + } + + // LSApplicationWorkspace is in CoreServices (already linked) + Class lsClass = NSClassFromString(@"LSApplicationWorkspace"); + if (!lsClass) { + NSLog(@"vphoned: LSApplicationWorkspace class not found"); + return NO; + } + + gAppsLoaded = YES; + NSLog(@"vphoned: apps loaded (FBS=%s)", + gFBSSystemServiceClass ? "yes" : "no"); + return YES; +} + +// MARK: - Helpers + +static pid_t pid_for_app(NSString *bundleID) { + if (!gFBSSystemServiceClass) + return 0; + id service = ((id (*)(Class, SEL))objc_msgSend)( + gFBSSystemServiceClass, sel_registerName("sharedService")); + if (!service) + return 0; + return ((pid_t (*)(id, SEL, id))objc_msgSend)( + service, sel_registerName("pidForApplication:"), bundleID); +} + +static NSString *state_for_pid(pid_t pid) { + if (pid > 0) + return @"running"; + return @"not_running"; +} + +// MARK: - Command Handler + +NSDictionary *vp_handle_apps_command(NSDictionary *msg) { + NSString *type = msg[@"t"]; + id reqId = msg[@"id"]; + + if (!gAppsLoaded) { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = @"apps not available"; + return r; + } + + // -- app_list -- + if ([type isEqualToString:@"app_list"]) { + LSApplicationWorkspace *ws = [LSApplicationWorkspace defaultWorkspace]; + NSArray *allApps = [ws allInstalledApplications]; + NSString *filter = msg[@"filter"] ?: @"all"; + + NSMutableArray *result = [NSMutableArray array]; + for (LSApplicationProxy *proxy in allApps) { + NSString *appType = proxy.applicationType; + BOOL isSystem = [appType isEqualToString:@"System"]; + + if ([filter isEqualToString:@"user"] && isSystem) + continue; + if ([filter isEqualToString:@"system"] && !isSystem) + continue; + + pid_t pid = pid_for_app(proxy.bundleIdentifier); + + if ([filter isEqualToString:@"running"] && pid <= 0) + continue; + + [result addObject:@{ + @"bundle_id" : proxy.bundleIdentifier ?: @"", + @"name" : proxy.localizedName ?: @"", + @"version" : proxy.shortVersionString ?: @"", + @"type" : isSystem ? @"system" : @"user", + @"state" : state_for_pid(pid), + @"pid" : @(pid > 0 ? pid : 0), + @"path" : proxy.bundleURL.path ?: @"", + @"data_container" : proxy.dataContainerURL.path ?: @"", + }]; + } + + NSMutableDictionary *r = vp_make_response(@"app_list", reqId); + r[@"apps"] = result; + return r; + } + + // -- app_launch -- + if ([type isEqualToString:@"app_launch"]) { + NSString *bundleID = msg[@"bundle_id"]; + if (!bundleID) { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = @"missing bundle_id"; + return r; + } + + LSApplicationWorkspace *ws = [LSApplicationWorkspace defaultWorkspace]; + NSString *url = msg[@"url"]; + + BOOL ok; + if (url) { + // Open URL (which will launch the handling app) + NSURL *nsurl = [NSURL URLWithString:url]; + // Try openURL:withOptions: if available + SEL openURLSel = sel_registerName("openURL:withOptions:"); + if ([ws respondsToSelector:openURLSel]) { + ok = ((BOOL (*)(id, SEL, id, id))objc_msgSend)(ws, openURLSel, nsurl, + nil); + } else { + ok = [ws openApplicationWithBundleID:bundleID]; + } + } else { + ok = [ws openApplicationWithBundleID:bundleID]; + } + + if (!ok) { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = [NSString stringWithFormat:@"failed to launch %@", bundleID]; + return r; + } + + // Brief wait for app to start + usleep(500000); // 500ms + + pid_t pid = pid_for_app(bundleID); + NSMutableDictionary *r = vp_make_response(@"app_launch", reqId); + r[@"ok"] = @YES; + r[@"pid"] = @(pid > 0 ? pid : 0); + return r; + } + + // -- app_terminate -- + if ([type isEqualToString:@"app_terminate"]) { + NSString *bundleID = msg[@"bundle_id"]; + if (!bundleID) { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = @"missing bundle_id"; + return r; + } + + if (gFBSSystemServiceClass) { + id service = ((id (*)(Class, SEL))objc_msgSend)( + gFBSSystemServiceClass, sel_registerName("sharedService")); + if (service) { + // terminateApplication:forReason:andReport:withDescription: + // reason 5 = user requested, report NO + ((void (*)(id, SEL, id, int, BOOL, id))objc_msgSend)( + service, + sel_registerName( + "terminateApplication:forReason:andReport:withDescription:"), + bundleID, 5, NO, @"vphoned terminate request"); + } + } else { + // Fallback: kill by PID + pid_t pid = pid_for_app(bundleID); + if (pid > 0) + kill(pid, SIGTERM); + } + + NSMutableDictionary *r = vp_make_response(@"app_terminate", reqId); + r[@"ok"] = @YES; + return r; + } + + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = [NSString stringWithFormat:@"unknown apps command: %@", type]; + return r; +} diff --git a/scripts/vphoned/vphoned_clipboard.h b/scripts/vphoned/vphoned_clipboard.h new file mode 100644 index 0000000..4006b19 --- /dev/null +++ b/scripts/vphoned/vphoned_clipboard.h @@ -0,0 +1,16 @@ +/* + * vphoned_clipboard — Clipboard (pasteboard) read/write over vsock. + * + * Handles clipboard_get and clipboard_set using UIPasteboard via dlopen. + * Supports text and image (PNG) payloads with inline binary transfer. + */ + +#pragma once +#import + +/// Load UIKit symbols for clipboard access. Returns NO on failure. +BOOL vp_clipboard_load(void); + +/// Handle a clipboard command. May write binary data inline for images. +/// Returns a response dict, or nil if the response was written inline. +NSDictionary *vp_handle_clipboard_command(int fd, NSDictionary *msg); diff --git a/scripts/vphoned/vphoned_clipboard.m b/scripts/vphoned/vphoned_clipboard.m new file mode 100644 index 0000000..a7b4cc0 --- /dev/null +++ b/scripts/vphoned/vphoned_clipboard.m @@ -0,0 +1,168 @@ +/* + * vphoned_clipboard — Clipboard read/write via UIPasteboard (dlopen). + * + * UIPasteboard is loaded at runtime since vphoned is a daemon without UIKit. + * Uses objc_msgSend for all UIPasteboard interactions. + */ + +#import "vphoned_clipboard.h" +#import "vphoned_protocol.h" +#include +#include +#include + +static BOOL gClipboardLoaded = NO; +static Class gPasteboardClass = Nil; +// UIImagePNGRepresentation +static NSData *(*pImagePNGRep)(id) = NULL; + +BOOL vp_clipboard_load(void) { + void *h = + dlopen("/System/Library/Frameworks/UIKit.framework/UIKit", RTLD_LAZY); + if (!h) { + NSLog(@"vphoned: dlopen UIKit failed: %s", dlerror()); + return NO; + } + + gPasteboardClass = NSClassFromString(@"UIPasteboard"); + if (!gPasteboardClass) { + NSLog(@"vphoned: UIPasteboard class not found"); + return NO; + } + + pImagePNGRep = dlsym(h, "UIImagePNGRepresentation"); + if (!pImagePNGRep) { + NSLog(@"vphoned: UIImagePNGRepresentation not found (image support " + @"disabled)"); + // Non-fatal: text clipboard still works + } + + gClipboardLoaded = YES; + NSLog(@"vphoned: clipboard loaded (UIKit)"); + return YES; +} + +static id get_general_pasteboard(void) { + return ((id (*)(Class, SEL))objc_msgSend)( + gPasteboardClass, sel_registerName("generalPasteboard")); +} + +NSDictionary *vp_handle_clipboard_command(int fd, NSDictionary *msg) { + NSString *type = msg[@"t"]; + id reqId = msg[@"id"]; + + if (!gClipboardLoaded) { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = @"clipboard not available (UIKit not loaded)"; + return r; + } + + // -- clipboard_get -- + if ([type isEqualToString:@"clipboard_get"]) { + id pb = get_general_pasteboard(); + if (!pb) { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = @"failed to get general pasteboard"; + return r; + } + + NSMutableDictionary *r = vp_make_response(@"clipboard_get", reqId); + + // changeCount + NSInteger changeCount = ((NSInteger (*)(id, SEL))objc_msgSend)( + pb, sel_registerName("changeCount")); + r[@"change_count"] = @(changeCount); + + // pasteboardTypes + NSArray *types = ((id (*)(id, SEL))objc_msgSend)( + pb, sel_registerName("pasteboardTypes")); + r[@"types"] = types ?: @[]; + + // string + NSString *str = + ((id (*)(id, SEL))objc_msgSend)(pb, sel_registerName("string")); + if (str) + r[@"text"] = str; + + // image + id image = ((id (*)(id, SEL))objc_msgSend)(pb, sel_registerName("image")); + NSData *pngData = nil; + if (image && pImagePNGRep) { + pngData = pImagePNGRep(image); + } + + if (pngData && pngData.length > 0) { + r[@"has_image"] = @YES; + r[@"image_size"] = @(pngData.length); + + // Write JSON header, then binary PNG data + if (!vp_write_message(fd, r)) + return nil; + vp_write_fully(fd, pngData.bytes, pngData.length); + return nil; // Already written inline + } else { + r[@"has_image"] = @NO; + return r; + } + } + + // -- clipboard_set -- + if ([type isEqualToString:@"clipboard_set"]) { + id pb = get_general_pasteboard(); + if (!pb) { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = @"failed to get general pasteboard"; + return r; + } + + NSString *setType = msg[@"type"]; + if ([setType isEqualToString:@"image"]) { + // Image mode: read binary payload + NSUInteger size = [msg[@"size"] unsignedIntegerValue]; + if (size == 0 || size > 50 * 1024 * 1024) { + if (size > 0) + vp_drain(fd, size); + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = @"invalid image size"; + return r; + } + + NSMutableData *imgData = [NSMutableData dataWithLength:size]; + if (!vp_read_fully(fd, imgData.mutableBytes, size)) { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = @"failed to read image data"; + return r; + } + + // Create UIImage from PNG data and set on pasteboard + Class uiImageClass = NSClassFromString(@"UIImage"); + if (uiImageClass) { + id image = ((id (*)(Class, SEL, id))objc_msgSend)( + uiImageClass, sel_registerName("imageWithData:"), imgData); + if (image) { + ((void (*)(id, SEL, id))objc_msgSend)( + pb, sel_registerName("setImage:"), image); + } + } + } else { + // Text mode + NSString *text = msg[@"text"]; + if (text) { + ((void (*)(id, SEL, id))objc_msgSend)( + pb, sel_registerName("setString:"), text); + } + } + + NSInteger changeCount = ((NSInteger (*)(id, SEL))objc_msgSend)( + pb, sel_registerName("changeCount")); + NSMutableDictionary *r = vp_make_response(@"clipboard_set", reqId); + r[@"ok"] = @YES; + r[@"change_count"] = @(changeCount); + return r; + } + + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = + [NSString stringWithFormat:@"unknown clipboard command: %@", type]; + return r; +} diff --git a/scripts/vphoned/vphoned_settings.h b/scripts/vphoned/vphoned_settings.h new file mode 100644 index 0000000..39b9482 --- /dev/null +++ b/scripts/vphoned/vphoned_settings.h @@ -0,0 +1,12 @@ +/* + * vphoned_settings — System preferences read/write over vsock. + * + * Handles settings_get and settings_set using CFPreferences. + * No extra frameworks needed — CFPreferences is in CoreFoundation. + */ + +#pragma once +#import + +/// Handle a settings command. Returns a response dict. +NSDictionary *vp_handle_settings_command(NSDictionary *msg); diff --git a/scripts/vphoned/vphoned_settings.m b/scripts/vphoned/vphoned_settings.m new file mode 100644 index 0000000..945550c --- /dev/null +++ b/scripts/vphoned/vphoned_settings.m @@ -0,0 +1,175 @@ +/* + * vphoned_settings — System preferences read/write via CFPreferences. + * + * Reads and writes preference domains using CFPreferences API. + * No additional frameworks required. + */ + +#import "vphoned_settings.h" +#import "vphoned_protocol.h" + +// MARK: - Helpers + +/// Map a CFPropertyList value to a JSON-safe representation with type info. +static NSDictionary *serialize_value(id value) { + if (!value || value == (id)kCFNull) { + return @{@"value" : [NSNull null], @"type" : @"null"}; + } + if ([value isKindOfClass:[NSNumber class]]) { + // Distinguish boolean from number + if (strcmp([value objCType], @encode(BOOL)) == 0 || + strcmp([value objCType], @encode(char)) == 0) { + return @{@"value" : value, @"type" : @"boolean"}; + } + // Check for float/double + if (strcmp([value objCType], @encode(float)) == 0 || + strcmp([value objCType], @encode(double)) == 0) { + return @{@"value" : value, @"type" : @"float"}; + } + return @{@"value" : value, @"type" : @"integer"}; + } + if ([value isKindOfClass:[NSString class]]) { + return @{@"value" : value, @"type" : @"string"}; + } + if ([value isKindOfClass:[NSData class]]) { + return @{ + @"value" : [(NSData *)value base64EncodedStringWithOptions:0], + @"type" : @"data" + }; + } + if ([value isKindOfClass:[NSDate class]]) { + return @{ + @"value" : @([(NSDate *)value timeIntervalSince1970]), + @"type" : @"date" + }; + } + if ([value isKindOfClass:[NSArray class]] || + [value isKindOfClass:[NSDictionary class]]) { + // Try JSON serialization + if ([NSJSONSerialization isValidJSONObject:value]) { + return @{@"value" : value, @"type" : @"plist"}; + } + return @{@"value" : [value description], @"type" : @"plist"}; + } + return @{@"value" : [value description], @"type" : @"unknown"}; +} + +/// Deserialize a value from the request based on type hint. +static id deserialize_value(id rawValue, NSString *typeHint) { + if (!rawValue || rawValue == (id)[NSNull null]) + return nil; + + if ([typeHint isEqualToString:@"boolean"]) { + return @([rawValue boolValue]); + } + if ([typeHint isEqualToString:@"integer"]) { + return @([rawValue longLongValue]); + } + if ([typeHint isEqualToString:@"float"]) { + return @([rawValue doubleValue]); + } + if ([typeHint isEqualToString:@"string"]) { + return [rawValue description]; + } + if ([typeHint isEqualToString:@"data"]) { + if ([rawValue isKindOfClass:[NSString class]]) { + return [[NSData alloc] initWithBase64EncodedString:rawValue options:0]; + } + } + // Default: pass through (JSON types map naturally) + return rawValue; +} + +// MARK: - Command Handler + +NSDictionary *vp_handle_settings_command(NSDictionary *msg) { + NSString *type = msg[@"t"]; + id reqId = msg[@"id"]; + + // -- settings_get -- + if ([type isEqualToString:@"settings_get"]) { + NSString *domain = msg[@"domain"]; + NSString *key = msg[@"key"]; + + if (!domain) { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = @"missing domain"; + return r; + } + + if (key && key.length > 0) { + // Single key + CFPropertyListRef value = CFPreferencesCopyAppValue( + (__bridge CFStringRef)key, (__bridge CFStringRef)domain); + NSMutableDictionary *r = vp_make_response(@"settings_get", reqId); + if (value) { + NSDictionary *serialized = serialize_value((__bridge id)value); + r[@"value"] = serialized[@"value"]; + r[@"type"] = serialized[@"type"]; + CFRelease(value); + } else { + r[@"value"] = [NSNull null]; + r[@"type"] = @"null"; + } + return r; + } else { + // All keys in domain + CFArrayRef keys = CFPreferencesCopyKeyList((__bridge CFStringRef)domain, + kCFPreferencesCurrentUser, + kCFPreferencesAnyHost); + + NSMutableDictionary *r = vp_make_response(@"settings_get", reqId); + if (keys) { + CFDictionaryRef allValues = CFPreferencesCopyMultiple( + keys, (__bridge CFStringRef)domain, kCFPreferencesCurrentUser, + kCFPreferencesAnyHost); + if (allValues) { + // Convert to serializable dict + NSDictionary *dict = (__bridge NSDictionary *)allValues; + NSMutableDictionary *serialized = [NSMutableDictionary dictionary]; + for (NSString *k in dict) { + NSDictionary *entry = serialize_value(dict[k]); + serialized[k] = entry; + } + r[@"value"] = serialized; + r[@"type"] = @"dictionary"; + CFRelease(allValues); + } + CFRelease(keys); + } else { + r[@"value"] = @{}; + r[@"type"] = @"dictionary"; + } + return r; + } + } + + // -- settings_set -- + if ([type isEqualToString:@"settings_set"]) { + NSString *domain = msg[@"domain"]; + NSString *key = msg[@"key"]; + id rawValue = msg[@"value"]; + NSString *typeHint = msg[@"type"]; + + if (!domain || !key) { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = @"missing domain or key"; + return r; + } + + id value = deserialize_value(rawValue, typeHint); + + CFPreferencesSetAppValue((__bridge CFStringRef)key, + (__bridge CFPropertyListRef)value, + (__bridge CFStringRef)domain); + CFPreferencesAppSynchronize((__bridge CFStringRef)domain); + + NSMutableDictionary *r = vp_make_response(@"settings_set", reqId); + r[@"ok"] = @YES; + return r; + } + + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = [NSString stringWithFormat:@"unknown settings command: %@", type]; + return r; +} diff --git a/scripts/vphoned/vphoned_url.h b/scripts/vphoned/vphoned_url.h new file mode 100644 index 0000000..ff364fd --- /dev/null +++ b/scripts/vphoned/vphoned_url.h @@ -0,0 +1,11 @@ +/* + * vphoned_url — URL opening over vsock. + * + * Handles open_url using LSApplicationWorkspace. + */ + +#pragma once +#import + +/// Handle an open_url command. Returns a response dict. +NSDictionary *vp_handle_url_command(NSDictionary *msg); diff --git a/scripts/vphoned/vphoned_url.m b/scripts/vphoned/vphoned_url.m new file mode 100644 index 0000000..2f53ab4 --- /dev/null +++ b/scripts/vphoned/vphoned_url.m @@ -0,0 +1,59 @@ +/* + * vphoned_url — URL opening via LSApplicationWorkspace. + * + * Uses LSApplicationWorkspace (CoreServices) to open URLs. + * Does not require UIKit — works from daemon context. + */ + +#import "vphoned_url.h" +#import "vphoned_protocol.h" +#include + +@interface LSApplicationWorkspace : NSObject ++ (instancetype)defaultWorkspace; +- (BOOL)openURL:(NSURL *)url withOptions:(NSDictionary *)options; +- (BOOL)openSensitiveURL:(NSURL *)url withOptions:(NSDictionary *)options; +@end + +NSDictionary *vp_handle_url_command(NSDictionary *msg) { + id reqId = msg[@"id"]; + NSString *urlStr = msg[@"url"]; + + if (!urlStr) { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = @"missing url"; + return r; + } + + NSURL *url = [NSURL URLWithString:urlStr]; + if (!url) { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = [NSString stringWithFormat:@"invalid url: %@", urlStr]; + return r; + } + + LSApplicationWorkspace *ws = [LSApplicationWorkspace defaultWorkspace]; + BOOL ok = NO; + + // Try openURL:withOptions: first + SEL openURLSel = sel_registerName("openURL:withOptions:"); + if ([ws respondsToSelector:openURLSel]) { + ok = ((BOOL (*)(id, SEL, id, id))objc_msgSend)(ws, openURLSel, url, nil); + } + + if (!ok) { + // Fallback: try openSensitiveURL:withOptions: (requires entitlement) + SEL sensitiveSel = sel_registerName("openSensitiveURL:withOptions:"); + if ([ws respondsToSelector:sensitiveSel]) { + ok = + ((BOOL (*)(id, SEL, id, id))objc_msgSend)(ws, sensitiveSel, url, nil); + } + } + + NSMutableDictionary *r = vp_make_response(@"open_url", reqId); + r[@"ok"] = @(ok); + if (!ok) { + r[@"msg"] = [NSString stringWithFormat:@"failed to open url: %@", urlStr]; + } + return r; +} diff --git a/sources/vphone-cli/VPhoneAppBrowserModel.swift b/sources/vphone-cli/VPhoneAppBrowserModel.swift new file mode 100644 index 0000000..cf9f3ba --- /dev/null +++ b/sources/vphone-cli/VPhoneAppBrowserModel.swift @@ -0,0 +1,43 @@ +import Foundation + +@MainActor +@Observable +class VPhoneAppBrowserModel { + let control: VPhoneControl + + var apps: [VPhoneControl.AppInfo] = [] + var filter: AppFilter = .installed + var searchText = "" + var isLoading = false + var error: String? + + enum AppFilter: String, CaseIterable { + case installed = "all" + case running + case user + case system + } + + var filteredApps: [VPhoneControl.AppInfo] { + guard !searchText.isEmpty else { return apps } + let query = searchText.lowercased() + return apps.filter { + $0.name.lowercased().contains(query) + || $0.bundleId.lowercased().contains(query) + } + } + + init(control: VPhoneControl) { + self.control = control + } + + func refresh() async { + isLoading = true + defer { isLoading = false } + do { + apps = try await control.appList(filter: filter.rawValue) + } catch { + self.error = "\(error)" + } + } +} diff --git a/sources/vphone-cli/VPhoneAppBrowserView.swift b/sources/vphone-cli/VPhoneAppBrowserView.swift new file mode 100644 index 0000000..60c42b3 --- /dev/null +++ b/sources/vphone-cli/VPhoneAppBrowserView.swift @@ -0,0 +1,133 @@ +import SwiftUI + +struct VPhoneAppBrowserView: View { + @Bindable var model: VPhoneAppBrowserModel + + var body: some View { + VStack(spacing: 0) { + filterBar + Divider() + if model.isLoading && model.apps.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if model.filteredApps.isEmpty { + ContentUnavailableView( + "No Apps", + systemImage: "app.dashed", + description: Text(model.searchText.isEmpty ? "No apps found." : "No matching apps.") + ) + } else { + appTable + } + } + .searchable(text: $model.searchText, prompt: "Filter by name or bundle ID") + .task { await model.refresh() } + .alert( + "Error", + isPresented: .init( + get: { model.error != nil }, + set: { if !$0 { model.error = nil } } + ) + ) { + Button("OK") { model.error = nil } + } message: { + Text(model.error ?? "") + } + } + + // MARK: - Filter Bar + + private var filterBar: some View { + HStack(spacing: 12) { + Picker("Filter", selection: $model.filter) { + ForEach(VPhoneAppBrowserModel.AppFilter.allCases, id: \.self) { f in + Text(f.rawValue.capitalized).tag(f) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 400) + + Spacer() + + Text("\(model.filteredApps.count) apps") + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + + Button { + Task { await model.refresh() } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(model.isLoading) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .onChange(of: model.filter) { + Task { await model.refresh() } + } + } + + // MARK: - Table + + private var appTable: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(model.filteredApps.enumerated()), id: \.element.bundleId) { index, app in + appRow(app) + .background(index % 2 == 0 ? Color.clear : Color.primary.opacity(0.03)) + } + } + } + } + + private func appRow(_ app: VPhoneControl.AppInfo) -> some View { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(app.name.isEmpty ? app.bundleId : app.name) + .font(.system(.body, design: .monospaced)) + .fontWeight(.medium) + .lineLimit(1) + + if !app.version.isEmpty { + Text("v\(app.version)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + + Text(app.type) + .font(.system(.caption2, design: .monospaced)) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background( + app.type == "system" + ? Color.blue.opacity(0.15) : Color.green.opacity(0.15) + ) + .cornerRadius(3) + } + + Text(app.bundleId) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .textSelection(.enabled) + } + + Spacer() + + if app.pid > 0 { + HStack(spacing: 4) { + Circle() + .fill(.green) + .frame(width: 6, height: 6) + Text("pid \(app.pid)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .contentShape(Rectangle()) + } +} diff --git a/sources/vphone-cli/VPhoneAppDelegate.swift b/sources/vphone-cli/VPhoneAppDelegate.swift index 7622ca9..7497041 100644 --- a/sources/vphone-cli/VPhoneAppDelegate.swift +++ b/sources/vphone-cli/VPhoneAppDelegate.swift @@ -10,6 +10,7 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { private var menuController: VPhoneMenuController? private var fileWindowController: VPhoneFileWindowController? private var keychainWindowController: VPhoneKeychainWindowController? + private var appWindowController: VPhoneAppWindowController? private var locationProvider: VPhoneLocationProvider? private var sigintSource: DispatchSourceSignal? @@ -24,7 +25,7 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { signal(SIGINT, SIG_IGN) let src = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main) src.setEventHandler { - print("\n[vphone] SIGINT - shutting down") + print("\n[vphone] SIGINT — shutting down") NSApp.terminate(nil) } src.activate() @@ -109,6 +110,9 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { let keychainWC = VPhoneKeychainWindowController() keychainWindowController = keychainWC + let appWC = VPhoneAppWindowController() + appWindowController = appWC + let mc = VPhoneMenuController(keyHelper: keyHelper, control: control) mc.vm = vm mc.captureView = wc.captureView @@ -120,6 +124,10 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { guard let keychainWC, let control else { return } keychainWC.showWindow(control: control) } + mc.onAppsPressed = { [weak appWC, weak control] in + guard let appWC, let control else { return } + appWC.showWindow(control: control) + } if let provider = locationProvider { mc.locationProvider = provider } @@ -130,6 +138,9 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { control.onConnect = { [weak mc, weak provider = locationProvider] caps in mc?.updateConnectAvailability(available: true) mc?.updateInstallAvailability(available: true) + mc?.updateAppsAvailability(available: caps.contains("apps")) + mc?.updateClipboardAvailability(available: caps.contains("clipboard")) + mc?.updateSettingsAvailability(available: true) if caps.contains("location") { mc?.updateLocationCapability(available: true) // Auto-resume if user had toggle on @@ -143,6 +154,9 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { control.onDisconnect = { [weak mc, weak provider = locationProvider] in mc?.updateConnectAvailability(available: false) mc?.updateInstallAvailability(available: false) + mc?.updateAppsAvailability(available: false) + mc?.updateClipboardAvailability(available: false) + mc?.updateSettingsAvailability(available: false) provider?.stopReplay() provider?.stopForwarding() mc?.updateLocationCapability(available: false) diff --git a/sources/vphone-cli/VPhoneAppWindowController.swift b/sources/vphone-cli/VPhoneAppWindowController.swift new file mode 100644 index 0000000..eaa8514 --- /dev/null +++ b/sources/vphone-cli/VPhoneAppWindowController.swift @@ -0,0 +1,53 @@ +import AppKit +import SwiftUI + +@MainActor +class VPhoneAppWindowController { + private var window: NSWindow? + private var model: VPhoneAppBrowserModel? + + func showWindow(control: VPhoneControl) { + if let window { + window.makeKeyAndOrderFront(nil) + return + } + + let model = VPhoneAppBrowserModel(control: control) + self.model = model + + let view = VPhoneAppBrowserView(model: model) + let hostingView = NSHostingView(rootView: view) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 650, height: 500), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.title = "Apps" + window.subtitle = "vphone" + window.contentView = hostingView + window.contentMinSize = NSSize(width: 450, height: 300) + window.center() + window.toolbarStyle = .unified + window.isReleasedWhenClosed = false + + let toolbar = NSToolbar(identifier: "vphone-apps-toolbar") + toolbar.displayMode = .iconOnly + window.toolbar = toolbar + + window.makeKeyAndOrderFront(nil) + self.window = window + + NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.window = nil + self?.model = nil + } + } + } +} diff --git a/sources/vphone-cli/VPhoneCLI.swift b/sources/vphone-cli/VPhoneCLI.swift index 78896d3..8a15e13 100644 --- a/sources/vphone-cli/VPhoneCLI.swift +++ b/sources/vphone-cli/VPhoneCLI.swift @@ -7,7 +7,7 @@ struct VPhoneCLI: ParsableCommand { abstract: "Boot a virtual iPhone (PV=3)", discussion: """ Creates a Virtualization.framework VM with platform version 3 (vphone) - and boots it into DFU mode for firmware loading via irecovery. + and boots it from a manifest plist that describes all paths and hardware. Requires: - macOS 15+ (Sequoia or later) @@ -15,7 +15,7 @@ struct VPhoneCLI: ParsableCommand { - Signed with vphone entitlements (done automatically by wrapper script) Example: - vphone-cli --config config.plist --rom ./AVPBooter.vresearch1.bin --disk ./Disk.img + vphone-cli --config ./config.plist """ ) @@ -25,100 +25,40 @@ struct VPhoneCLI: ParsableCommand { ) var config: URL - @Option(help: "Path to the AVPBooter / ROM binary") - var rom: String - - @Option(help: "Path to the disk image") - var disk: String - - @Option(help: "Path to NVRAM storage (created/overwritten)") - var nvram: String = "nvram.bin" - - @Option(help: "Number of CPU cores (overridden by --config if present)") - var cpu: Int? - - @Option(help: "Memory size in MB (overridden by --config if present)") - var memory: Int? - - @Option(help: "Path to SEP storage file (created if missing)") - var sepStorage: String - - @Option(help: "Path to SEP ROM binary") - var sepRom: String - @Flag(help: "Boot into DFU mode") var dfu: Bool = false - @Option(help: "Display width in pixels (overridden by --config if present)") - var screenWidth: Int? - - @Option(help: "Display height in pixels (overridden by --config if present)") - var screenHeight: Int? - - @Option(help: "Display pixels per inch (overridden by --config if present)") - var screenPpi: Int? - - @Option(help: "Window scale divisor (default: 3.0)") - var screenScale: Double = 3.0 - @Option(help: "Kernel GDB debug stub port on host (omit for system-assigned port; valid: 6000...65535)") var kernelDebugPort: Int? - @Flag(help: "Run without GUI (headless)") - var noGraphics: Bool = false + /// DFU mode runs headless (no GUI). + var noGraphics: Bool { + dfu + } @Option(help: "Path to signed vphoned binary for guest auto-update") var vphonedBin: String = ".vphoned.signed" - /// Resolve final options by merging manifest with command-line overrides + /// Resolve final options by merging manifest values func resolveOptions() throws -> VPhoneVirtualMachine.Options { - // Start with command-line paths - let romURL = URL(fileURLWithPath: rom) - let diskURL = URL(fileURLWithPath: disk) - let nvramURL = URL(fileURLWithPath: nvram) - let sepStorageURL = URL(fileURLWithPath: sepStorage) - let sepRomURL = URL(fileURLWithPath: sepRom) - - // Default values - var resolvedCpuCount = 8 - var resolvedMemorySize: UInt64 = 8 * 1024 * 1024 * 1024 - var resolvedScreenWidth = 1290 - var resolvedScreenHeight = 2796 - var resolvedScreenPpi = 460 - var resolvedScreenScale = 3.0 - - // Load manifest (required) let manifest = try VPhoneVirtualMachineManifest.load(from: config) print("[vphone] Loaded VM manifest from \(config.path)") - // Apply manifest settings - resolvedCpuCount = Int(manifest.cpuCount) - resolvedMemorySize = manifest.memorySize - resolvedScreenWidth = manifest.screenConfig.width - resolvedScreenHeight = manifest.screenConfig.height - resolvedScreenPpi = manifest.screenConfig.pixelsPerInch - resolvedScreenScale = manifest.screenConfig.scale - - // Apply command-line overrides (if provided) - if let cpuArg = cpu { resolvedCpuCount = cpuArg } - if let memoryArg = memory { resolvedMemorySize = UInt64(memoryArg) * 1024 * 1024 } - if let screenWidthArg = screenWidth { resolvedScreenWidth = screenWidthArg } - if let screenHeightArg = screenHeight { resolvedScreenHeight = screenHeightArg } - if let screenPpiArg = screenPpi { resolvedScreenPpi = screenPpiArg } + let vmDir = config.deletingLastPathComponent() return VPhoneVirtualMachine.Options( configURL: config, - romURL: romURL, - nvramURL: nvramURL, - diskURL: diskURL, - cpuCount: resolvedCpuCount, - memorySize: resolvedMemorySize, - sepStorageURL: sepStorageURL, - sepRomURL: sepRomURL, - screenWidth: resolvedScreenWidth, - screenHeight: resolvedScreenHeight, - screenPPI: resolvedScreenPpi, - screenScale: resolvedScreenScale, + romURL: manifest.resolve(path: manifest.romImages.avpBooter, in: vmDir), + nvramURL: manifest.resolve(path: manifest.nvramStorage, in: vmDir), + diskURL: manifest.resolve(path: manifest.diskImage, in: vmDir), + cpuCount: Int(manifest.cpuCount), + memorySize: manifest.memorySize, + sepStorageURL: manifest.resolve(path: manifest.sepStorage, in: vmDir), + sepRomURL: manifest.resolve(path: manifest.romImages.avpSEPBooter, in: vmDir), + screenWidth: manifest.screenConfig.width, + screenHeight: manifest.screenConfig.height, + screenPPI: manifest.screenConfig.pixelsPerInch, + screenScale: manifest.screenConfig.scale, kernelDebugPort: kernelDebugPort ) } diff --git a/sources/vphone-cli/VPhoneControl.swift b/sources/vphone-cli/VPhoneControl.swift index c806bf2..2306b15 100644 --- a/sources/vphone-cli/VPhoneControl.swift +++ b/sources/vphone-cli/VPhoneControl.swift @@ -100,8 +100,12 @@ class VPhoneControl { let candidates = [ Bundle.main.resourceURL?.appendingPathComponent("signcert.p12"), Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/signcert.p12"), - URL(fileURLWithPath: fm.currentDirectoryPath).appendingPathComponent("scripts/vphoned/signcert.p12"), - URL(fileURLWithPath: fm.currentDirectoryPath).appendingPathComponent("../scripts/vphoned/signcert.p12"), + URL(fileURLWithPath: fm.currentDirectoryPath).appendingPathComponent( + "scripts/vphoned/signcert.p12" + ), + URL(fileURLWithPath: fm.currentDirectoryPath).appendingPathComponent( + "../scripts/vphoned/signcert.p12" + ), ] for candidate in candidates.compactMap(\.self) { if fm.fileExists(atPath: candidate.path) { @@ -485,8 +489,12 @@ class VPhoneControl { return KeychainResult(items: items, diagnostics: diag) } - func addKeychainItem(account: String = "vphone-test", service: String = "vphone", password: String = "testpass123") async throws -> Bool { - let req: [String: Any] = ["t": "keychain_add", "account": account, "service": service, "password": password] + func addKeychainItem( + account: String = "vphone-test", service: String = "vphone", password: String = "testpass123" + ) async throws -> Bool { + let req: [String: Any] = [ + "t": "keychain_add", "account": account, "service": service, "password": password, + ] let (resp, _) = try await sendRequest(req) let ok = resp["ok"] as? Bool ?? false if !ok { @@ -496,6 +504,158 @@ class VPhoneControl { return true } + // MARK: - Clipboard + + struct ClipboardContent { + let text: String? + let types: [String] + let hasImage: Bool + let changeCount: Int + let imageData: Data? + } + + func clipboardGet() async throws -> ClipboardContent { + let (resp, data) = try await sendRequest(["t": "clipboard_get"]) + let text = resp["text"] as? String + let types = resp["types"] as? [String] ?? [] + let hasImage = resp["has_image"] as? Bool ?? false + let changeCount = resp["change_count"] as? Int ?? 0 + return ClipboardContent( + text: text, types: types, hasImage: hasImage, changeCount: changeCount, imageData: data + ) + } + + func clipboardSet(text: String) async throws { + _ = try await sendRequest(["t": "clipboard_set", "text": text]) + } + + func clipboardSet(imageData: Data) async throws { + guard let fd = connection?.fileDescriptor else { + throw ControlError.notConnected + } + + nextRequestId += 1 + let reqId = String(nextRequestId, radix: 16) + let header: [String: Any] = [ + "v": Self.protocolVersion, + "t": "clipboard_set", + "id": reqId, + "type": "image", + "size": imageData.count, + ] + let timeout = Self.timeoutForRequest(type: "clipboard_set") + + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + addPending(id: reqId) { result in + switch result { + case .success: continuation.resume() + case let .failure(error): continuation.resume(throwing: error) + } + } + armRequestTimeout(id: reqId, type: "clipboard_set", timeout: timeout) + + guard writeMessage(fd: fd, dict: header) else { + _ = removePending(id: reqId) + continuation.resume(throwing: ControlError.notConnected) + return + } + let ok = imageData.withUnsafeBytes { buf in + Self.writeFully(fd: fd, buf: buf.baseAddress!, count: imageData.count) + } + guard ok else { + _ = removePending(id: reqId) + continuation.resume(throwing: ControlError.protocolError("failed to write image data")) + return + } + } + } + + // MARK: - App Management + + struct AppInfo { + let bundleId: String + let name: String + let version: String + let type: String + let state: String + let pid: Int + let path: String + let dataContainer: String + } + + func appList(filter: String = "all") async throws -> [AppInfo] { + let (resp, _) = try await sendRequest(["t": "app_list", "filter": filter]) + guard let apps = resp["apps"] as? [[String: Any]] else { + throw ControlError.protocolError("missing apps in response") + } + return apps.map { app in + AppInfo( + bundleId: app["bundle_id"] as? String ?? "", + name: app["name"] as? String ?? "", + version: app["version"] as? String ?? "", + type: app["type"] as? String ?? "", + state: app["state"] as? String ?? "", + pid: app["pid"] as? Int ?? 0, + path: app["path"] as? String ?? "", + dataContainer: app["data_container"] as? String ?? "" + ) + } + } + + func appLaunch(bundleId: String, url: String? = nil) async throws -> Int { + var req: [String: Any] = ["t": "app_launch", "bundle_id": bundleId] + if let url { req["url"] = url } + let (resp, _) = try await sendRequest(req) + return resp["pid"] as? Int ?? 0 + } + + func appTerminate(bundleId: String) async throws { + _ = try await sendRequest(["t": "app_terminate", "bundle_id": bundleId]) + } + + func appForeground() async throws -> (bundleId: String, name: String, pid: Int) { + let (resp, _) = try await sendRequest(["t": "app_foreground"]) + return ( + bundleId: resp["bundle_id"] as? String ?? "", + name: resp["name"] as? String ?? "", + pid: resp["pid"] as? Int ?? 0 + ) + } + + // MARK: - URL + + func openURL(_ url: String) async throws { + let (resp, _) = try await sendRequest(["t": "open_url", "url": url]) + let ok = resp["ok"] as? Bool ?? false + if !ok { + let msg = resp["msg"] as? String ?? "failed to open URL" + throw ControlError.guestError(msg) + } + } + + // MARK: - Settings + + func settingsGet(domain: String, key: String? = nil) async throws -> Any? { + var req: [String: Any] = ["t": "settings_get", "domain": domain] + if let key { req["key"] = key } + let (resp, _) = try await sendRequest(req) + return resp["value"] + } + + func settingsSet(domain: String, key: String, value: Any, type: String? = nil) async throws { + var req: [String: Any] = ["t": "settings_set", "domain": domain, "key": key, "value": value] + if let type { req["type"] = type } + _ = try await sendRequest(req) + } + + // MARK: - Accessibility + + func accessibilityTree(depth: Int = -1) async throws -> [String: Any] { + let (resp, _) = try await sendRequest(["t": "accessibility_tree", "depth": depth]) + return resp + } + // MARK: - Location func sendLocation( @@ -577,6 +737,8 @@ class VPhoneControl { // Check for pending request callback if let reqId, let pending = removePending(id: reqId) { + nonisolated(unsafe) let safeMsg = msg + if type == "err" { let detail = msg["msg"] as? String ?? "unknown error" DispatchQueue.main.async { pending.handler(.failure(ControlError.guestError(detail))) } @@ -591,23 +753,48 @@ class VPhoneControl { if Self.readFully(fd: fd, buf: buf, count: size) { let data = Data(bytes: buf, count: size) buf.deallocate() - DispatchQueue.main.async { pending.handler(.success((msg, data))) } + DispatchQueue.main.async { pending.handler(.success((safeMsg, data))) } } else { buf.deallocate() - DispatchQueue.main.async { pending.handler(.failure(ControlError.protocolError("failed to read file data"))) } + DispatchQueue.main.async { + pending.handler(.failure(ControlError.protocolError("failed to read file data"))) + } } } else { - DispatchQueue.main.async { pending.handler(.success((msg, Data()))) } + DispatchQueue.main.async { pending.handler(.success((safeMsg, Data()))) } + } + continue + } + + // For clipboard_get with image, read inline binary payload + if type == "clipboard_get", msg["has_image"] as? Bool == true { + let size = msg["image_size"] as? Int ?? 0 + if size > 0 { + let buf = UnsafeMutablePointer.allocate(capacity: size) + if Self.readFully(fd: fd, buf: buf, count: size) { + let data = Data(bytes: buf, count: size) + buf.deallocate() + DispatchQueue.main.async { pending.handler(.success((safeMsg, data))) } + } else { + buf.deallocate() + DispatchQueue.main.async { + pending.handler( + .failure(ControlError.protocolError("failed to read clipboard image data")) + ) + } + } + } else { + DispatchQueue.main.async { pending.handler(.success((safeMsg, nil))) } } continue } // Normal response (ok, pong, etc.) - DispatchQueue.main.async { pending.handler(.success((msg, nil))) } + DispatchQueue.main.async { pending.handler(.success((safeMsg, nil))) } continue } - // No pending request - handle as before (fire-and-forget) + // No pending request — handle as before (fire-and-forget) switch type { case "ok": let detail = msg["msg"] as? String ?? "" @@ -685,7 +872,8 @@ class VPhoneControl { switch type { case "file_get", "file_put", "ipa_install": transferRequestTimeout - case "devmode", "file_list", "file_delete", "file_rename", "file_mkdir", "keychain_list": + case "devmode", "file_list", "file_delete", "file_rename", "file_mkdir", "keychain_list", + "app_list", "app_launch", "open_url", "accessibility_tree": slowRequestTimeout default: defaultRequestTimeout @@ -698,7 +886,9 @@ class VPhoneControl { DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + timeout) { [weak self] in guard let self else { return } guard let pending = removePending(id: id) else { return } - DispatchQueue.main.async { pending.handler(.failure(ControlError.requestTimedOut(type: type, seconds: timeoutSeconds))) } + DispatchQueue.main.async { + pending.handler(.failure(ControlError.requestTimedOut(type: type, seconds: timeoutSeconds))) + } } } diff --git a/sources/vphone-cli/VPhoneMenuInstall.swift b/sources/vphone-cli/VPhoneMenuApps.swift similarity index 51% rename from sources/vphone-cli/VPhoneMenuInstall.swift rename to sources/vphone-cli/VPhoneMenuApps.swift index 29d9804..ef71fcf 100644 --- a/sources/vphone-cli/VPhoneMenuInstall.swift +++ b/sources/vphone-cli/VPhoneMenuApps.swift @@ -1,26 +1,50 @@ import AppKit import Foundation -// MARK: - Install Menu +// MARK: - Apps Menu extension VPhoneMenuController { - func buildInstallMenu() -> NSMenuItem { + func buildAppsMenu() -> NSMenuItem { let item = NSMenuItem() - let menu = NSMenu(title: "Install") + let menu = NSMenu(title: "Apps") menu.autoenablesItems = false + let browse = makeItem("App Browser", action: #selector(openAppBrowser)) + browse.isEnabled = false + appsListItem = browse + menu.addItem(browse) + + menu.addItem(NSMenuItem.separator()) + + let openURL = makeItem("Open URL...", action: #selector(openURL)) + openURL.isEnabled = false + appsOpenURLItem = openURL + menu.addItem(openURL) + + menu.addItem(NSMenuItem.separator()) + let install = makeItem("Install IPA/TIPA...", action: #selector(installIPAFromDisk)) install.isEnabled = false installPackageItem = install menu.addItem(install) + item.submenu = menu return item } + func updateAppsAvailability(available: Bool) { + appsListItem?.isEnabled = available + appsOpenURLItem?.isEnabled = available + } + func updateInstallAvailability(available: Bool) { installPackageItem?.isEnabled = available } + @objc func openAppBrowser() { + onAppsPressed?() + } + @objc func installIPAFromDisk() { guard control.isConnected else { showAlert(title: "Install App Package", message: "Guest is not connected.", style: .warning) @@ -55,4 +79,30 @@ extension VPhoneMenuController { } } } + + @objc func openURL() { + let alert = NSAlert() + alert.messageText = "Open URL" + alert.informativeText = "Enter URL to open on the guest:" + alert.alertStyle = .informational + alert.addButton(withTitle: "Open") + alert.addButton(withTitle: "Cancel") + + let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 400, height: 24)) + input.placeholderString = "https://example.com" + alert.accessoryView = input + + guard alert.runModal() == .alertFirstButtonReturn else { return } + let url = input.stringValue + guard !url.isEmpty else { return } + + Task { + do { + try await control.openURL(url) + showAlert(title: "Open URL", message: "Opened \(url)", style: .informational) + } catch { + showAlert(title: "Open URL", message: "\(error)", style: .warning) + } + } + } } diff --git a/sources/vphone-cli/VPhoneMenuBattery.swift b/sources/vphone-cli/VPhoneMenuBattery.swift index 292e397..0f22580 100644 --- a/sources/vphone-cli/VPhoneMenuBattery.swift +++ b/sources/vphone-cli/VPhoneMenuBattery.swift @@ -3,8 +3,8 @@ import AppKit // MARK: - Battery Menu extension VPhoneMenuController { - func buildBatteryMenu() -> NSMenuItem { - let item = NSMenuItem() + func buildBatterySubmenu() -> NSMenuItem { + let item = NSMenuItem(title: "Battery", action: nil, keyEquivalent: "") let menu = NSMenu(title: "Battery") // Charge level presets diff --git a/sources/vphone-cli/VPhoneMenuConnect.swift b/sources/vphone-cli/VPhoneMenuConnect.swift index cd65d2f..56a2bb3 100644 --- a/sources/vphone-cli/VPhoneMenuConnect.swift +++ b/sources/vphone-cli/VPhoneMenuConnect.swift @@ -37,10 +37,44 @@ extension VPhoneMenuController { connectGuestVersionItem = guestVersion menu.addItem(guestVersion) + menu.addItem(NSMenuItem.separator()) + + let clipGet = makeItem("Get Clipboard", action: #selector(getClipboard)) + clipGet.isEnabled = false + clipboardGetItem = clipGet + menu.addItem(clipGet) + + let clipSet = makeItem("Set Clipboard Text...", action: #selector(setClipboardText)) + clipSet.isEnabled = false + clipboardSetItem = clipSet + menu.addItem(clipSet) + + menu.addItem(NSMenuItem.separator()) + + let settingsGet = makeItem("Read Setting...", action: #selector(readSetting)) + settingsGet.isEnabled = false + settingsGetItem = settingsGet + menu.addItem(settingsGet) + + let settingsSet = makeItem("Write Setting...", action: #selector(writeSetting)) + settingsSet.isEnabled = false + settingsSetItem = settingsSet + menu.addItem(settingsSet) + + menu.addItem(NSMenuItem.separator()) + + menu.addItem(buildLocationSubmenu()) + menu.addItem(buildBatterySubmenu()) + item.submenu = menu return item } + func updateSettingsAvailability(available: Bool) { + settingsGetItem?.isEnabled = available + settingsSetItem?.isEnabled = available + } + func updateConnectAvailability(available: Bool) { connectFileBrowserItem?.isEnabled = available connectKeychainBrowserItem?.isEnabled = available @@ -94,6 +128,187 @@ extension VPhoneMenuController { } } + func updateClipboardAvailability(available: Bool) { + clipboardGetItem?.isEnabled = available + clipboardSetItem?.isEnabled = available + } + + // MARK: - Clipboard + + @objc func getClipboard() { + Task { + do { + let content = try await control.clipboardGet() + var message = "" + if let text = content.text { + let truncated = text.count > 500 ? String(text.prefix(500)) + "..." : text + message += "Text: \(truncated)\n" + } + message += "Types: \(content.types.joined(separator: ", "))\n" + message += "Has Image: \(content.hasImage)\n" + message += "Change Count: \(content.changeCount)" + showAlert(title: "Clipboard Content", message: message, style: .informational) + } catch { + showAlert(title: "Clipboard", message: "\(error)", style: .warning) + } + } + } + + @objc func setClipboardText() { + let alert = NSAlert() + alert.messageText = "Set Clipboard Text" + alert.informativeText = "Enter text to set on the guest clipboard:" + alert.alertStyle = .informational + alert.addButton(withTitle: "Set") + alert.addButton(withTitle: "Cancel") + + let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 80)) + input.placeholderString = "Text to copy to clipboard" + alert.accessoryView = input + + guard alert.runModal() == .alertFirstButtonReturn else { return } + let text = input.stringValue + guard !text.isEmpty else { return } + + Task { + do { + try await control.clipboardSet(text: text) + showAlert(title: "Clipboard", message: "Text set successfully.", style: .informational) + } catch { + showAlert(title: "Clipboard", message: "\(error)", style: .warning) + } + } + } + + // MARK: - Settings + + @objc func readSetting() { + let alert = NSAlert() + alert.messageText = "Read Setting" + alert.informativeText = "Enter preference domain and key:" + alert.alertStyle = .informational + alert.addButton(withTitle: "Read") + alert.addButton(withTitle: "Cancel") + + let stack = NSStackView(frame: NSRect(x: 0, y: 0, width: 350, height: 56)) + stack.orientation = .vertical + stack.spacing = 8 + + let domainField = NSTextField(frame: .zero) + domainField.placeholderString = "com.apple.springboard" + domainField.translatesAutoresizingMaskIntoConstraints = false + domainField.widthAnchor.constraint(equalToConstant: 350).isActive = true + + let keyField = NSTextField(frame: .zero) + keyField.placeholderString = "Key (leave empty for all keys)" + keyField.translatesAutoresizingMaskIntoConstraints = false + keyField.widthAnchor.constraint(equalToConstant: 350).isActive = true + + stack.addArrangedSubview(domainField) + stack.addArrangedSubview(keyField) + alert.accessoryView = stack + + guard alert.runModal() == .alertFirstButtonReturn else { return } + let domain = domainField.stringValue + guard !domain.isEmpty else { return } + let key: String? = keyField.stringValue.isEmpty ? nil : keyField.stringValue + + Task { + do { + let value = try await control.settingsGet(domain: domain, key: key) + let display: String + if let dict = value as? [String: Any] { + let data = try JSONSerialization.data( + withJSONObject: dict, options: [.prettyPrinted, .sortedKeys] + ) + display = String(data: data, encoding: .utf8) ?? "\(dict)" + } else { + display = "\(value ?? "nil")" + } + let truncated = display.count > 2000 ? String(display.prefix(2000)) + "\n..." : display + showAlert( + title: "Setting: \(domain)\(key.map { ".\($0)" } ?? "")", + message: truncated, + style: .informational + ) + } catch { + showAlert(title: "Read Setting", message: "\(error)", style: .warning) + } + } + } + + @objc func writeSetting() { + let alert = NSAlert() + alert.messageText = "Write Setting" + alert.informativeText = "Enter preference domain, key, type, and value:" + alert.alertStyle = .informational + alert.addButton(withTitle: "Write") + alert.addButton(withTitle: "Cancel") + + let stack = NSStackView(frame: NSRect(x: 0, y: 0, width: 350, height: 116)) + stack.orientation = .vertical + stack.spacing = 8 + + let domainField = NSTextField(frame: .zero) + domainField.placeholderString = "com.apple.springboard" + domainField.translatesAutoresizingMaskIntoConstraints = false + domainField.widthAnchor.constraint(equalToConstant: 350).isActive = true + + let keyField = NSTextField(frame: .zero) + keyField.placeholderString = "Key" + keyField.translatesAutoresizingMaskIntoConstraints = false + keyField.widthAnchor.constraint(equalToConstant: 350).isActive = true + + let typeField = NSTextField(frame: .zero) + typeField.placeholderString = "Type: boolean | string | integer | float" + typeField.translatesAutoresizingMaskIntoConstraints = false + typeField.widthAnchor.constraint(equalToConstant: 350).isActive = true + + let valueField = NSTextField(frame: .zero) + valueField.placeholderString = "Value" + valueField.translatesAutoresizingMaskIntoConstraints = false + valueField.widthAnchor.constraint(equalToConstant: 350).isActive = true + + stack.addArrangedSubview(domainField) + stack.addArrangedSubview(keyField) + stack.addArrangedSubview(typeField) + stack.addArrangedSubview(valueField) + alert.accessoryView = stack + + guard alert.runModal() == .alertFirstButtonReturn else { return } + let domain = domainField.stringValue + let key = keyField.stringValue + let type = typeField.stringValue + let rawValue = valueField.stringValue + guard !domain.isEmpty, !key.isEmpty else { return } + + let value: Any = + switch type.lowercased() { + case "boolean", "bool": + rawValue.lowercased() == "true" || rawValue == "1" + case "integer", "int": + Int(rawValue) ?? 0 + case "float", "double": + Double(rawValue) ?? 0.0 + default: + rawValue + } + + Task { + do { + try await control.settingsSet( + domain: domain, key: key, value: value, type: type.isEmpty ? nil : type + ) + showAlert( + title: "Write Setting", message: "Set \(domain).\(key) = \(rawValue)", + style: .informational + ) + } catch { + showAlert(title: "Write Setting", message: "\(error)", style: .warning) + } + } + } + // MARK: - Alert func showAlert(title: String, message: String, style: NSAlert.Style) { diff --git a/sources/vphone-cli/VPhoneMenuController.swift b/sources/vphone-cli/VPhoneMenuController.swift index 4227d09..5319a1a 100644 --- a/sources/vphone-cli/VPhoneMenuController.swift +++ b/sources/vphone-cli/VPhoneMenuController.swift @@ -11,12 +11,19 @@ class VPhoneMenuController { var onFilesPressed: (() -> Void)? var onKeychainPressed: (() -> Void)? + var onAppsPressed: (() -> Void)? var connectFileBrowserItem: NSMenuItem? var connectKeychainBrowserItem: NSMenuItem? var connectDevModeStatusItem: NSMenuItem? var connectPingItem: NSMenuItem? var connectGuestVersionItem: NSMenuItem? var installPackageItem: NSMenuItem? + var clipboardGetItem: NSMenuItem? + var clipboardSetItem: NSMenuItem? + var appsListItem: NSMenuItem? + var appsOpenURLItem: NSMenuItem? + var settingsGetItem: NSMenuItem? + var settingsSetItem: NSMenuItem? var locationProvider: VPhoneLocationProvider? var locationMenuItem: NSMenuItem? var locationPresetMenuItem: NSMenuItem? @@ -41,7 +48,9 @@ class VPhoneMenuController { let appMenuItem = NSMenuItem() let appMenu = NSMenu(title: "vphone") #if canImport(VPhoneBuildInfo) - let buildItem = NSMenuItem(title: "Build: \(VPhoneBuildInfo.commitHash)", action: nil, keyEquivalent: "") + let buildItem = NSMenuItem( + title: "Build: \(VPhoneBuildInfo.commitHash)", action: nil, keyEquivalent: "" + ) #else let buildItem = NSMenuItem(title: "Build: unknown", action: nil, keyEquivalent: "") #endif @@ -54,13 +63,10 @@ class VPhoneMenuController { appMenuItem.submenu = appMenu mainMenu.addItem(appMenuItem) - mainMenu.addItem(buildKeysMenu()) - mainMenu.addItem(buildTypeMenu()) mainMenu.addItem(buildConnectMenu()) - mainMenu.addItem(buildInstallMenu()) - mainMenu.addItem(buildLocationMenu()) + mainMenu.addItem(buildKeysMenu()) + mainMenu.addItem(buildAppsMenu()) mainMenu.addItem(buildRecordMenu()) - mainMenu.addItem(buildBatteryMenu()) NSApp.mainMenu = mainMenu } diff --git a/sources/vphone-cli/VPhoneMenuKeys.swift b/sources/vphone-cli/VPhoneMenuKeys.swift index 08a6f9a..b70ecc3 100644 --- a/sources/vphone-cli/VPhoneMenuKeys.swift +++ b/sources/vphone-cli/VPhoneMenuKeys.swift @@ -12,6 +12,8 @@ extension VPhoneMenuController { menu.addItem(makeItem("Volume Down", action: #selector(sendVolumeDown))) menu.addItem(NSMenuItem.separator()) menu.addItem(makeItem("Spotlight (Cmd+Space)", action: #selector(sendSpotlight))) + menu.addItem(NSMenuItem.separator()) + menu.addItem(makeItem("Type ASCII from Clipboard", action: #selector(typeFromClipboard))) item.submenu = menu return item } @@ -35,4 +37,8 @@ extension VPhoneMenuController { @objc func sendSpotlight() { keyHelper.sendSpotlight() } + + @objc func typeFromClipboard() { + keyHelper.typeFromClipboard() + } } diff --git a/sources/vphone-cli/VPhoneMenuLocation.swift b/sources/vphone-cli/VPhoneMenuLocation.swift index 50379ec..9d03d2f 100644 --- a/sources/vphone-cli/VPhoneMenuLocation.swift +++ b/sources/vphone-cli/VPhoneMenuLocation.swift @@ -49,8 +49,8 @@ private let locationReplayPoints: [VPhoneLocationProvider.ReplayPoint] = [ // MARK: - Location Menu extension VPhoneMenuController { - func buildLocationMenu() -> NSMenuItem { - let item = NSMenuItem() + func buildLocationSubmenu() -> NSMenuItem { + let item = NSMenuItem(title: "Location", action: nil, keyEquivalent: "") let menu = NSMenu(title: "Location") let toggle = makeItem("Sync Host Location", action: #selector(toggleLocationSync)) diff --git a/sources/vphone-cli/VPhoneMenuType.swift b/sources/vphone-cli/VPhoneMenuType.swift deleted file mode 100644 index c553a80..0000000 --- a/sources/vphone-cli/VPhoneMenuType.swift +++ /dev/null @@ -1,17 +0,0 @@ -import AppKit - -// MARK: - Type Menu - -extension VPhoneMenuController { - func buildTypeMenu() -> NSMenuItem { - let item = NSMenuItem() - let menu = NSMenu(title: "Type") - menu.addItem(makeItem("Type ASCII from Clipboard", action: #selector(typeFromClipboard))) - item.submenu = menu - return item - } - - @objc func typeFromClipboard() { - keyHelper.typeFromClipboard() - } -}