Author SHA1 Message Date
Lakr bb12565070 fix: Resolve frontmost app detection always returning SpringBoard
vphoned runs as root (uid 0) in the system launchd domain, but
SBSCopyFrontmostApplicationDisplayIdentifier() internally looks up
SpringBoard's mach service which is registered in the mobile user
session (uid 501). The bootstrap lookup silently fails, returning nil,
causing the fallback to always report com.apple.springboard.

Fix by temporarily dropping euid to mobile (seteuid(501)) before
calling SBS functions, then restoring root after. Also add the
low-level SBFrontmostApplicationDisplayIdentifier(port, buf) MIG
variant as a secondary method, and a heuristic fallback that walks
running apps via FBSSystemService.

Add com.apple.springboard.debugapplications entitlement to vphoned.
2026-03-11 00:35:41 +08:00
Lakr 7a5a8edd27 refactor: Consolidate menus and simplify CLI to manifest-only config
- Merge Type menu into Keys, Clipboard menu into Connect
- Remove --rom/--disk/--nvram/--sep-rom/--sep-storage/--screen-* CLI args; all paths and hardware config now derived from config.plist manifest
- Remove --no-graphics flag; DFU mode implies headless
- Fix clipboard availability to be solely capability-gated, not double-set by updateConnectAvailability

