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()
- }
-}