mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-05 17:14:28 +00:00
feat: Add vphoned modules (accessibility, apps, clipboard, settings, url) and host-side menus
Split vphoned into modular ObjC files for accessibility, apps, clipboard, settings, and URL handling. Add corresponding Swift menu extensions and VPhoneControl commands. Format all files with clang-format and swift-format. Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
+382
-286
@@ -13,362 +13,458 @@
|
|||||||
* make vphoned
|
* make vphoned
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
#include <CommonCrypto/CommonDigest.h>
|
#include <CommonCrypto/CommonDigest.h>
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
#include <mach-o/dyld.h>
|
#include <mach-o/dyld.h>
|
||||||
#include <sys/socket.h>
|
#include <sys/socket.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
#import "vphoned_protocol.h"
|
#import "vphoned_accessibility.h"
|
||||||
#import "vphoned_hid.h"
|
#import "vphoned_apps.h"
|
||||||
|
#import "vphoned_clipboard.h"
|
||||||
#import "vphoned_devmode.h"
|
#import "vphoned_devmode.h"
|
||||||
#import "vphoned_location.h"
|
|
||||||
#import "vphoned_files.h"
|
#import "vphoned_files.h"
|
||||||
|
#import "vphoned_hid.h"
|
||||||
#import "vphoned_install.h"
|
#import "vphoned_install.h"
|
||||||
#import "vphoned_keychain.h"
|
#import "vphoned_keychain.h"
|
||||||
|
#import "vphoned_location.h"
|
||||||
|
#import "vphoned_protocol.h"
|
||||||
|
#import "vphoned_settings.h"
|
||||||
|
#import "vphoned_url.h"
|
||||||
|
|
||||||
#ifndef AF_VSOCK
|
#ifndef AF_VSOCK
|
||||||
#define AF_VSOCK 40
|
#define AF_VSOCK 40
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#define VMADDR_CID_ANY 0xFFFFFFFF
|
#define VMADDR_CID_ANY 0xFFFFFFFF
|
||||||
#define VPHONED_PORT 1337
|
#define VPHONED_PORT 1337
|
||||||
|
|
||||||
#ifndef VPHONED_BUILD_HASH
|
#ifndef VPHONED_BUILD_HASH
|
||||||
#define VPHONED_BUILD_HASH "unknown"
|
#define VPHONED_BUILD_HASH "unknown"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
static BOOL gClipboardAvailable = NO;
|
||||||
|
static BOOL gAppsAvailable = NO;
|
||||||
|
|
||||||
#define INSTALL_PATH "/usr/bin/vphoned"
|
#define INSTALL_PATH "/usr/bin/vphoned"
|
||||||
#define CACHE_PATH "/var/root/Library/Caches/vphoned"
|
#define CACHE_PATH "/var/root/Library/Caches/vphoned"
|
||||||
#define CACHE_DIR "/var/root/Library/Caches"
|
#define CACHE_DIR "/var/root/Library/Caches"
|
||||||
|
|
||||||
struct sockaddr_vm {
|
struct sockaddr_vm {
|
||||||
__uint8_t svm_len;
|
__uint8_t svm_len;
|
||||||
sa_family_t svm_family;
|
sa_family_t svm_family;
|
||||||
__uint16_t svm_reserved1;
|
__uint16_t svm_reserved1;
|
||||||
__uint32_t svm_port;
|
__uint32_t svm_port;
|
||||||
__uint32_t svm_cid;
|
__uint32_t svm_cid;
|
||||||
};
|
};
|
||||||
|
|
||||||
// MARK: - Self-hash
|
// MARK: - Self-hash
|
||||||
|
|
||||||
static NSString *sha256_of_file(const char *path) {
|
static NSString *sha256_of_file(const char *path) {
|
||||||
int fd = open(path, O_RDONLY);
|
int fd = open(path, O_RDONLY);
|
||||||
if (fd < 0) return nil;
|
if (fd < 0)
|
||||||
|
return nil;
|
||||||
|
|
||||||
CC_SHA256_CTX ctx;
|
CC_SHA256_CTX ctx;
|
||||||
CC_SHA256_Init(&ctx);
|
CC_SHA256_Init(&ctx);
|
||||||
|
|
||||||
uint8_t buf[32768];
|
uint8_t buf[32768];
|
||||||
ssize_t n;
|
ssize_t n;
|
||||||
while ((n = read(fd, buf, sizeof(buf))) > 0)
|
while ((n = read(fd, buf, sizeof(buf))) > 0)
|
||||||
CC_SHA256_Update(&ctx, buf, (CC_LONG)n);
|
CC_SHA256_Update(&ctx, buf, (CC_LONG)n);
|
||||||
close(fd);
|
close(fd);
|
||||||
|
|
||||||
unsigned char digest[CC_SHA256_DIGEST_LENGTH];
|
unsigned char digest[CC_SHA256_DIGEST_LENGTH];
|
||||||
CC_SHA256_Final(digest, &ctx);
|
CC_SHA256_Final(digest, &ctx);
|
||||||
|
|
||||||
NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
|
NSMutableString *hex =
|
||||||
for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++)
|
[NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
|
||||||
[hex appendFormat:@"%02x", digest[i]];
|
for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++)
|
||||||
return hex;
|
[hex appendFormat:@"%02x", digest[i]];
|
||||||
|
return hex;
|
||||||
}
|
}
|
||||||
|
|
||||||
static const char *self_executable_path(void) {
|
static const char *self_executable_path(void) {
|
||||||
static char path[4096];
|
static char path[4096];
|
||||||
uint32_t size = sizeof(path);
|
uint32_t size = sizeof(path);
|
||||||
if (_NSGetExecutablePath(path, &size) != 0) return NULL;
|
if (_NSGetExecutablePath(path, &size) != 0)
|
||||||
return path;
|
return NULL;
|
||||||
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Auto-update
|
// MARK: - Auto-update
|
||||||
|
|
||||||
/// Receive raw binary from host, write to CACHE_PATH, chmod +x.
|
/// Receive raw binary from host, write to CACHE_PATH, chmod +x.
|
||||||
static BOOL receive_update(int fd, NSUInteger size) {
|
static BOOL receive_update(int fd, NSUInteger size) {
|
||||||
mkdir(CACHE_DIR, 0755);
|
mkdir(CACHE_DIR, 0755);
|
||||||
|
|
||||||
char tmp_path[] = CACHE_DIR "/vphoned.XXXXXX";
|
char tmp_path[] = CACHE_DIR "/vphoned.XXXXXX";
|
||||||
int tmp_fd = mkstemp(tmp_path);
|
int tmp_fd = mkstemp(tmp_path);
|
||||||
if (tmp_fd < 0) {
|
if (tmp_fd < 0) {
|
||||||
NSLog(@"vphoned: mkstemp failed: %s", strerror(errno));
|
NSLog(@"vphoned: mkstemp failed: %s", strerror(errno));
|
||||||
return NO;
|
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) {
|
||||||
uint8_t buf[32768];
|
NSLog(@"vphoned: update write failed: %s", strerror(errno));
|
||||||
NSUInteger remaining = size;
|
close(tmp_fd);
|
||||||
while (remaining > 0) {
|
unlink(tmp_path);
|
||||||
size_t chunk = remaining < sizeof(buf) ? remaining : sizeof(buf);
|
return NO;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
close(tmp_fd);
|
remaining -= chunk;
|
||||||
chmod(tmp_path, 0755);
|
}
|
||||||
|
close(tmp_fd);
|
||||||
|
chmod(tmp_path, 0755);
|
||||||
|
|
||||||
if (rename(tmp_path, CACHE_PATH) != 0) {
|
if (rename(tmp_path, CACHE_PATH) != 0) {
|
||||||
NSLog(@"vphoned: rename to cache failed: %s", strerror(errno));
|
NSLog(@"vphoned: rename to cache failed: %s", strerror(errno));
|
||||||
unlink(tmp_path);
|
unlink(tmp_path);
|
||||||
return NO;
|
return NO;
|
||||||
}
|
}
|
||||||
|
|
||||||
NSLog(@"vphoned: update written to %s (%lu bytes)", CACHE_PATH, (unsigned long)size);
|
NSLog(@"vphoned: update written to %s (%lu bytes)", CACHE_PATH,
|
||||||
return YES;
|
(unsigned long)size);
|
||||||
|
return YES;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Command Dispatch
|
// MARK: - Command Dispatch
|
||||||
|
|
||||||
static NSDictionary *handle_command(NSDictionary *msg) {
|
static NSDictionary *handle_command(NSDictionary *msg) {
|
||||||
NSString *type = msg[@"t"];
|
NSString *type = msg[@"t"];
|
||||||
id reqId = msg[@"id"];
|
id reqId = msg[@"id"];
|
||||||
|
|
||||||
if ([type isEqualToString:@"hid"]) {
|
if ([type isEqualToString:@"hid"]) {
|
||||||
uint32_t page = [msg[@"page"] unsignedIntValue];
|
uint32_t page = [msg[@"page"] unsignedIntValue];
|
||||||
uint32_t usage = [msg[@"usage"] unsignedIntValue];
|
uint32_t usage = [msg[@"usage"] unsignedIntValue];
|
||||||
NSNumber *downVal = msg[@"down"];
|
NSNumber *downVal = msg[@"down"];
|
||||||
if (downVal != nil) {
|
if (downVal != nil) {
|
||||||
vp_hid_key(page, usage, [downVal boolValue]);
|
vp_hid_key(page, usage, [downVal boolValue]);
|
||||||
} else {
|
} else {
|
||||||
vp_hid_press(page, usage);
|
vp_hid_press(page, usage);
|
||||||
}
|
|
||||||
return vp_make_response(@"ok", reqId);
|
|
||||||
}
|
}
|
||||||
|
return vp_make_response(@"ok", reqId);
|
||||||
|
}
|
||||||
|
|
||||||
if ([type isEqualToString:@"devmode"]) {
|
if ([type isEqualToString:@"devmode"]) {
|
||||||
if (!vp_devmode_available()) {
|
if (!vp_devmode_available()) {
|
||||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||||
r[@"msg"] = @"XPC not available";
|
r[@"msg"] = @"XPC not available";
|
||||||
return r;
|
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;
|
|
||||||
}
|
}
|
||||||
|
NSString *action = msg[@"action"];
|
||||||
if ([type isEqualToString:@"ping"]) {
|
if ([action isEqualToString:@"status"]) {
|
||||||
return vp_make_response(@"pong", reqId);
|
BOOL enabled = vp_devmode_status();
|
||||||
|
NSMutableDictionary *r = vp_make_response(@"ok", reqId);
|
||||||
|
r[@"enabled"] = @(enabled);
|
||||||
|
return r;
|
||||||
}
|
}
|
||||||
|
if ([action isEqualToString:@"enable"]) {
|
||||||
if ([type isEqualToString:@"location"]) {
|
BOOL alreadyEnabled = NO;
|
||||||
double lat = [msg[@"lat"] doubleValue];
|
BOOL ok = vp_devmode_arm(&alreadyEnabled);
|
||||||
double lon = [msg[@"lon"] doubleValue];
|
NSMutableDictionary *r = vp_make_response(ok ? @"ok" : @"err", reqId);
|
||||||
double alt = [msg[@"alt"] doubleValue];
|
if (ok) {
|
||||||
double hacc = [msg[@"hacc"] doubleValue];
|
r[@"already_enabled"] = @(alreadyEnabled);
|
||||||
double vacc = [msg[@"vacc"] doubleValue];
|
r[@"msg"] = alreadyEnabled
|
||||||
double speed = [msg[@"speed"] doubleValue];
|
? @"developer mode already enabled"
|
||||||
double course = [msg[@"course"] doubleValue];
|
: @"developer mode armed, reboot to activate";
|
||||||
vp_location_simulate(lat, lon, alt, hacc, vacc, speed, course);
|
} else {
|
||||||
return vp_make_response(@"ok", reqId);
|
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);
|
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||||
r[@"msg"] = [NSString stringWithFormat:@"unknown type: %@", type];
|
r[@"msg"] =
|
||||||
|
[NSString stringWithFormat:@"unknown devmode action: %@", action];
|
||||||
return r;
|
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
|
// MARK: - Client Session
|
||||||
|
|
||||||
/// Returns YES if daemon should exit for restart (after update).
|
/// Returns YES if daemon should exit for restart (after update).
|
||||||
static BOOL handle_client(int fd) {
|
static BOOL handle_client(int fd) {
|
||||||
BOOL should_restart = NO;
|
BOOL should_restart = NO;
|
||||||
@autoreleasepool {
|
@autoreleasepool {
|
||||||
NSDictionary *hello = vp_read_message(fd);
|
NSDictionary *hello = vp_read_message(fd);
|
||||||
if (!hello) { close(fd); return NO; }
|
if (!hello) {
|
||||||
|
close(fd);
|
||||||
NSInteger version = [hello[@"v"] integerValue];
|
return NO;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
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
|
// MARK: - Main
|
||||||
|
|
||||||
int main(int argc, char *argv[]) {
|
int main(int argc, char *argv[]) {
|
||||||
@autoreleasepool {
|
@autoreleasepool {
|
||||||
// Bootstrap: if running from install path and a cached update exists, exec it
|
// Bootstrap: if running from install path and a cached update exists, exec
|
||||||
const char *selfPath = self_executable_path();
|
// it
|
||||||
if (selfPath && strcmp(selfPath, INSTALL_PATH) == 0 && access(CACHE_PATH, X_OK) == 0) {
|
const char *selfPath = self_executable_path();
|
||||||
NSLog(@"vphoned: found cached binary at %s, exec'ing", CACHE_PATH);
|
if (selfPath && strcmp(selfPath, INSTALL_PATH) == 0 &&
|
||||||
execv(CACHE_PATH, argv);
|
access(CACHE_PATH, X_OK) == 0) {
|
||||||
NSLog(@"vphoned: execv failed: %s — continuing with installed binary", strerror(errno));
|
NSLog(@"vphoned: found cached binary at %s, exec'ing", CACHE_PATH);
|
||||||
unlink(CACHE_PATH);
|
execv(CACHE_PATH, argv);
|
||||||
}
|
NSLog(@"vphoned: execv failed: %s — continuing with installed binary",
|
||||||
|
strerror(errno));
|
||||||
NSLog(@"vphoned: starting (pid=%d, path=%s)", getpid(), selfPath ?: "?");
|
unlink(CACHE_PATH);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,263 @@
|
|||||||
|
/*
|
||||||
|
* 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 <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;
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
} 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 = @"";
|
||||||
|
|
||||||
|
if (pSBSCopyFrontmost) {
|
||||||
|
frontApp = pSBSCopyFrontmost();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -3,167 +3,173 @@ import Foundation
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
class VPhoneAppDelegate: NSObject, NSApplicationDelegate {
|
class VPhoneAppDelegate: NSObject, NSApplicationDelegate {
|
||||||
private let cli: VPhoneCLI
|
private let cli: VPhoneCLI
|
||||||
private var vm: VPhoneVirtualMachine?
|
private var vm: VPhoneVirtualMachine?
|
||||||
private var control: VPhoneControl?
|
private var control: VPhoneControl?
|
||||||
private var windowController: VPhoneWindowController?
|
private var windowController: VPhoneWindowController?
|
||||||
private var menuController: VPhoneMenuController?
|
private var menuController: VPhoneMenuController?
|
||||||
private var fileWindowController: VPhoneFileWindowController?
|
private var fileWindowController: VPhoneFileWindowController?
|
||||||
private var keychainWindowController: VPhoneKeychainWindowController?
|
private var keychainWindowController: VPhoneKeychainWindowController?
|
||||||
private var locationProvider: VPhoneLocationProvider?
|
private var locationProvider: VPhoneLocationProvider?
|
||||||
private var sigintSource: DispatchSourceSignal?
|
private var sigintSource: DispatchSourceSignal?
|
||||||
|
|
||||||
init(cli: VPhoneCLI) {
|
init(cli: VPhoneCLI) {
|
||||||
self.cli = cli
|
self.cli = cli
|
||||||
super.init()
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_: Notification) {
|
||||||
|
NSApp.setActivationPolicy(cli.noGraphics ? .prohibited : .regular)
|
||||||
|
|
||||||
|
signal(SIGINT, SIG_IGN)
|
||||||
|
let src = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
|
||||||
|
src.setEventHandler {
|
||||||
|
print("\n[vphone] SIGINT - shutting down")
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
}
|
||||||
|
src.activate()
|
||||||
|
sigintSource = src
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
try await self.startVirtualMachine()
|
||||||
|
} catch {
|
||||||
|
print("[vphone] Fatal: \(error)")
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func startVirtualMachine() async throws {
|
||||||
|
let options = try cli.resolveOptions()
|
||||||
|
|
||||||
|
guard FileManager.default.fileExists(atPath: options.romURL.path) else {
|
||||||
|
throw VPhoneError.romNotFound(options.romURL.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_: Notification) {
|
print("=== vphone-cli ===")
|
||||||
NSApp.setActivationPolicy(cli.noGraphics ? .prohibited : .regular)
|
print("ROM : \(options.romURL.path)")
|
||||||
|
print("Disk : \(options.diskURL.path)")
|
||||||
|
print("NVRAM : \(options.nvramURL.path)")
|
||||||
|
print("Config: \(options.configURL.path)")
|
||||||
|
print("CPU : \(options.cpuCount)")
|
||||||
|
print("Memory: \(options.memorySize / 1024 / 1024) MB")
|
||||||
|
print(
|
||||||
|
"Screen: \(options.screenWidth)x\(options.screenHeight) @ \(options.screenPPI) PPI (scale \(options.screenScale)x)"
|
||||||
|
)
|
||||||
|
if let kernelDebugPort = options.kernelDebugPort {
|
||||||
|
print("Kernel debug stub : 127.0.0.1:\(kernelDebugPort)")
|
||||||
|
} else {
|
||||||
|
print("Kernel debug stub : auto-assigned")
|
||||||
|
}
|
||||||
|
print("SEP : enabled")
|
||||||
|
print(" storage : \(options.sepStorageURL.path)")
|
||||||
|
print(" rom : \(options.sepRomURL.path)")
|
||||||
|
print("")
|
||||||
|
|
||||||
signal(SIGINT, SIG_IGN)
|
let vm = try VPhoneVirtualMachine(options: options)
|
||||||
let src = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
|
self.vm = vm
|
||||||
src.setEventHandler {
|
|
||||||
print("\n[vphone] SIGINT - shutting down")
|
|
||||||
NSApp.terminate(nil)
|
|
||||||
}
|
|
||||||
src.activate()
|
|
||||||
sigintSource = src
|
|
||||||
|
|
||||||
Task { @MainActor in
|
try await vm.start(forceDFU: cli.dfu)
|
||||||
do {
|
|
||||||
try await self.startVirtualMachine()
|
let control = VPhoneControl()
|
||||||
} catch {
|
self.control = control
|
||||||
print("[vphone] Fatal: \(error)")
|
if !cli.dfu {
|
||||||
NSApp.terminate(nil)
|
let vphonedURL = URL(fileURLWithPath: cli.vphonedBin)
|
||||||
}
|
if FileManager.default.fileExists(atPath: vphonedURL.path) {
|
||||||
}
|
control.guestBinaryURL = vphonedURL
|
||||||
|
}
|
||||||
|
|
||||||
|
let provider = VPhoneLocationProvider(control: control)
|
||||||
|
locationProvider = provider
|
||||||
|
|
||||||
|
if let device = vm.virtualMachine.socketDevices.first as? VZVirtioSocketDevice {
|
||||||
|
control.connect(device: device)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
if !cli.noGraphics {
|
||||||
private func startVirtualMachine() async throws {
|
let keyHelper = VPhoneKeyHelper(vm: vm, control: control)
|
||||||
let options = try cli.resolveOptions()
|
let wc = VPhoneWindowController()
|
||||||
|
wc.showWindow(
|
||||||
|
for: vm.virtualMachine,
|
||||||
|
screenWidth: options.screenWidth,
|
||||||
|
screenHeight: options.screenHeight,
|
||||||
|
screenScale: options.screenScale,
|
||||||
|
keyHelper: keyHelper,
|
||||||
|
control: control,
|
||||||
|
ecid: vm.ecidHex
|
||||||
|
)
|
||||||
|
windowController = wc
|
||||||
|
|
||||||
guard FileManager.default.fileExists(atPath: options.romURL.path) else {
|
let fileWC = VPhoneFileWindowController()
|
||||||
throw VPhoneError.romNotFound(options.romURL.path)
|
fileWindowController = fileWC
|
||||||
}
|
|
||||||
|
|
||||||
print("=== vphone-cli ===")
|
let keychainWC = VPhoneKeychainWindowController()
|
||||||
print("ROM : \(options.romURL.path)")
|
keychainWindowController = keychainWC
|
||||||
print("Disk : \(options.diskURL.path)")
|
|
||||||
print("NVRAM : \(options.nvramURL.path)")
|
let mc = VPhoneMenuController(keyHelper: keyHelper, control: control)
|
||||||
print("Config: \(options.configURL.path)")
|
mc.vm = vm
|
||||||
print("CPU : \(options.cpuCount)")
|
mc.captureView = wc.captureView
|
||||||
print("Memory: \(options.memorySize / 1024 / 1024) MB")
|
mc.onFilesPressed = { [weak fileWC, weak control] in
|
||||||
print(
|
guard let fileWC, let control else { return }
|
||||||
"Screen: \(options.screenWidth)x\(options.screenHeight) @ \(options.screenPPI) PPI (scale \(options.screenScale)x)"
|
fileWC.showWindow(control: control)
|
||||||
)
|
}
|
||||||
if let kernelDebugPort = options.kernelDebugPort {
|
mc.onKeychainPressed = { [weak keychainWC, weak control] in
|
||||||
print("Kernel debug stub : 127.0.0.1:\(kernelDebugPort)")
|
guard let keychainWC, let control else { return }
|
||||||
|
keychainWC.showWindow(control: control)
|
||||||
|
}
|
||||||
|
if let provider = locationProvider {
|
||||||
|
mc.locationProvider = provider
|
||||||
|
}
|
||||||
|
mc.screenRecorder = VPhoneScreenRecorder()
|
||||||
|
menuController = mc
|
||||||
|
|
||||||
|
// Wire location toggle through onConnect/onDisconnect
|
||||||
|
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
|
||||||
|
if mc?.locationMenuItem?.state == .on {
|
||||||
|
provider?.startForwarding()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
print("Kernel debug stub : auto-assigned")
|
print("[location] guest does not support location simulation")
|
||||||
}
|
}
|
||||||
print("SEP : enabled")
|
}
|
||||||
print(" storage : \(options.sepStorageURL.path)")
|
control.onDisconnect = { [weak mc, weak provider = locationProvider] in
|
||||||
print(" rom : \(options.sepRomURL.path)")
|
mc?.updateConnectAvailability(available: false)
|
||||||
print("")
|
mc?.updateInstallAvailability(available: false)
|
||||||
|
mc?.updateAppsAvailability(available: false)
|
||||||
let vm = try VPhoneVirtualMachine(options: options)
|
mc?.updateClipboardAvailability(available: false)
|
||||||
self.vm = vm
|
mc?.updateSettingsAvailability(available: false)
|
||||||
|
provider?.stopReplay()
|
||||||
try await vm.start(forceDFU: cli.dfu)
|
provider?.stopForwarding()
|
||||||
|
mc?.updateLocationCapability(available: false)
|
||||||
let control = VPhoneControl()
|
}
|
||||||
self.control = control
|
} else if !cli.dfu {
|
||||||
if !cli.dfu {
|
// Headless mode: auto-start location as before (no menu exists)
|
||||||
let vphonedURL = URL(fileURLWithPath: cli.vphonedBin)
|
control.onConnect = { [weak provider = locationProvider] caps in
|
||||||
if FileManager.default.fileExists(atPath: vphonedURL.path) {
|
if caps.contains("location") {
|
||||||
control.guestBinaryURL = vphonedURL
|
provider?.startForwarding()
|
||||||
}
|
} else {
|
||||||
|
print("[location] guest does not support location simulation")
|
||||||
let provider = VPhoneLocationProvider(control: control)
|
|
||||||
locationProvider = provider
|
|
||||||
|
|
||||||
if let device = vm.virtualMachine.socketDevices.first as? VZVirtioSocketDevice {
|
|
||||||
control.connect(device: device)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !cli.noGraphics {
|
|
||||||
let keyHelper = VPhoneKeyHelper(vm: vm, control: control)
|
|
||||||
let wc = VPhoneWindowController()
|
|
||||||
wc.showWindow(
|
|
||||||
for: vm.virtualMachine,
|
|
||||||
screenWidth: options.screenWidth,
|
|
||||||
screenHeight: options.screenHeight,
|
|
||||||
screenScale: options.screenScale,
|
|
||||||
keyHelper: keyHelper,
|
|
||||||
control: control,
|
|
||||||
ecid: vm.ecidHex
|
|
||||||
)
|
|
||||||
windowController = wc
|
|
||||||
|
|
||||||
let fileWC = VPhoneFileWindowController()
|
|
||||||
fileWindowController = fileWC
|
|
||||||
|
|
||||||
let keychainWC = VPhoneKeychainWindowController()
|
|
||||||
keychainWindowController = keychainWC
|
|
||||||
|
|
||||||
let mc = VPhoneMenuController(keyHelper: keyHelper, control: control)
|
|
||||||
mc.vm = vm
|
|
||||||
mc.captureView = wc.captureView
|
|
||||||
mc.onFilesPressed = { [weak fileWC, weak control] in
|
|
||||||
guard let fileWC, let control else { return }
|
|
||||||
fileWC.showWindow(control: control)
|
|
||||||
}
|
|
||||||
mc.onKeychainPressed = { [weak keychainWC, weak control] in
|
|
||||||
guard let keychainWC, let control else { return }
|
|
||||||
keychainWC.showWindow(control: control)
|
|
||||||
}
|
|
||||||
if let provider = locationProvider {
|
|
||||||
mc.locationProvider = provider
|
|
||||||
}
|
|
||||||
mc.screenRecorder = VPhoneScreenRecorder()
|
|
||||||
menuController = mc
|
|
||||||
|
|
||||||
// Wire location toggle through onConnect/onDisconnect
|
|
||||||
control.onConnect = { [weak mc, weak provider = locationProvider] caps in
|
|
||||||
mc?.updateConnectAvailability(available: true)
|
|
||||||
mc?.updateInstallAvailability(available: true)
|
|
||||||
if caps.contains("location") {
|
|
||||||
mc?.updateLocationCapability(available: true)
|
|
||||||
// Auto-resume if user had toggle on
|
|
||||||
if mc?.locationMenuItem?.state == .on {
|
|
||||||
provider?.startForwarding()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
print("[location] guest does not support location simulation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
control.onDisconnect = { [weak mc, weak provider = locationProvider] in
|
|
||||||
mc?.updateConnectAvailability(available: false)
|
|
||||||
mc?.updateInstallAvailability(available: false)
|
|
||||||
provider?.stopReplay()
|
|
||||||
provider?.stopForwarding()
|
|
||||||
mc?.updateLocationCapability(available: false)
|
|
||||||
}
|
|
||||||
} else if !cli.dfu {
|
|
||||||
// Headless mode: auto-start location as before (no menu exists)
|
|
||||||
control.onConnect = { [weak provider = locationProvider] caps in
|
|
||||||
if caps.contains("location") {
|
|
||||||
provider?.startForwarding()
|
|
||||||
} else {
|
|
||||||
print("[location] guest does not support location simulation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
control.onDisconnect = { [weak provider = locationProvider] in
|
|
||||||
provider?.stopReplay()
|
|
||||||
provider?.stopForwarding()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
control.onDisconnect = { [weak provider = locationProvider] in
|
||||||
|
provider?.stopReplay()
|
||||||
|
provider?.stopForwarding()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
|
func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
|
||||||
!cli.noGraphics
|
!cli.noGraphics
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,185 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
// MARK: - Clipboard Menu
|
||||||
|
|
||||||
|
extension VPhoneMenuController {
|
||||||
|
func buildClipboardMenu() -> NSMenuItem {
|
||||||
|
let item = NSMenuItem()
|
||||||
|
let menu = NSMenu(title: "Clipboard")
|
||||||
|
menu.autoenablesItems = false
|
||||||
|
|
||||||
|
let get = makeItem("Get Clipboard", action: #selector(getClipboard))
|
||||||
|
get.isEnabled = false
|
||||||
|
clipboardGetItem = get
|
||||||
|
menu.addItem(get)
|
||||||
|
|
||||||
|
let set = makeItem("Set Clipboard Text...", action: #selector(setClipboardText))
|
||||||
|
set.isEnabled = false
|
||||||
|
clipboardSetItem = set
|
||||||
|
menu.addItem(set)
|
||||||
|
|
||||||
|
item.submenu = menu
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateClipboardAvailability(available: Bool) {
|
||||||
|
clipboardGetItem?.isEnabled = available
|
||||||
|
clipboardSetItem?.isEnabled = available
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
let response: NSApplication.ModalResponse =
|
||||||
|
if let window = NSApp.keyWindow {
|
||||||
|
alert.runModal()
|
||||||
|
} else {
|
||||||
|
alert.runModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard response == .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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,69 +5,83 @@ import Foundation
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class VPhoneMenuController {
|
class VPhoneMenuController {
|
||||||
let keyHelper: VPhoneKeyHelper
|
let keyHelper: VPhoneKeyHelper
|
||||||
let control: VPhoneControl
|
let control: VPhoneControl
|
||||||
weak var vm: VPhoneVirtualMachine?
|
weak var vm: VPhoneVirtualMachine?
|
||||||
|
|
||||||
var onFilesPressed: (() -> Void)?
|
var onFilesPressed: (() -> Void)?
|
||||||
var onKeychainPressed: (() -> Void)?
|
var onKeychainPressed: (() -> Void)?
|
||||||
var connectFileBrowserItem: NSMenuItem?
|
var connectFileBrowserItem: NSMenuItem?
|
||||||
var connectKeychainBrowserItem: NSMenuItem?
|
var connectKeychainBrowserItem: NSMenuItem?
|
||||||
var connectDevModeStatusItem: NSMenuItem?
|
var connectDevModeStatusItem: NSMenuItem?
|
||||||
var connectPingItem: NSMenuItem?
|
var connectPingItem: NSMenuItem?
|
||||||
var connectGuestVersionItem: NSMenuItem?
|
var connectGuestVersionItem: NSMenuItem?
|
||||||
var installPackageItem: NSMenuItem?
|
var installPackageItem: NSMenuItem?
|
||||||
var locationProvider: VPhoneLocationProvider?
|
var clipboardGetItem: NSMenuItem?
|
||||||
var locationMenuItem: NSMenuItem?
|
var clipboardSetItem: NSMenuItem?
|
||||||
var locationPresetMenuItem: NSMenuItem?
|
var appsListItem: NSMenuItem?
|
||||||
var locationReplayStartItem: NSMenuItem?
|
var appsRunningItem: NSMenuItem?
|
||||||
var locationReplayStopItem: NSMenuItem?
|
var appsForegroundItem: NSMenuItem?
|
||||||
var screenRecorder: VPhoneScreenRecorder?
|
var appsLaunchItem: NSMenuItem?
|
||||||
var recordingItem: NSMenuItem?
|
var appsTerminateItem: NSMenuItem?
|
||||||
weak var captureView: VPhoneVirtualMachineView?
|
var appsOpenURLItem: NSMenuItem?
|
||||||
|
var settingsGetItem: NSMenuItem?
|
||||||
|
var settingsSetItem: NSMenuItem?
|
||||||
|
var locationProvider: VPhoneLocationProvider?
|
||||||
|
var locationMenuItem: NSMenuItem?
|
||||||
|
var locationPresetMenuItem: NSMenuItem?
|
||||||
|
var locationReplayStartItem: NSMenuItem?
|
||||||
|
var locationReplayStopItem: NSMenuItem?
|
||||||
|
var screenRecorder: VPhoneScreenRecorder?
|
||||||
|
var recordingItem: NSMenuItem?
|
||||||
|
weak var captureView: VPhoneVirtualMachineView?
|
||||||
|
|
||||||
init(keyHelper: VPhoneKeyHelper, control: VPhoneControl) {
|
init(keyHelper: VPhoneKeyHelper, control: VPhoneControl) {
|
||||||
self.keyHelper = keyHelper
|
self.keyHelper = keyHelper
|
||||||
self.control = control
|
self.control = control
|
||||||
setupMenuBar()
|
setupMenuBar()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Menu Bar Setup
|
// MARK: - Menu Bar Setup
|
||||||
|
|
||||||
private func setupMenuBar() {
|
private func setupMenuBar() {
|
||||||
let mainMenu = NSMenu()
|
let mainMenu = NSMenu()
|
||||||
|
|
||||||
// App menu
|
// App menu
|
||||||
let appMenuItem = NSMenuItem()
|
let appMenuItem = NSMenuItem()
|
||||||
let appMenu = NSMenu(title: "vphone")
|
let appMenu = NSMenu(title: "vphone")
|
||||||
#if canImport(VPhoneBuildInfo)
|
#if canImport(VPhoneBuildInfo)
|
||||||
let buildItem = NSMenuItem(title: "Build: \(VPhoneBuildInfo.commitHash)", action: nil, keyEquivalent: "")
|
let buildItem = NSMenuItem(
|
||||||
#else
|
title: "Build: \(VPhoneBuildInfo.commitHash)", action: nil, keyEquivalent: "")
|
||||||
let buildItem = NSMenuItem(title: "Build: unknown", action: nil, keyEquivalent: "")
|
#else
|
||||||
#endif
|
let buildItem = NSMenuItem(title: "Build: unknown", action: nil, keyEquivalent: "")
|
||||||
buildItem.isEnabled = false
|
#endif
|
||||||
appMenu.addItem(buildItem)
|
buildItem.isEnabled = false
|
||||||
appMenu.addItem(NSMenuItem.separator())
|
appMenu.addItem(buildItem)
|
||||||
appMenu.addItem(
|
appMenu.addItem(NSMenuItem.separator())
|
||||||
withTitle: "Quit vphone", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"
|
appMenu.addItem(
|
||||||
)
|
withTitle: "Quit vphone", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"
|
||||||
appMenuItem.submenu = appMenu
|
)
|
||||||
mainMenu.addItem(appMenuItem)
|
appMenuItem.submenu = appMenu
|
||||||
|
mainMenu.addItem(appMenuItem)
|
||||||
|
|
||||||
mainMenu.addItem(buildKeysMenu())
|
mainMenu.addItem(buildKeysMenu())
|
||||||
mainMenu.addItem(buildTypeMenu())
|
mainMenu.addItem(buildTypeMenu())
|
||||||
mainMenu.addItem(buildConnectMenu())
|
mainMenu.addItem(buildConnectMenu())
|
||||||
mainMenu.addItem(buildInstallMenu())
|
mainMenu.addItem(buildAppsMenu())
|
||||||
mainMenu.addItem(buildLocationMenu())
|
mainMenu.addItem(buildClipboardMenu())
|
||||||
mainMenu.addItem(buildRecordMenu())
|
mainMenu.addItem(buildInstallMenu())
|
||||||
mainMenu.addItem(buildBatteryMenu())
|
mainMenu.addItem(buildSettingsMenu())
|
||||||
|
mainMenu.addItem(buildLocationMenu())
|
||||||
|
mainMenu.addItem(buildRecordMenu())
|
||||||
|
mainMenu.addItem(buildBatteryMenu())
|
||||||
|
|
||||||
NSApp.mainMenu = mainMenu
|
NSApp.mainMenu = mainMenu
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeItem(_ title: String, action: Selector) -> NSMenuItem {
|
func makeItem(_ title: String, action: Selector) -> NSMenuItem {
|
||||||
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
|
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
|
||||||
item.target = self
|
item.target = self
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
// MARK: - Settings Menu
|
||||||
|
|
||||||
|
extension VPhoneMenuController {
|
||||||
|
func buildSettingsMenu() -> NSMenuItem {
|
||||||
|
let item = NSMenuItem()
|
||||||
|
let menu = NSMenu(title: "Settings")
|
||||||
|
menu.autoenablesItems = false
|
||||||
|
|
||||||
|
let get = makeItem("Read Setting...", action: #selector(readSetting))
|
||||||
|
get.isEnabled = false
|
||||||
|
settingsGetItem = get
|
||||||
|
menu.addItem(get)
|
||||||
|
|
||||||
|
let set = makeItem("Write Setting...", action: #selector(writeSetting))
|
||||||
|
set.isEnabled = false
|
||||||
|
settingsSetItem = set
|
||||||
|
menu.addItem(set)
|
||||||
|
|
||||||
|
item.submenu = menu
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSettingsAvailability(available: Bool) {
|
||||||
|
settingsGetItem?.isEnabled = available
|
||||||
|
settingsSetItem?.isEnabled = available
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 }
|
||||||
|
|
||||||
|
// Convert value based on type
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user