update
2026-03-10 23:39:15 +08:00
LakrandClaude Opus 4.6 df5d5449b5 feat: Add vphoned modules (accessibility, apps, clipboard, settings, url) and host-side menus
Split vphoned into modular ObjC files for accessibility, apps, clipboard,
settings, and URL handling. Add corresponding Swift menu extensions and
VPhoneControl commands. Format all files with clang-format and swift-format.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-10 23:35:41 +08:00
24 changed files with 1896 additions and 472 deletions
+2 -12
View File
@@ -175,22 +175,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
+2
View File
@@ -505,6 +505,8 @@
<string>IOSurfaceRootUserClient</string>
<string>RootDomainUserClient</string>
</array>
<key>com.apple.springboard.debugapplications</key>
<true/>
<key>com.apple.springboard.launchapplications</key>
<true/>
<key>com.apple.springboard.launchapplicationswithoptions</key>
+382 -286
View File
@@ -13,362 +13,458 @@
* make vphoned
*/
#import <Foundation/Foundation.h>
#include <CommonCrypto/CommonDigest.h>
#import <Foundation/Foundation.h>
#include <mach-o/dyld.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <unistd.h>
#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;
}
}
}
}
+12
View File
@@ -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 <Foundation/Foundation.h>
/// Handle an accessibility_tree command. Returns a response dict.
NSDictionary *vp_handle_accessibility_command(NSDictionary *msg);
+21
View File
@@ -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;
}
+15
View File
@@ -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 <Foundation/Foundation.h>
/// 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);
+322
View File
@@ -0,0 +1,322 @@
/*
* vphoned_apps — App lifecycle management via private APIs.
*
* Uses LSApplicationWorkspace (CoreServices), FBSSystemService
* (FrontBoardServices), and SBSCopyFrontmostApplicationDisplayIdentifier
* (SpringBoardServices).
*/
#import "vphoned_apps.h"
#import "vphoned_protocol.h"
#include <dlfcn.h>
#include <mach/mach.h>
#include <objc/message.h>
#include <signal.h>
#include <unistd.h>
// 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;
// SBSCopyFrontmostApplicationDisplayIdentifier loaded via dlsym
static NSString *(*pSBSCopyFrontmost)(void) = NULL;
// Low-level MIG variants
static mach_port_t (*pSBSSpringBoardServerPort)(void) = NULL;
static void (*pSBFrontmostApplicationDisplayIdentifier)(mach_port_t port,
char *result) = NULL;
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());
}
// SpringBoardServices
void *sbs = dlopen("/System/Library/PrivateFrameworks/"
"SpringBoardServices.framework/SpringBoardServices",
RTLD_LAZY);
if (sbs) {
pSBSCopyFrontmost =
dlsym(sbs, "SBSCopyFrontmostApplicationDisplayIdentifier");
if (!pSBSCopyFrontmost) {
NSLog(@"vphoned: SBSCopyFrontmostApplicationDisplayIdentifier not found");
}
pSBSSpringBoardServerPort = dlsym(sbs, "SBSSpringBoardServerPort");
pSBFrontmostApplicationDisplayIdentifier =
dlsym(sbs, "SBFrontmostApplicationDisplayIdentifier");
} else {
NSLog(@"vphoned: dlopen SpringBoardServices 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, SBS=%s)",
gFBSSystemServiceClass ? "yes" : "no",
pSBSCopyFrontmost ? "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;
}
// -- app_foreground --
if ([type isEqualToString:@"app_foreground"]) {
NSString *frontApp = nil;
pid_t pid = 0;
NSString *name = @"";
// SBS functions talk to SpringBoard via mach IPC. When running as root,
// the bootstrap port resolves in the system domain, which cannot see
// SpringBoard's per-user mach service. Temporarily drop to mobile (501)
// so the port lookup hits the user-session namespace.
uid_t orig_euid = geteuid();
BOOL switched = NO;
if (orig_euid == 0) {
if (seteuid(501) == 0) {
switched = YES;
}
}
// Method 1: High-level SBSCopyFrontmostApplicationDisplayIdentifier
if (pSBSCopyFrontmost) {
frontApp = pSBSCopyFrontmost();
}
// Method 2: Low-level SBFrontmostApplicationDisplayIdentifier(port, buf)
if ((!frontApp || frontApp.length == 0) && pSBSSpringBoardServerPort &&
pSBFrontmostApplicationDisplayIdentifier) {
mach_port_t port = pSBSSpringBoardServerPort();
if (port != MACH_PORT_NULL) {
char buf[256] = {0};
pSBFrontmostApplicationDisplayIdentifier(port, buf);
if (buf[0] != '\0') {
frontApp = [NSString stringWithUTF8String:buf];
}
}
}
if (switched) {
seteuid(orig_euid);
}
// Method 3: Walk running apps, pick the one with highest PID (most
// recently launched) that isn't SpringBoard — rough heuristic fallback
if ((!frontApp || frontApp.length == 0) && gFBSSystemServiceClass) {
LSApplicationWorkspace *ws = [LSApplicationWorkspace defaultWorkspace];
pid_t bestPid = 0;
NSString *bestApp = nil;
for (LSApplicationProxy *proxy in [ws allInstalledApplications]) {
pid_t p = pid_for_app(proxy.bundleIdentifier);
if (p > 0 &&
![proxy.bundleIdentifier isEqualToString:@"com.apple.springboard"])
{
if (p > bestPid) {
bestPid = p;
bestApp = proxy.bundleIdentifier;
}
}
}
if (bestApp)
frontApp = bestApp;
}
if (!frontApp || frontApp.length == 0) {
frontApp = @"com.apple.springboard";
}
pid = pid_for_app(frontApp);
// Try to get the display name
LSApplicationWorkspace *ws = [LSApplicationWorkspace defaultWorkspace];
for (LSApplicationProxy *proxy in [ws allInstalledApplications]) {
if ([proxy.bundleIdentifier isEqualToString:frontApp]) {
name = proxy.localizedName ?: @"";
break;
}
}
NSMutableDictionary *r = vp_make_response(@"app_foreground", reqId);
r[@"bundle_id"] = frontApp;
r[@"name"] = name;
r[@"pid"] = @(pid > 0 ? pid : 0);
return r;
}
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = [NSString stringWithFormat:@"unknown apps command: %@", type];
return r;
}
+16
View File
@@ -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 <Foundation/Foundation.h>
/// 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);
+168
View File
@@ -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 <dlfcn.h>
#include <objc/message.h>
#include <unistd.h>
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;
}
+12
View File
@@ -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 <Foundation/Foundation.h>
/// Handle a settings command. Returns a response dict.
NSDictionary *vp_handle_settings_command(NSDictionary *msg);
+175
View File
@@ -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;
}
+11
View File
@@ -0,0 +1,11 @@
/*
* vphoned_url — URL opening over vsock.
*
* Handles open_url using LSApplicationWorkspace.
*/
#pragma once
#import <Foundation/Foundation.h>
/// Handle an open_url command. Returns a response dict.
NSDictionary *vp_handle_url_command(NSDictionary *msg);
+59
View File
@@ -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 <objc/message.h>
@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;
}
+7 -1
View File
@@ -24,7 +24,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()
@@ -130,6 +130,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 +146,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)
+19 -79
View File
@@ -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
)
}
+201 -11
View File
@@ -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<Void, any Error>) 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<UInt8>.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)))
}
}
}
+234
View File
@@ -0,0 +1,234 @@
import AppKit
import Foundation
// MARK: - Apps Menu
extension VPhoneMenuController {
func buildAppsMenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu(title: "Apps")
menu.autoenablesItems = false
let list = makeItem("List Installed Apps", action: #selector(listApps))
list.isEnabled = false
appsListItem = list
menu.addItem(list)
let running = makeItem("List Running Apps", action: #selector(listRunningApps))
running.isEnabled = false
appsRunningItem = running
menu.addItem(running)
menu.addItem(NSMenuItem.separator())
let foreground = makeItem("Foreground App", action: #selector(queryForegroundApp))
foreground.isEnabled = false
appsForegroundItem = foreground
menu.addItem(foreground)
menu.addItem(NSMenuItem.separator())
let launch = makeItem("Launch App...", action: #selector(launchApp))
launch.isEnabled = false
appsLaunchItem = launch
menu.addItem(launch)
let terminate = makeItem("Terminate App...", action: #selector(terminateApp))
terminate.isEnabled = false
appsTerminateItem = terminate
menu.addItem(terminate)
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
appsRunningItem?.isEnabled = available
appsForegroundItem?.isEnabled = available
appsLaunchItem?.isEnabled = available
appsTerminateItem?.isEnabled = available
appsOpenURLItem?.isEnabled = available
}
func updateInstallAvailability(available: Bool) {
installPackageItem?.isEnabled = available
}
@objc func listApps() {
showAppList(filter: "installed")
}
@objc func listRunningApps() {
showAppList(filter: "running")
}
private func showAppList(filter: String) {
Task {
do {
let apps = try await control.appList(filter: filter)
if apps.isEmpty {
showAlert(title: "Apps (\(filter))", message: "No apps found.", style: .informational)
return
}
let lines = apps.prefix(50).map { app in
let pidStr = app.pid > 0 ? " (pid \(app.pid))" : ""
return "\(app.name)\(app.bundleId) v\(app.version) [\(app.type)]\(pidStr)"
}
var message = lines.joined(separator: "\n")
if apps.count > 50 {
message += "\n... and \(apps.count - 50) more"
}
showAlert(
title: "Apps (\(filter)) — \(apps.count) total", message: message, style: .informational
)
} catch {
showAlert(title: "Apps", message: "\(error)", style: .warning)
}
}
}
@objc func queryForegroundApp() {
Task {
do {
let fg = try await control.appForeground()
showAlert(
title: "Foreground App",
message: "\(fg.name)\n\(fg.bundleId)\npid: \(fg.pid)",
style: .informational
)
} catch {
showAlert(title: "Foreground App", message: "\(error)", style: .warning)
}
}
}
@objc func launchApp() {
let alert = NSAlert()
alert.messageText = "Launch App"
alert.informativeText = "Enter bundle ID to launch:"
alert.alertStyle = .informational
alert.addButton(withTitle: "Launch")
alert.addButton(withTitle: "Cancel")
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24))
input.placeholderString = "com.apple.mobilesafari"
alert.accessoryView = input
guard alert.runModal() == .alertFirstButtonReturn else { return }
let bundleId = input.stringValue
guard !bundleId.isEmpty else { return }
Task {
do {
let pid = try await control.appLaunch(bundleId: bundleId)
showAlert(
title: "Launch App", message: "Launched \(bundleId) (pid \(pid))", style: .informational
)
} catch {
showAlert(title: "Launch App", message: "\(error)", style: .warning)
}
}
}
@objc func terminateApp() {
let alert = NSAlert()
alert.messageText = "Terminate App"
alert.informativeText = "Enter bundle ID to terminate:"
alert.alertStyle = .informational
alert.addButton(withTitle: "Terminate")
alert.addButton(withTitle: "Cancel")
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24))
input.placeholderString = "com.apple.mobilesafari"
alert.accessoryView = input
guard alert.runModal() == .alertFirstButtonReturn else { return }
let bundleId = input.stringValue
guard !bundleId.isEmpty else { return }
Task {
do {
try await control.appTerminate(bundleId: bundleId)
showAlert(title: "Terminate App", message: "Terminated \(bundleId)", style: .informational)
} catch {
showAlert(title: "Terminate App", message: "\(error)", style: .warning)
}
}
}
@objc func installIPAFromDisk() {
guard control.isConnected else {
showAlert(title: "Install App Package", message: "Guest is not connected.", style: .warning)
return
}
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.allowedContentTypes = VPhoneInstallPackage.allowedContentTypes
panel.prompt = "Install"
panel.message = "Choose an IPA or TIPA package to install in the guest."
let response = panel.runModal()
guard response == .OK, let url = panel.url else { return }
Task {
do {
let result = try await control.installIPA(localURL: url)
print("[install] \(result)")
showAlert(
title: "Install App Package",
message: VPhoneInstallPackage.successMessage(
for: url.lastPathComponent,
detail: result
),
style: .informational
)
} catch {
showAlert(title: "Install App Package", message: "\(error)", style: .warning)
}
}
}
@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)
}
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import AppKit
// MARK: - Battery Menu
extension VPhoneMenuController {
func buildBatteryMenu() -> NSMenuItem {
func buildBatterySubmenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu(title: "Battery")
+215
View File
@@ -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) {
+15 -6
View File
@@ -17,6 +17,16 @@ class VPhoneMenuController {
var connectPingItem: NSMenuItem?
var connectGuestVersionItem: NSMenuItem?
var installPackageItem: NSMenuItem?
var clipboardGetItem: NSMenuItem?
var clipboardSetItem: NSMenuItem?
var appsListItem: NSMenuItem?
var appsRunningItem: NSMenuItem?
var appsForegroundItem: NSMenuItem?
var appsLaunchItem: NSMenuItem?
var appsTerminateItem: NSMenuItem?
var appsOpenURLItem: NSMenuItem?
var settingsGetItem: NSMenuItem?
var settingsSetItem: NSMenuItem?
var locationProvider: VPhoneLocationProvider?
var locationMenuItem: NSMenuItem?
var locationPresetMenuItem: NSMenuItem?
@@ -41,7 +51,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 +66,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
}
@@ -1,58 +0,0 @@
import AppKit
import Foundation
// MARK: - Install Menu
extension VPhoneMenuController {
func buildInstallMenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu(title: "Install")
menu.autoenablesItems = false
let install = makeItem("Install IPA/TIPA...", action: #selector(installIPAFromDisk))
install.isEnabled = false
installPackageItem = install
menu.addItem(install)
item.submenu = menu
return item
}
func updateInstallAvailability(available: Bool) {
installPackageItem?.isEnabled = available
}
@objc func installIPAFromDisk() {
guard control.isConnected else {
showAlert(title: "Install App Package", message: "Guest is not connected.", style: .warning)
return
}
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.allowedContentTypes = VPhoneInstallPackage.allowedContentTypes
panel.prompt = "Install"
panel.message = "Choose an IPA or TIPA package to install in the guest."
let response = panel.runModal()
guard response == .OK, let url = panel.url else { return }
Task {
do {
let result = try await control.installIPA(localURL: url)
print("[install] \(result)")
showAlert(
title: "Install App Package",
message: VPhoneInstallPackage.successMessage(
for: url.lastPathComponent,
detail: result
),
style: .informational
)
} catch {
showAlert(title: "Install App Package", message: "\(error)", style: .warning)
}
}
}
}
+6
View File
@@ -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()
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ private let locationReplayPoints: [VPhoneLocationProvider.ReplayPoint] = [
// MARK: - Location Menu
extension VPhoneMenuController {
func buildLocationMenu() -> NSMenuItem {
func buildLocationSubmenu() -> NSMenuItem {
let item = NSMenuItem()
let menu = NSMenu(title: "Location")
-17
View File
@@ -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()
}
}