mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-02 02:34:29 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb12565070 | ||
|
|
7a5a8edd27 | ||
|
|
df5d5449b5 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user