mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-04-05 13:09:06 +08: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 <noreply@anthropic.com>
This commit is contained in:
@@ -13,362 +13,458 @@
|
||||
* make vphoned
|
||||
*/
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#include <CommonCrypto/CommonDigest.h>
|
||||
#import <Foundation/Foundation.h>
|
||||
#include <mach-o/dyld.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#import "vphoned_protocol.h"
|
||||
#import "vphoned_hid.h"
|
||||
#import "vphoned_accessibility.h"
|
||||
#import "vphoned_apps.h"
|
||||
#import "vphoned_clipboard.h"
|
||||
#import "vphoned_devmode.h"
|
||||
#import "vphoned_location.h"
|
||||
#import "vphoned_files.h"
|
||||
#import "vphoned_hid.h"
|
||||
#import "vphoned_install.h"
|
||||
#import "vphoned_keychain.h"
|
||||
#import "vphoned_location.h"
|
||||
#import "vphoned_protocol.h"
|
||||
#import "vphoned_settings.h"
|
||||
#import "vphoned_url.h"
|
||||
|
||||
#ifndef AF_VSOCK
|
||||
#define AF_VSOCK 40
|
||||
#endif
|
||||
|
||||
#define VMADDR_CID_ANY 0xFFFFFFFF
|
||||
#define VPHONED_PORT 1337
|
||||
#define VMADDR_CID_ANY 0xFFFFFFFF
|
||||
#define VPHONED_PORT 1337
|
||||
|
||||
#ifndef VPHONED_BUILD_HASH
|
||||
#define VPHONED_BUILD_HASH "unknown"
|
||||
#endif
|
||||
|
||||
static BOOL gClipboardAvailable = NO;
|
||||
static BOOL gAppsAvailable = NO;
|
||||
|
||||
#define INSTALL_PATH "/usr/bin/vphoned"
|
||||
#define CACHE_PATH "/var/root/Library/Caches/vphoned"
|
||||
#define CACHE_DIR "/var/root/Library/Caches"
|
||||
#define CACHE_PATH "/var/root/Library/Caches/vphoned"
|
||||
#define CACHE_DIR "/var/root/Library/Caches"
|
||||
|
||||
struct sockaddr_vm {
|
||||
__uint8_t svm_len;
|
||||
sa_family_t svm_family;
|
||||
__uint16_t svm_reserved1;
|
||||
__uint32_t svm_port;
|
||||
__uint32_t svm_cid;
|
||||
__uint8_t svm_len;
|
||||
sa_family_t svm_family;
|
||||
__uint16_t svm_reserved1;
|
||||
__uint32_t svm_port;
|
||||
__uint32_t svm_cid;
|
||||
};
|
||||
|
||||
// MARK: - Self-hash
|
||||
|
||||
static NSString *sha256_of_file(const char *path) {
|
||||
int fd = open(path, O_RDONLY);
|
||||
if (fd < 0) return nil;
|
||||
int fd = open(path, O_RDONLY);
|
||||
if (fd < 0)
|
||||
return nil;
|
||||
|
||||
CC_SHA256_CTX ctx;
|
||||
CC_SHA256_Init(&ctx);
|
||||
CC_SHA256_CTX ctx;
|
||||
CC_SHA256_Init(&ctx);
|
||||
|
||||
uint8_t buf[32768];
|
||||
ssize_t n;
|
||||
while ((n = read(fd, buf, sizeof(buf))) > 0)
|
||||
CC_SHA256_Update(&ctx, buf, (CC_LONG)n);
|
||||
close(fd);
|
||||
uint8_t buf[32768];
|
||||
ssize_t n;
|
||||
while ((n = read(fd, buf, sizeof(buf))) > 0)
|
||||
CC_SHA256_Update(&ctx, buf, (CC_LONG)n);
|
||||
close(fd);
|
||||
|
||||
unsigned char digest[CC_SHA256_DIGEST_LENGTH];
|
||||
CC_SHA256_Final(digest, &ctx);
|
||||
unsigned char digest[CC_SHA256_DIGEST_LENGTH];
|
||||
CC_SHA256_Final(digest, &ctx);
|
||||
|
||||
NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
|
||||
for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++)
|
||||
[hex appendFormat:@"%02x", digest[i]];
|
||||
return hex;
|
||||
NSMutableString *hex =
|
||||
[NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
|
||||
for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++)
|
||||
[hex appendFormat:@"%02x", digest[i]];
|
||||
return hex;
|
||||
}
|
||||
|
||||
static const char *self_executable_path(void) {
|
||||
static char path[4096];
|
||||
uint32_t size = sizeof(path);
|
||||
if (_NSGetExecutablePath(path, &size) != 0) return NULL;
|
||||
return path;
|
||||
static char path[4096];
|
||||
uint32_t size = sizeof(path);
|
||||
if (_NSGetExecutablePath(path, &size) != 0)
|
||||
return NULL;
|
||||
return path;
|
||||
}
|
||||
|
||||
// MARK: - Auto-update
|
||||
|
||||
/// Receive raw binary from host, write to CACHE_PATH, chmod +x.
|
||||
static BOOL receive_update(int fd, NSUInteger size) {
|
||||
mkdir(CACHE_DIR, 0755);
|
||||
mkdir(CACHE_DIR, 0755);
|
||||
|
||||
char tmp_path[] = CACHE_DIR "/vphoned.XXXXXX";
|
||||
int tmp_fd = mkstemp(tmp_path);
|
||||
if (tmp_fd < 0) {
|
||||
NSLog(@"vphoned: mkstemp failed: %s", strerror(errno));
|
||||
return NO;
|
||||
char tmp_path[] = CACHE_DIR "/vphoned.XXXXXX";
|
||||
int tmp_fd = mkstemp(tmp_path);
|
||||
if (tmp_fd < 0) {
|
||||
NSLog(@"vphoned: mkstemp failed: %s", strerror(errno));
|
||||
return NO;
|
||||
}
|
||||
|
||||
uint8_t buf[32768];
|
||||
NSUInteger remaining = size;
|
||||
while (remaining > 0) {
|
||||
size_t chunk = remaining < sizeof(buf) ? remaining : sizeof(buf);
|
||||
if (!vp_read_fully(fd, buf, chunk)) {
|
||||
NSLog(@"vphoned: update read failed at %lu/%lu",
|
||||
(unsigned long)(size - remaining), (unsigned long)size);
|
||||
close(tmp_fd);
|
||||
unlink(tmp_path);
|
||||
return NO;
|
||||
}
|
||||
|
||||
uint8_t buf[32768];
|
||||
NSUInteger remaining = size;
|
||||
while (remaining > 0) {
|
||||
size_t chunk = remaining < sizeof(buf) ? remaining : sizeof(buf);
|
||||
if (!vp_read_fully(fd, buf, chunk)) {
|
||||
NSLog(@"vphoned: update read failed at %lu/%lu",
|
||||
(unsigned long)(size - remaining), (unsigned long)size);
|
||||
close(tmp_fd);
|
||||
unlink(tmp_path);
|
||||
return NO;
|
||||
}
|
||||
if (write(tmp_fd, buf, chunk) != (ssize_t)chunk) {
|
||||
NSLog(@"vphoned: update write failed: %s", strerror(errno));
|
||||
close(tmp_fd);
|
||||
unlink(tmp_path);
|
||||
return NO;
|
||||
}
|
||||
remaining -= chunk;
|
||||
if (write(tmp_fd, buf, chunk) != (ssize_t)chunk) {
|
||||
NSLog(@"vphoned: update write failed: %s", strerror(errno));
|
||||
close(tmp_fd);
|
||||
unlink(tmp_path);
|
||||
return NO;
|
||||
}
|
||||
close(tmp_fd);
|
||||
chmod(tmp_path, 0755);
|
||||
remaining -= chunk;
|
||||
}
|
||||
close(tmp_fd);
|
||||
chmod(tmp_path, 0755);
|
||||
|
||||
if (rename(tmp_path, CACHE_PATH) != 0) {
|
||||
NSLog(@"vphoned: rename to cache failed: %s", strerror(errno));
|
||||
unlink(tmp_path);
|
||||
return NO;
|
||||
}
|
||||
if (rename(tmp_path, CACHE_PATH) != 0) {
|
||||
NSLog(@"vphoned: rename to cache failed: %s", strerror(errno));
|
||||
unlink(tmp_path);
|
||||
return NO;
|
||||
}
|
||||
|
||||
NSLog(@"vphoned: update written to %s (%lu bytes)", CACHE_PATH, (unsigned long)size);
|
||||
return YES;
|
||||
NSLog(@"vphoned: update written to %s (%lu bytes)", CACHE_PATH,
|
||||
(unsigned long)size);
|
||||
return YES;
|
||||
}
|
||||
|
||||
// MARK: - Command Dispatch
|
||||
|
||||
static NSDictionary *handle_command(NSDictionary *msg) {
|
||||
NSString *type = msg[@"t"];
|
||||
id reqId = msg[@"id"];
|
||||
NSString *type = msg[@"t"];
|
||||
id reqId = msg[@"id"];
|
||||
|
||||
if ([type isEqualToString:@"hid"]) {
|
||||
uint32_t page = [msg[@"page"] unsignedIntValue];
|
||||
uint32_t usage = [msg[@"usage"] unsignedIntValue];
|
||||
NSNumber *downVal = msg[@"down"];
|
||||
if (downVal != nil) {
|
||||
vp_hid_key(page, usage, [downVal boolValue]);
|
||||
} else {
|
||||
vp_hid_press(page, usage);
|
||||
}
|
||||
return vp_make_response(@"ok", reqId);
|
||||
if ([type isEqualToString:@"hid"]) {
|
||||
uint32_t page = [msg[@"page"] unsignedIntValue];
|
||||
uint32_t usage = [msg[@"usage"] unsignedIntValue];
|
||||
NSNumber *downVal = msg[@"down"];
|
||||
if (downVal != nil) {
|
||||
vp_hid_key(page, usage, [downVal boolValue]);
|
||||
} else {
|
||||
vp_hid_press(page, usage);
|
||||
}
|
||||
return vp_make_response(@"ok", reqId);
|
||||
}
|
||||
|
||||
if ([type isEqualToString:@"devmode"]) {
|
||||
if (!vp_devmode_available()) {
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = @"XPC not available";
|
||||
return r;
|
||||
}
|
||||
NSString *action = msg[@"action"];
|
||||
if ([action isEqualToString:@"status"]) {
|
||||
BOOL enabled = vp_devmode_status();
|
||||
NSMutableDictionary *r = vp_make_response(@"ok", reqId);
|
||||
r[@"enabled"] = @(enabled);
|
||||
return r;
|
||||
}
|
||||
if ([action isEqualToString:@"enable"]) {
|
||||
BOOL alreadyEnabled = NO;
|
||||
BOOL ok = vp_devmode_arm(&alreadyEnabled);
|
||||
NSMutableDictionary *r = vp_make_response(ok ? @"ok" : @"err", reqId);
|
||||
if (ok) {
|
||||
r[@"already_enabled"] = @(alreadyEnabled);
|
||||
r[@"msg"] = alreadyEnabled
|
||||
? @"developer mode already enabled"
|
||||
: @"developer mode armed, reboot to activate";
|
||||
} else {
|
||||
r[@"msg"] = @"failed to arm developer mode";
|
||||
}
|
||||
return r;
|
||||
}
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = [NSString stringWithFormat:@"unknown devmode action: %@", action];
|
||||
return r;
|
||||
if ([type isEqualToString:@"devmode"]) {
|
||||
if (!vp_devmode_available()) {
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = @"XPC not available";
|
||||
return r;
|
||||
}
|
||||
|
||||
if ([type isEqualToString:@"ping"]) {
|
||||
return vp_make_response(@"pong", reqId);
|
||||
NSString *action = msg[@"action"];
|
||||
if ([action isEqualToString:@"status"]) {
|
||||
BOOL enabled = vp_devmode_status();
|
||||
NSMutableDictionary *r = vp_make_response(@"ok", reqId);
|
||||
r[@"enabled"] = @(enabled);
|
||||
return r;
|
||||
}
|
||||
|
||||
if ([type isEqualToString:@"location"]) {
|
||||
double lat = [msg[@"lat"] doubleValue];
|
||||
double lon = [msg[@"lon"] doubleValue];
|
||||
double alt = [msg[@"alt"] doubleValue];
|
||||
double hacc = [msg[@"hacc"] doubleValue];
|
||||
double vacc = [msg[@"vacc"] doubleValue];
|
||||
double speed = [msg[@"speed"] doubleValue];
|
||||
double course = [msg[@"course"] doubleValue];
|
||||
vp_location_simulate(lat, lon, alt, hacc, vacc, speed, course);
|
||||
return vp_make_response(@"ok", reqId);
|
||||
if ([action isEqualToString:@"enable"]) {
|
||||
BOOL alreadyEnabled = NO;
|
||||
BOOL ok = vp_devmode_arm(&alreadyEnabled);
|
||||
NSMutableDictionary *r = vp_make_response(ok ? @"ok" : @"err", reqId);
|
||||
if (ok) {
|
||||
r[@"already_enabled"] = @(alreadyEnabled);
|
||||
r[@"msg"] = alreadyEnabled
|
||||
? @"developer mode already enabled"
|
||||
: @"developer mode armed, reboot to activate";
|
||||
} else {
|
||||
r[@"msg"] = @"failed to arm developer mode";
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
if ([type isEqualToString:@"location_stop"]) {
|
||||
vp_location_clear();
|
||||
return vp_make_response(@"ok", reqId);
|
||||
}
|
||||
|
||||
if ([type isEqualToString:@"version"]) {
|
||||
NSMutableDictionary *r = vp_make_response(@"version", reqId);
|
||||
r[@"hash"] = @VPHONED_BUILD_HASH;
|
||||
return r;
|
||||
}
|
||||
|
||||
if ([type isEqualToString:@"ipa_install"]) {
|
||||
return vp_handle_custom_install(msg);
|
||||
}
|
||||
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = [NSString stringWithFormat:@"unknown type: %@", type];
|
||||
r[@"msg"] =
|
||||
[NSString stringWithFormat:@"unknown devmode action: %@", action];
|
||||
return r;
|
||||
}
|
||||
|
||||
if ([type isEqualToString:@"ping"]) {
|
||||
return vp_make_response(@"pong", reqId);
|
||||
}
|
||||
|
||||
if ([type isEqualToString:@"location"]) {
|
||||
double lat = [msg[@"lat"] doubleValue];
|
||||
double lon = [msg[@"lon"] doubleValue];
|
||||
double alt = [msg[@"alt"] doubleValue];
|
||||
double hacc = [msg[@"hacc"] doubleValue];
|
||||
double vacc = [msg[@"vacc"] doubleValue];
|
||||
double speed = [msg[@"speed"] doubleValue];
|
||||
double course = [msg[@"course"] doubleValue];
|
||||
vp_location_simulate(lat, lon, alt, hacc, vacc, speed, course);
|
||||
return vp_make_response(@"ok", reqId);
|
||||
}
|
||||
|
||||
if ([type isEqualToString:@"location_stop"]) {
|
||||
vp_location_clear();
|
||||
return vp_make_response(@"ok", reqId);
|
||||
}
|
||||
|
||||
if ([type isEqualToString:@"version"]) {
|
||||
NSMutableDictionary *r = vp_make_response(@"version", reqId);
|
||||
r[@"hash"] = @VPHONED_BUILD_HASH;
|
||||
return r;
|
||||
}
|
||||
|
||||
if ([type isEqualToString:@"ipa_install"]) {
|
||||
return vp_handle_custom_install(msg);
|
||||
}
|
||||
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = [NSString stringWithFormat:@"unknown type: %@", type];
|
||||
return r;
|
||||
}
|
||||
|
||||
// MARK: - Client Session
|
||||
|
||||
/// Returns YES if daemon should exit for restart (after update).
|
||||
static BOOL handle_client(int fd) {
|
||||
BOOL should_restart = NO;
|
||||
@autoreleasepool {
|
||||
NSDictionary *hello = vp_read_message(fd);
|
||||
if (!hello) { close(fd); return NO; }
|
||||
|
||||
NSInteger version = [hello[@"v"] integerValue];
|
||||
NSString *type = hello[@"t"];
|
||||
|
||||
if (![type isEqualToString:@"hello"]) {
|
||||
NSLog(@"vphoned: expected hello, got %@", type);
|
||||
close(fd);
|
||||
return NO;
|
||||
}
|
||||
|
||||
if (version != PROTOCOL_VERSION) {
|
||||
NSLog(@"vphoned: version mismatch (client v%ld, daemon v%d)",
|
||||
(long)version, PROTOCOL_VERSION);
|
||||
vp_write_message(fd, @{@"v": @PROTOCOL_VERSION, @"t": @"err",
|
||||
@"msg": @"version mismatch"});
|
||||
close(fd);
|
||||
return NO;
|
||||
}
|
||||
|
||||
// Hash comparison for auto-update
|
||||
NSString *hostHash = hello[@"bin_hash"];
|
||||
BOOL needUpdate = NO;
|
||||
if (hostHash.length > 0) {
|
||||
const char *selfPath = self_executable_path();
|
||||
NSString *selfHash = selfPath ? sha256_of_file(selfPath) : nil;
|
||||
if (selfHash && ![selfHash isEqualToString:hostHash]) {
|
||||
NSLog(@"vphoned: hash mismatch (self=%@ host=%@)", selfHash, hostHash);
|
||||
needUpdate = YES;
|
||||
} else if (selfHash) {
|
||||
NSLog(@"vphoned: hash OK");
|
||||
}
|
||||
}
|
||||
|
||||
// Build capabilities list
|
||||
NSMutableArray *caps = [NSMutableArray arrayWithObjects:@"hid", @"devmode", @"file", @"keychain", nil];
|
||||
if (vp_location_available()) [caps addObject:@"location"];
|
||||
if (vp_custom_installer_available()) [caps addObject:@"ipa_install"];
|
||||
|
||||
NSMutableDictionary *helloResp = [@{
|
||||
@"v": @PROTOCOL_VERSION,
|
||||
@"t": @"hello",
|
||||
@"name": @"vphoned",
|
||||
@"caps": caps,
|
||||
} mutableCopy];
|
||||
if (needUpdate) helloResp[@"need_update"] = @YES;
|
||||
|
||||
if (!vp_write_message(fd, helloResp)) { close(fd); return NO; }
|
||||
NSLog(@"vphoned: client connected (v%d)%s",
|
||||
PROTOCOL_VERSION, needUpdate ? " [update pending]" : "");
|
||||
|
||||
NSDictionary *msg;
|
||||
while ((msg = vp_read_message(fd)) != nil) {
|
||||
@autoreleasepool {
|
||||
NSString *t = msg[@"t"];
|
||||
NSLog(@"vphoned: recv cmd: %@", t);
|
||||
|
||||
if ([t isEqualToString:@"update"]) {
|
||||
NSUInteger size = [msg[@"size"] unsignedIntegerValue];
|
||||
id reqId = msg[@"id"];
|
||||
NSLog(@"vphoned: receiving update (%lu bytes)", (unsigned long)size);
|
||||
if (size > 0 && size < 10 * 1024 * 1024 && receive_update(fd, size)) {
|
||||
NSMutableDictionary *r = vp_make_response(@"ok", reqId);
|
||||
r[@"msg"] = @"updated, restarting";
|
||||
vp_write_message(fd, r);
|
||||
should_restart = YES;
|
||||
break;
|
||||
} else {
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = @"update failed";
|
||||
vp_write_message(fd, r);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// File operations (need fd for inline binary transfer)
|
||||
if ([t hasPrefix:@"file_"]) {
|
||||
NSDictionary *resp = vp_handle_file_command(fd, msg);
|
||||
if (resp && !vp_write_message(fd, resp)) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keychain operations
|
||||
if ([t hasPrefix:@"keychain_"]) {
|
||||
NSDictionary *resp = vp_handle_keychain_command(msg);
|
||||
if (resp && !vp_write_message(fd, resp)) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
NSDictionary *resp = handle_command(msg);
|
||||
if (resp && !vp_write_message(fd, resp)) break;
|
||||
}
|
||||
}
|
||||
|
||||
NSLog(@"vphoned: client disconnected%s", should_restart ? " (restarting for update)" : "");
|
||||
close(fd);
|
||||
BOOL should_restart = NO;
|
||||
@autoreleasepool {
|
||||
NSDictionary *hello = vp_read_message(fd);
|
||||
if (!hello) {
|
||||
close(fd);
|
||||
return NO;
|
||||
}
|
||||
return should_restart;
|
||||
|
||||
NSInteger version = [hello[@"v"] integerValue];
|
||||
NSString *type = hello[@"t"];
|
||||
|
||||
if (![type isEqualToString:@"hello"]) {
|
||||
NSLog(@"vphoned: expected hello, got %@", type);
|
||||
close(fd);
|
||||
return NO;
|
||||
}
|
||||
|
||||
if (version != PROTOCOL_VERSION) {
|
||||
NSLog(@"vphoned: version mismatch (client v%ld, daemon v%d)",
|
||||
(long)version, PROTOCOL_VERSION);
|
||||
vp_write_message(
|
||||
fd, @{
|
||||
@"v" : @PROTOCOL_VERSION,
|
||||
@"t" : @"err",
|
||||
@"msg" : @"version mismatch"
|
||||
});
|
||||
close(fd);
|
||||
return NO;
|
||||
}
|
||||
|
||||
// Hash comparison for auto-update
|
||||
NSString *hostHash = hello[@"bin_hash"];
|
||||
BOOL needUpdate = NO;
|
||||
if (hostHash.length > 0) {
|
||||
const char *selfPath = self_executable_path();
|
||||
NSString *selfHash = selfPath ? sha256_of_file(selfPath) : nil;
|
||||
if (selfHash && ![selfHash isEqualToString:hostHash]) {
|
||||
NSLog(@"vphoned: hash mismatch (self=%@ host=%@)", selfHash, hostHash);
|
||||
needUpdate = YES;
|
||||
} else if (selfHash) {
|
||||
NSLog(@"vphoned: hash OK");
|
||||
}
|
||||
}
|
||||
|
||||
// Build capabilities list
|
||||
NSMutableArray *caps = [NSMutableArray
|
||||
arrayWithObjects:@"hid", @"devmode", @"file", @"keychain", nil];
|
||||
if (vp_location_available())
|
||||
[caps addObject:@"location"];
|
||||
if (vp_custom_installer_available())
|
||||
[caps addObject:@"ipa_install"];
|
||||
if (gClipboardAvailable)
|
||||
[caps addObject:@"clipboard"];
|
||||
if (gAppsAvailable)
|
||||
[caps addObject:@"apps"];
|
||||
[caps addObject:@"url"];
|
||||
[caps addObject:@"settings"];
|
||||
|
||||
NSMutableDictionary *helloResp = [@{
|
||||
@"v" : @PROTOCOL_VERSION,
|
||||
@"t" : @"hello",
|
||||
@"name" : @"vphoned",
|
||||
@"caps" : caps,
|
||||
} mutableCopy];
|
||||
if (needUpdate)
|
||||
helloResp[@"need_update"] = @YES;
|
||||
|
||||
if (!vp_write_message(fd, helloResp)) {
|
||||
close(fd);
|
||||
return NO;
|
||||
}
|
||||
NSLog(@"vphoned: client connected (v%d)%s", PROTOCOL_VERSION,
|
||||
needUpdate ? " [update pending]" : "");
|
||||
|
||||
NSDictionary *msg;
|
||||
while ((msg = vp_read_message(fd)) != nil) {
|
||||
@autoreleasepool {
|
||||
NSString *t = msg[@"t"];
|
||||
NSLog(@"vphoned: recv cmd: %@", t);
|
||||
|
||||
if ([t isEqualToString:@"update"]) {
|
||||
NSUInteger size = [msg[@"size"] unsignedIntegerValue];
|
||||
id reqId = msg[@"id"];
|
||||
NSLog(@"vphoned: receiving update (%lu bytes)", (unsigned long)size);
|
||||
if (size > 0 && size < 10 * 1024 * 1024 && receive_update(fd, size)) {
|
||||
NSMutableDictionary *r = vp_make_response(@"ok", reqId);
|
||||
r[@"msg"] = @"updated, restarting";
|
||||
vp_write_message(fd, r);
|
||||
should_restart = YES;
|
||||
break;
|
||||
} else {
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = @"update failed";
|
||||
vp_write_message(fd, r);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// File operations (need fd for inline binary transfer)
|
||||
if ([t hasPrefix:@"file_"]) {
|
||||
NSDictionary *resp = vp_handle_file_command(fd, msg);
|
||||
if (resp && !vp_write_message(fd, resp))
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keychain operations
|
||||
if ([t hasPrefix:@"keychain_"]) {
|
||||
NSDictionary *resp = vp_handle_keychain_command(msg);
|
||||
if (resp && !vp_write_message(fd, resp))
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clipboard operations (need fd for inline binary transfer)
|
||||
if ([t hasPrefix:@"clipboard_"]) {
|
||||
NSDictionary *resp = vp_handle_clipboard_command(fd, msg);
|
||||
if (resp && !vp_write_message(fd, resp))
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
// App management operations
|
||||
if ([t hasPrefix:@"app_"]) {
|
||||
NSDictionary *resp = vp_handle_apps_command(msg);
|
||||
if (resp && !vp_write_message(fd, resp))
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
// URL opening
|
||||
if ([t isEqualToString:@"open_url"]) {
|
||||
NSDictionary *resp = vp_handle_url_command(msg);
|
||||
if (resp && !vp_write_message(fd, resp))
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Settings operations
|
||||
if ([t hasPrefix:@"settings_"]) {
|
||||
NSDictionary *resp = vp_handle_settings_command(msg);
|
||||
if (resp && !vp_write_message(fd, resp))
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Accessibility tree
|
||||
if ([t isEqualToString:@"accessibility_tree"]) {
|
||||
NSDictionary *resp = vp_handle_accessibility_command(msg);
|
||||
if (resp && !vp_write_message(fd, resp))
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
NSDictionary *resp = handle_command(msg);
|
||||
if (resp && !vp_write_message(fd, resp))
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
NSLog(@"vphoned: client disconnected%s",
|
||||
should_restart ? " (restarting for update)" : "");
|
||||
close(fd);
|
||||
}
|
||||
return should_restart;
|
||||
}
|
||||
|
||||
// MARK: - Main
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
@autoreleasepool {
|
||||
// Bootstrap: if running from install path and a cached update exists, exec it
|
||||
const char *selfPath = self_executable_path();
|
||||
if (selfPath && strcmp(selfPath, INSTALL_PATH) == 0 && access(CACHE_PATH, X_OK) == 0) {
|
||||
NSLog(@"vphoned: found cached binary at %s, exec'ing", CACHE_PATH);
|
||||
execv(CACHE_PATH, argv);
|
||||
NSLog(@"vphoned: execv failed: %s — continuing with installed binary", strerror(errno));
|
||||
unlink(CACHE_PATH);
|
||||
}
|
||||
|
||||
NSLog(@"vphoned: starting (pid=%d, path=%s)", getpid(), selfPath ?: "?");
|
||||
|
||||
if (!vp_hid_load()) return 1;
|
||||
if (!vp_devmode_load()) NSLog(@"vphoned: XPC unavailable, devmode disabled");
|
||||
vp_location_load();
|
||||
|
||||
int sock = socket(AF_VSOCK, SOCK_STREAM, 0);
|
||||
if (sock < 0) { perror("vphoned: socket(AF_VSOCK)"); return 1; }
|
||||
|
||||
int one = 1;
|
||||
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
|
||||
|
||||
struct sockaddr_vm addr = {
|
||||
.svm_len = sizeof(struct sockaddr_vm),
|
||||
.svm_family = AF_VSOCK,
|
||||
.svm_port = VPHONED_PORT,
|
||||
.svm_cid = VMADDR_CID_ANY,
|
||||
};
|
||||
|
||||
if (bind(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
|
||||
perror("vphoned: bind"); close(sock); return 1;
|
||||
}
|
||||
if (listen(sock, 2) < 0) {
|
||||
perror("vphoned: listen"); close(sock); return 1;
|
||||
}
|
||||
|
||||
NSLog(@"vphoned: listening on vsock port %d", VPHONED_PORT);
|
||||
|
||||
for (;;) {
|
||||
int client = accept(sock, NULL, NULL);
|
||||
if (client < 0) { perror("vphoned: accept"); sleep(1); continue; }
|
||||
if (handle_client(client)) {
|
||||
NSLog(@"vphoned: exiting for update restart");
|
||||
close(sock);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@autoreleasepool {
|
||||
// Bootstrap: if running from install path and a cached update exists, exec
|
||||
// it
|
||||
const char *selfPath = self_executable_path();
|
||||
if (selfPath && strcmp(selfPath, INSTALL_PATH) == 0 &&
|
||||
access(CACHE_PATH, X_OK) == 0) {
|
||||
NSLog(@"vphoned: found cached binary at %s, exec'ing", CACHE_PATH);
|
||||
execv(CACHE_PATH, argv);
|
||||
NSLog(@"vphoned: execv failed: %s — continuing with installed binary",
|
||||
strerror(errno));
|
||||
unlink(CACHE_PATH);
|
||||
}
|
||||
|
||||
NSLog(@"vphoned: starting (pid=%d, path=%s)", getpid(), selfPath ?: "?");
|
||||
|
||||
if (!vp_hid_load())
|
||||
return 1;
|
||||
if (!vp_devmode_load())
|
||||
NSLog(@"vphoned: XPC unavailable, devmode disabled");
|
||||
vp_location_load();
|
||||
|
||||
gClipboardAvailable = vp_clipboard_load();
|
||||
gAppsAvailable = vp_apps_load();
|
||||
|
||||
int sock = socket(AF_VSOCK, SOCK_STREAM, 0);
|
||||
if (sock < 0) {
|
||||
perror("vphoned: socket(AF_VSOCK)");
|
||||
return 1;
|
||||
}
|
||||
|
||||
int one = 1;
|
||||
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
|
||||
|
||||
struct sockaddr_vm addr = {
|
||||
.svm_len = sizeof(struct sockaddr_vm),
|
||||
.svm_family = AF_VSOCK,
|
||||
.svm_port = VPHONED_PORT,
|
||||
.svm_cid = VMADDR_CID_ANY,
|
||||
};
|
||||
|
||||
if (bind(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
|
||||
perror("vphoned: bind");
|
||||
close(sock);
|
||||
return 1;
|
||||
}
|
||||
if (listen(sock, 2) < 0) {
|
||||
perror("vphoned: listen");
|
||||
close(sock);
|
||||
return 1;
|
||||
}
|
||||
|
||||
NSLog(@"vphoned: listening on vsock port %d", VPHONED_PORT);
|
||||
|
||||
for (;;) {
|
||||
int client = accept(sock, NULL, NULL);
|
||||
if (client < 0) {
|
||||
perror("vphoned: accept");
|
||||
sleep(1);
|
||||
continue;
|
||||
}
|
||||
if (handle_client(client)) {
|
||||
NSLog(@"vphoned: exiting for update restart");
|
||||
close(sock);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
scripts/vphoned/vphoned_accessibility.h
Normal file
12
scripts/vphoned/vphoned_accessibility.h
Normal file
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* vphoned_accessibility — Accessibility tree query over vsock.
|
||||
*
|
||||
* Handles accessibility_tree. Currently a stub — requires XPC research
|
||||
* to properly query the accessibility tree from a daemon context.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
/// Handle an accessibility_tree command. Returns a response dict.
|
||||
NSDictionary *vp_handle_accessibility_command(NSDictionary *msg);
|
||||
21
scripts/vphoned/vphoned_accessibility.m
Normal file
21
scripts/vphoned/vphoned_accessibility.m
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* vphoned_accessibility — Accessibility tree query (stub).
|
||||
*
|
||||
* TODO: Implement proper accessibility tree retrieval.
|
||||
* Options under investigation:
|
||||
* 1. XPC to com.apple.accessibility.AXRuntime
|
||||
* 2. AXUIElement private API (may not be available on iOS)
|
||||
* 3. Dylib injection into SpringBoard
|
||||
* 4. Direct UIAccessibility traversal via task_for_pid
|
||||
*/
|
||||
|
||||
#import "vphoned_accessibility.h"
|
||||
#import "vphoned_protocol.h"
|
||||
|
||||
NSDictionary *vp_handle_accessibility_command(NSDictionary *msg) {
|
||||
id reqId = msg[@"id"];
|
||||
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = @"accessibility_tree not yet implemented — requires XPC research";
|
||||
return r;
|
||||
}
|
||||
15
scripts/vphoned/vphoned_apps.h
Normal file
15
scripts/vphoned/vphoned_apps.h
Normal file
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* vphoned_apps — App lifecycle management over vsock.
|
||||
*
|
||||
* Handles app_list, app_launch, app_terminate, app_foreground using
|
||||
* private APIs: LSApplicationWorkspace, FBSSystemService, SpringBoardServices.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
/// Load private framework symbols for app management. Returns NO on failure.
|
||||
BOOL vp_apps_load(void);
|
||||
|
||||
/// Handle an app command. Returns a response dict.
|
||||
NSDictionary *vp_handle_apps_command(NSDictionary *msg);
|
||||
263
scripts/vphoned/vphoned_apps.m
Normal file
263
scripts/vphoned/vphoned_apps.m
Normal file
@@ -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;
|
||||
}
|
||||
16
scripts/vphoned/vphoned_clipboard.h
Normal file
16
scripts/vphoned/vphoned_clipboard.h
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* vphoned_clipboard — Clipboard (pasteboard) read/write over vsock.
|
||||
*
|
||||
* Handles clipboard_get and clipboard_set using UIPasteboard via dlopen.
|
||||
* Supports text and image (PNG) payloads with inline binary transfer.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
/// Load UIKit symbols for clipboard access. Returns NO on failure.
|
||||
BOOL vp_clipboard_load(void);
|
||||
|
||||
/// Handle a clipboard command. May write binary data inline for images.
|
||||
/// Returns a response dict, or nil if the response was written inline.
|
||||
NSDictionary *vp_handle_clipboard_command(int fd, NSDictionary *msg);
|
||||
168
scripts/vphoned/vphoned_clipboard.m
Normal file
168
scripts/vphoned/vphoned_clipboard.m
Normal file
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* vphoned_clipboard — Clipboard read/write via UIPasteboard (dlopen).
|
||||
*
|
||||
* UIPasteboard is loaded at runtime since vphoned is a daemon without UIKit.
|
||||
* Uses objc_msgSend for all UIPasteboard interactions.
|
||||
*/
|
||||
|
||||
#import "vphoned_clipboard.h"
|
||||
#import "vphoned_protocol.h"
|
||||
#include <dlfcn.h>
|
||||
#include <objc/message.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static BOOL gClipboardLoaded = NO;
|
||||
static Class gPasteboardClass = Nil;
|
||||
// UIImagePNGRepresentation
|
||||
static NSData *(*pImagePNGRep)(id) = NULL;
|
||||
|
||||
BOOL vp_clipboard_load(void) {
|
||||
void *h =
|
||||
dlopen("/System/Library/Frameworks/UIKit.framework/UIKit", RTLD_LAZY);
|
||||
if (!h) {
|
||||
NSLog(@"vphoned: dlopen UIKit failed: %s", dlerror());
|
||||
return NO;
|
||||
}
|
||||
|
||||
gPasteboardClass = NSClassFromString(@"UIPasteboard");
|
||||
if (!gPasteboardClass) {
|
||||
NSLog(@"vphoned: UIPasteboard class not found");
|
||||
return NO;
|
||||
}
|
||||
|
||||
pImagePNGRep = dlsym(h, "UIImagePNGRepresentation");
|
||||
if (!pImagePNGRep) {
|
||||
NSLog(@"vphoned: UIImagePNGRepresentation not found (image support "
|
||||
@"disabled)");
|
||||
// Non-fatal: text clipboard still works
|
||||
}
|
||||
|
||||
gClipboardLoaded = YES;
|
||||
NSLog(@"vphoned: clipboard loaded (UIKit)");
|
||||
return YES;
|
||||
}
|
||||
|
||||
static id get_general_pasteboard(void) {
|
||||
return ((id (*)(Class, SEL))objc_msgSend)(
|
||||
gPasteboardClass, sel_registerName("generalPasteboard"));
|
||||
}
|
||||
|
||||
NSDictionary *vp_handle_clipboard_command(int fd, NSDictionary *msg) {
|
||||
NSString *type = msg[@"t"];
|
||||
id reqId = msg[@"id"];
|
||||
|
||||
if (!gClipboardLoaded) {
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = @"clipboard not available (UIKit not loaded)";
|
||||
return r;
|
||||
}
|
||||
|
||||
// -- clipboard_get --
|
||||
if ([type isEqualToString:@"clipboard_get"]) {
|
||||
id pb = get_general_pasteboard();
|
||||
if (!pb) {
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = @"failed to get general pasteboard";
|
||||
return r;
|
||||
}
|
||||
|
||||
NSMutableDictionary *r = vp_make_response(@"clipboard_get", reqId);
|
||||
|
||||
// changeCount
|
||||
NSInteger changeCount = ((NSInteger (*)(id, SEL))objc_msgSend)(
|
||||
pb, sel_registerName("changeCount"));
|
||||
r[@"change_count"] = @(changeCount);
|
||||
|
||||
// pasteboardTypes
|
||||
NSArray *types = ((id (*)(id, SEL))objc_msgSend)(
|
||||
pb, sel_registerName("pasteboardTypes"));
|
||||
r[@"types"] = types ?: @[];
|
||||
|
||||
// string
|
||||
NSString *str =
|
||||
((id (*)(id, SEL))objc_msgSend)(pb, sel_registerName("string"));
|
||||
if (str)
|
||||
r[@"text"] = str;
|
||||
|
||||
// image
|
||||
id image = ((id (*)(id, SEL))objc_msgSend)(pb, sel_registerName("image"));
|
||||
NSData *pngData = nil;
|
||||
if (image && pImagePNGRep) {
|
||||
pngData = pImagePNGRep(image);
|
||||
}
|
||||
|
||||
if (pngData && pngData.length > 0) {
|
||||
r[@"has_image"] = @YES;
|
||||
r[@"image_size"] = @(pngData.length);
|
||||
|
||||
// Write JSON header, then binary PNG data
|
||||
if (!vp_write_message(fd, r))
|
||||
return nil;
|
||||
vp_write_fully(fd, pngData.bytes, pngData.length);
|
||||
return nil; // Already written inline
|
||||
} else {
|
||||
r[@"has_image"] = @NO;
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
// -- clipboard_set --
|
||||
if ([type isEqualToString:@"clipboard_set"]) {
|
||||
id pb = get_general_pasteboard();
|
||||
if (!pb) {
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = @"failed to get general pasteboard";
|
||||
return r;
|
||||
}
|
||||
|
||||
NSString *setType = msg[@"type"];
|
||||
if ([setType isEqualToString:@"image"]) {
|
||||
// Image mode: read binary payload
|
||||
NSUInteger size = [msg[@"size"] unsignedIntegerValue];
|
||||
if (size == 0 || size > 50 * 1024 * 1024) {
|
||||
if (size > 0)
|
||||
vp_drain(fd, size);
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = @"invalid image size";
|
||||
return r;
|
||||
}
|
||||
|
||||
NSMutableData *imgData = [NSMutableData dataWithLength:size];
|
||||
if (!vp_read_fully(fd, imgData.mutableBytes, size)) {
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = @"failed to read image data";
|
||||
return r;
|
||||
}
|
||||
|
||||
// Create UIImage from PNG data and set on pasteboard
|
||||
Class uiImageClass = NSClassFromString(@"UIImage");
|
||||
if (uiImageClass) {
|
||||
id image = ((id (*)(Class, SEL, id))objc_msgSend)(
|
||||
uiImageClass, sel_registerName("imageWithData:"), imgData);
|
||||
if (image) {
|
||||
((void (*)(id, SEL, id))objc_msgSend)(
|
||||
pb, sel_registerName("setImage:"), image);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Text mode
|
||||
NSString *text = msg[@"text"];
|
||||
if (text) {
|
||||
((void (*)(id, SEL, id))objc_msgSend)(
|
||||
pb, sel_registerName("setString:"), text);
|
||||
}
|
||||
}
|
||||
|
||||
NSInteger changeCount = ((NSInteger (*)(id, SEL))objc_msgSend)(
|
||||
pb, sel_registerName("changeCount"));
|
||||
NSMutableDictionary *r = vp_make_response(@"clipboard_set", reqId);
|
||||
r[@"ok"] = @YES;
|
||||
r[@"change_count"] = @(changeCount);
|
||||
return r;
|
||||
}
|
||||
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] =
|
||||
[NSString stringWithFormat:@"unknown clipboard command: %@", type];
|
||||
return r;
|
||||
}
|
||||
12
scripts/vphoned/vphoned_settings.h
Normal file
12
scripts/vphoned/vphoned_settings.h
Normal file
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* vphoned_settings — System preferences read/write over vsock.
|
||||
*
|
||||
* Handles settings_get and settings_set using CFPreferences.
|
||||
* No extra frameworks needed — CFPreferences is in CoreFoundation.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
/// Handle a settings command. Returns a response dict.
|
||||
NSDictionary *vp_handle_settings_command(NSDictionary *msg);
|
||||
175
scripts/vphoned/vphoned_settings.m
Normal file
175
scripts/vphoned/vphoned_settings.m
Normal file
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* vphoned_settings — System preferences read/write via CFPreferences.
|
||||
*
|
||||
* Reads and writes preference domains using CFPreferences API.
|
||||
* No additional frameworks required.
|
||||
*/
|
||||
|
||||
#import "vphoned_settings.h"
|
||||
#import "vphoned_protocol.h"
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Map a CFPropertyList value to a JSON-safe representation with type info.
|
||||
static NSDictionary *serialize_value(id value) {
|
||||
if (!value || value == (id)kCFNull) {
|
||||
return @{@"value" : [NSNull null], @"type" : @"null"};
|
||||
}
|
||||
if ([value isKindOfClass:[NSNumber class]]) {
|
||||
// Distinguish boolean from number
|
||||
if (strcmp([value objCType], @encode(BOOL)) == 0 ||
|
||||
strcmp([value objCType], @encode(char)) == 0) {
|
||||
return @{@"value" : value, @"type" : @"boolean"};
|
||||
}
|
||||
// Check for float/double
|
||||
if (strcmp([value objCType], @encode(float)) == 0 ||
|
||||
strcmp([value objCType], @encode(double)) == 0) {
|
||||
return @{@"value" : value, @"type" : @"float"};
|
||||
}
|
||||
return @{@"value" : value, @"type" : @"integer"};
|
||||
}
|
||||
if ([value isKindOfClass:[NSString class]]) {
|
||||
return @{@"value" : value, @"type" : @"string"};
|
||||
}
|
||||
if ([value isKindOfClass:[NSData class]]) {
|
||||
return @{
|
||||
@"value" : [(NSData *)value base64EncodedStringWithOptions:0],
|
||||
@"type" : @"data"
|
||||
};
|
||||
}
|
||||
if ([value isKindOfClass:[NSDate class]]) {
|
||||
return @{
|
||||
@"value" : @([(NSDate *)value timeIntervalSince1970]),
|
||||
@"type" : @"date"
|
||||
};
|
||||
}
|
||||
if ([value isKindOfClass:[NSArray class]] ||
|
||||
[value isKindOfClass:[NSDictionary class]]) {
|
||||
// Try JSON serialization
|
||||
if ([NSJSONSerialization isValidJSONObject:value]) {
|
||||
return @{@"value" : value, @"type" : @"plist"};
|
||||
}
|
||||
return @{@"value" : [value description], @"type" : @"plist"};
|
||||
}
|
||||
return @{@"value" : [value description], @"type" : @"unknown"};
|
||||
}
|
||||
|
||||
/// Deserialize a value from the request based on type hint.
|
||||
static id deserialize_value(id rawValue, NSString *typeHint) {
|
||||
if (!rawValue || rawValue == (id)[NSNull null])
|
||||
return nil;
|
||||
|
||||
if ([typeHint isEqualToString:@"boolean"]) {
|
||||
return @([rawValue boolValue]);
|
||||
}
|
||||
if ([typeHint isEqualToString:@"integer"]) {
|
||||
return @([rawValue longLongValue]);
|
||||
}
|
||||
if ([typeHint isEqualToString:@"float"]) {
|
||||
return @([rawValue doubleValue]);
|
||||
}
|
||||
if ([typeHint isEqualToString:@"string"]) {
|
||||
return [rawValue description];
|
||||
}
|
||||
if ([typeHint isEqualToString:@"data"]) {
|
||||
if ([rawValue isKindOfClass:[NSString class]]) {
|
||||
return [[NSData alloc] initWithBase64EncodedString:rawValue options:0];
|
||||
}
|
||||
}
|
||||
// Default: pass through (JSON types map naturally)
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
// MARK: - Command Handler
|
||||
|
||||
NSDictionary *vp_handle_settings_command(NSDictionary *msg) {
|
||||
NSString *type = msg[@"t"];
|
||||
id reqId = msg[@"id"];
|
||||
|
||||
// -- settings_get --
|
||||
if ([type isEqualToString:@"settings_get"]) {
|
||||
NSString *domain = msg[@"domain"];
|
||||
NSString *key = msg[@"key"];
|
||||
|
||||
if (!domain) {
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = @"missing domain";
|
||||
return r;
|
||||
}
|
||||
|
||||
if (key && key.length > 0) {
|
||||
// Single key
|
||||
CFPropertyListRef value = CFPreferencesCopyAppValue(
|
||||
(__bridge CFStringRef)key, (__bridge CFStringRef)domain);
|
||||
NSMutableDictionary *r = vp_make_response(@"settings_get", reqId);
|
||||
if (value) {
|
||||
NSDictionary *serialized = serialize_value((__bridge id)value);
|
||||
r[@"value"] = serialized[@"value"];
|
||||
r[@"type"] = serialized[@"type"];
|
||||
CFRelease(value);
|
||||
} else {
|
||||
r[@"value"] = [NSNull null];
|
||||
r[@"type"] = @"null";
|
||||
}
|
||||
return r;
|
||||
} else {
|
||||
// All keys in domain
|
||||
CFArrayRef keys = CFPreferencesCopyKeyList((__bridge CFStringRef)domain,
|
||||
kCFPreferencesCurrentUser,
|
||||
kCFPreferencesAnyHost);
|
||||
|
||||
NSMutableDictionary *r = vp_make_response(@"settings_get", reqId);
|
||||
if (keys) {
|
||||
CFDictionaryRef allValues = CFPreferencesCopyMultiple(
|
||||
keys, (__bridge CFStringRef)domain, kCFPreferencesCurrentUser,
|
||||
kCFPreferencesAnyHost);
|
||||
if (allValues) {
|
||||
// Convert to serializable dict
|
||||
NSDictionary *dict = (__bridge NSDictionary *)allValues;
|
||||
NSMutableDictionary *serialized = [NSMutableDictionary dictionary];
|
||||
for (NSString *k in dict) {
|
||||
NSDictionary *entry = serialize_value(dict[k]);
|
||||
serialized[k] = entry;
|
||||
}
|
||||
r[@"value"] = serialized;
|
||||
r[@"type"] = @"dictionary";
|
||||
CFRelease(allValues);
|
||||
}
|
||||
CFRelease(keys);
|
||||
} else {
|
||||
r[@"value"] = @{};
|
||||
r[@"type"] = @"dictionary";
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
// -- settings_set --
|
||||
if ([type isEqualToString:@"settings_set"]) {
|
||||
NSString *domain = msg[@"domain"];
|
||||
NSString *key = msg[@"key"];
|
||||
id rawValue = msg[@"value"];
|
||||
NSString *typeHint = msg[@"type"];
|
||||
|
||||
if (!domain || !key) {
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = @"missing domain or key";
|
||||
return r;
|
||||
}
|
||||
|
||||
id value = deserialize_value(rawValue, typeHint);
|
||||
|
||||
CFPreferencesSetAppValue((__bridge CFStringRef)key,
|
||||
(__bridge CFPropertyListRef)value,
|
||||
(__bridge CFStringRef)domain);
|
||||
CFPreferencesAppSynchronize((__bridge CFStringRef)domain);
|
||||
|
||||
NSMutableDictionary *r = vp_make_response(@"settings_set", reqId);
|
||||
r[@"ok"] = @YES;
|
||||
return r;
|
||||
}
|
||||
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = [NSString stringWithFormat:@"unknown settings command: %@", type];
|
||||
return r;
|
||||
}
|
||||
11
scripts/vphoned/vphoned_url.h
Normal file
11
scripts/vphoned/vphoned_url.h
Normal file
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* vphoned_url — URL opening over vsock.
|
||||
*
|
||||
* Handles open_url using LSApplicationWorkspace.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
/// Handle an open_url command. Returns a response dict.
|
||||
NSDictionary *vp_handle_url_command(NSDictionary *msg);
|
||||
59
scripts/vphoned/vphoned_url.m
Normal file
59
scripts/vphoned/vphoned_url.m
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* vphoned_url — URL opening via LSApplicationWorkspace.
|
||||
*
|
||||
* Uses LSApplicationWorkspace (CoreServices) to open URLs.
|
||||
* Does not require UIKit — works from daemon context.
|
||||
*/
|
||||
|
||||
#import "vphoned_url.h"
|
||||
#import "vphoned_protocol.h"
|
||||
#include <objc/message.h>
|
||||
|
||||
@interface LSApplicationWorkspace : NSObject
|
||||
+ (instancetype)defaultWorkspace;
|
||||
- (BOOL)openURL:(NSURL *)url withOptions:(NSDictionary *)options;
|
||||
- (BOOL)openSensitiveURL:(NSURL *)url withOptions:(NSDictionary *)options;
|
||||
@end
|
||||
|
||||
NSDictionary *vp_handle_url_command(NSDictionary *msg) {
|
||||
id reqId = msg[@"id"];
|
||||
NSString *urlStr = msg[@"url"];
|
||||
|
||||
if (!urlStr) {
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = @"missing url";
|
||||
return r;
|
||||
}
|
||||
|
||||
NSURL *url = [NSURL URLWithString:urlStr];
|
||||
if (!url) {
|
||||
NSMutableDictionary *r = vp_make_response(@"err", reqId);
|
||||
r[@"msg"] = [NSString stringWithFormat:@"invalid url: %@", urlStr];
|
||||
return r;
|
||||
}
|
||||
|
||||
LSApplicationWorkspace *ws = [LSApplicationWorkspace defaultWorkspace];
|
||||
BOOL ok = NO;
|
||||
|
||||
// Try openURL:withOptions: first
|
||||
SEL openURLSel = sel_registerName("openURL:withOptions:");
|
||||
if ([ws respondsToSelector:openURLSel]) {
|
||||
ok = ((BOOL (*)(id, SEL, id, id))objc_msgSend)(ws, openURLSel, url, nil);
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
// Fallback: try openSensitiveURL:withOptions: (requires entitlement)
|
||||
SEL sensitiveSel = sel_registerName("openSensitiveURL:withOptions:");
|
||||
if ([ws respondsToSelector:sensitiveSel]) {
|
||||
ok =
|
||||
((BOOL (*)(id, SEL, id, id))objc_msgSend)(ws, sensitiveSel, url, nil);
|
||||
}
|
||||
}
|
||||
|
||||
NSMutableDictionary *r = vp_make_response(@"open_url", reqId);
|
||||
r[@"ok"] = @(ok);
|
||||
if (!ok) {
|
||||
r[@"msg"] = [NSString stringWithFormat:@"failed to open url: %@", urlStr];
|
||||
}
|
||||
return r;
|
||||
}
|
||||
@@ -3,167 +3,173 @@ import Foundation
|
||||
import Virtualization
|
||||
|
||||
class VPhoneAppDelegate: NSObject, NSApplicationDelegate {
|
||||
private let cli: VPhoneCLI
|
||||
private var vm: VPhoneVirtualMachine?
|
||||
private var control: VPhoneControl?
|
||||
private var windowController: VPhoneWindowController?
|
||||
private var menuController: VPhoneMenuController?
|
||||
private var fileWindowController: VPhoneFileWindowController?
|
||||
private var keychainWindowController: VPhoneKeychainWindowController?
|
||||
private var locationProvider: VPhoneLocationProvider?
|
||||
private var sigintSource: DispatchSourceSignal?
|
||||
private let cli: VPhoneCLI
|
||||
private var vm: VPhoneVirtualMachine?
|
||||
private var control: VPhoneControl?
|
||||
private var windowController: VPhoneWindowController?
|
||||
private var menuController: VPhoneMenuController?
|
||||
private var fileWindowController: VPhoneFileWindowController?
|
||||
private var keychainWindowController: VPhoneKeychainWindowController?
|
||||
private var locationProvider: VPhoneLocationProvider?
|
||||
private var sigintSource: DispatchSourceSignal?
|
||||
|
||||
init(cli: VPhoneCLI) {
|
||||
self.cli = cli
|
||||
super.init()
|
||||
init(cli: VPhoneCLI) {
|
||||
self.cli = cli
|
||||
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) {
|
||||
NSApp.setActivationPolicy(cli.noGraphics ? .prohibited : .regular)
|
||||
print("=== vphone-cli ===")
|
||||
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 src = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
|
||||
src.setEventHandler {
|
||||
print("\n[vphone] SIGINT - shutting down")
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
src.activate()
|
||||
sigintSource = src
|
||||
let vm = try VPhoneVirtualMachine(options: options)
|
||||
self.vm = vm
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await self.startVirtualMachine()
|
||||
} catch {
|
||||
print("[vphone] Fatal: \(error)")
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
try await vm.start(forceDFU: cli.dfu)
|
||||
|
||||
let control = VPhoneControl()
|
||||
self.control = control
|
||||
if !cli.dfu {
|
||||
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
|
||||
private func startVirtualMachine() async throws {
|
||||
let options = try cli.resolveOptions()
|
||||
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
|
||||
|
||||
guard FileManager.default.fileExists(atPath: options.romURL.path) else {
|
||||
throw VPhoneError.romNotFound(options.romURL.path)
|
||||
}
|
||||
let fileWC = VPhoneFileWindowController()
|
||||
fileWindowController = fileWC
|
||||
|
||||
print("=== vphone-cli ===")
|
||||
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)")
|
||||
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)
|
||||
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 {
|
||||
print("Kernel debug stub : auto-assigned")
|
||||
print("[location] guest does not support location simulation")
|
||||
}
|
||||
print("SEP : enabled")
|
||||
print(" storage : \(options.sepStorageURL.path)")
|
||||
print(" rom : \(options.sepRomURL.path)")
|
||||
print("")
|
||||
|
||||
let vm = try VPhoneVirtualMachine(options: options)
|
||||
self.vm = vm
|
||||
|
||||
try await vm.start(forceDFU: cli.dfu)
|
||||
|
||||
let control = VPhoneControl()
|
||||
self.control = control
|
||||
if !cli.dfu {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 mc, weak provider = locationProvider] in
|
||||
mc?.updateConnectAvailability(available: false)
|
||||
mc?.updateInstallAvailability(available: false)
|
||||
mc?.updateAppsAvailability(available: false)
|
||||
mc?.updateClipboardAvailability(available: false)
|
||||
mc?.updateSettingsAvailability(available: false)
|
||||
provider?.stopReplay()
|
||||
provider?.stopForwarding()
|
||||
mc?.updateLocationCapability(available: false)
|
||||
}
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
|
||||
!cli.noGraphics
|
||||
}
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
|
||||
!cli.noGraphics
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
185
sources/vphone-cli/VPhoneMenuApps.swift
Normal file
185
sources/vphone-cli/VPhoneMenuApps.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
sources/vphone-cli/VPhoneMenuClipboard.swift
Normal file
81
sources/vphone-cli/VPhoneMenuClipboard.swift
Normal file
@@ -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
|
||||
class VPhoneMenuController {
|
||||
let keyHelper: VPhoneKeyHelper
|
||||
let control: VPhoneControl
|
||||
weak var vm: VPhoneVirtualMachine?
|
||||
let keyHelper: VPhoneKeyHelper
|
||||
let control: VPhoneControl
|
||||
weak var vm: VPhoneVirtualMachine?
|
||||
|
||||
var onFilesPressed: (() -> Void)?
|
||||
var onKeychainPressed: (() -> Void)?
|
||||
var connectFileBrowserItem: NSMenuItem?
|
||||
var connectKeychainBrowserItem: NSMenuItem?
|
||||
var connectDevModeStatusItem: NSMenuItem?
|
||||
var connectPingItem: NSMenuItem?
|
||||
var connectGuestVersionItem: NSMenuItem?
|
||||
var installPackageItem: 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?
|
||||
var onFilesPressed: (() -> Void)?
|
||||
var onKeychainPressed: (() -> Void)?
|
||||
var connectFileBrowserItem: NSMenuItem?
|
||||
var connectKeychainBrowserItem: NSMenuItem?
|
||||
var connectDevModeStatusItem: NSMenuItem?
|
||||
var connectPingItem: NSMenuItem?
|
||||
var connectGuestVersionItem: NSMenuItem?
|
||||
var installPackageItem: NSMenuItem?
|
||||
var clipboardGetItem: NSMenuItem?
|
||||
var clipboardSetItem: NSMenuItem?
|
||||
var appsListItem: NSMenuItem?
|
||||
var appsRunningItem: NSMenuItem?
|
||||
var appsForegroundItem: NSMenuItem?
|
||||
var appsLaunchItem: NSMenuItem?
|
||||
var appsTerminateItem: NSMenuItem?
|
||||
var appsOpenURLItem: NSMenuItem?
|
||||
var settingsGetItem: NSMenuItem?
|
||||
var settingsSetItem: NSMenuItem?
|
||||
var locationProvider: VPhoneLocationProvider?
|
||||
var locationMenuItem: NSMenuItem?
|
||||
var locationPresetMenuItem: NSMenuItem?
|
||||
var locationReplayStartItem: NSMenuItem?
|
||||
var locationReplayStopItem: NSMenuItem?
|
||||
var screenRecorder: VPhoneScreenRecorder?
|
||||
var recordingItem: NSMenuItem?
|
||||
weak var captureView: VPhoneVirtualMachineView?
|
||||
|
||||
init(keyHelper: VPhoneKeyHelper, control: VPhoneControl) {
|
||||
self.keyHelper = keyHelper
|
||||
self.control = control
|
||||
setupMenuBar()
|
||||
}
|
||||
init(keyHelper: VPhoneKeyHelper, control: VPhoneControl) {
|
||||
self.keyHelper = keyHelper
|
||||
self.control = control
|
||||
setupMenuBar()
|
||||
}
|
||||
|
||||
// MARK: - Menu Bar Setup
|
||||
// MARK: - Menu Bar Setup
|
||||
|
||||
private func setupMenuBar() {
|
||||
let mainMenu = NSMenu()
|
||||
private func setupMenuBar() {
|
||||
let mainMenu = NSMenu()
|
||||
|
||||
// App menu
|
||||
let appMenuItem = NSMenuItem()
|
||||
let appMenu = NSMenu(title: "vphone")
|
||||
#if canImport(VPhoneBuildInfo)
|
||||
let buildItem = NSMenuItem(title: "Build: \(VPhoneBuildInfo.commitHash)", action: nil, keyEquivalent: "")
|
||||
#else
|
||||
let buildItem = NSMenuItem(title: "Build: unknown", action: nil, keyEquivalent: "")
|
||||
#endif
|
||||
buildItem.isEnabled = false
|
||||
appMenu.addItem(buildItem)
|
||||
appMenu.addItem(NSMenuItem.separator())
|
||||
appMenu.addItem(
|
||||
withTitle: "Quit vphone", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"
|
||||
)
|
||||
appMenuItem.submenu = appMenu
|
||||
mainMenu.addItem(appMenuItem)
|
||||
// App menu
|
||||
let appMenuItem = NSMenuItem()
|
||||
let appMenu = NSMenu(title: "vphone")
|
||||
#if canImport(VPhoneBuildInfo)
|
||||
let buildItem = NSMenuItem(
|
||||
title: "Build: \(VPhoneBuildInfo.commitHash)", action: nil, keyEquivalent: "")
|
||||
#else
|
||||
let buildItem = NSMenuItem(title: "Build: unknown", action: nil, keyEquivalent: "")
|
||||
#endif
|
||||
buildItem.isEnabled = false
|
||||
appMenu.addItem(buildItem)
|
||||
appMenu.addItem(NSMenuItem.separator())
|
||||
appMenu.addItem(
|
||||
withTitle: "Quit vphone", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"
|
||||
)
|
||||
appMenuItem.submenu = appMenu
|
||||
mainMenu.addItem(appMenuItem)
|
||||
|
||||
mainMenu.addItem(buildKeysMenu())
|
||||
mainMenu.addItem(buildTypeMenu())
|
||||
mainMenu.addItem(buildConnectMenu())
|
||||
mainMenu.addItem(buildInstallMenu())
|
||||
mainMenu.addItem(buildLocationMenu())
|
||||
mainMenu.addItem(buildRecordMenu())
|
||||
mainMenu.addItem(buildBatteryMenu())
|
||||
mainMenu.addItem(buildKeysMenu())
|
||||
mainMenu.addItem(buildTypeMenu())
|
||||
mainMenu.addItem(buildConnectMenu())
|
||||
mainMenu.addItem(buildAppsMenu())
|
||||
mainMenu.addItem(buildClipboardMenu())
|
||||
mainMenu.addItem(buildInstallMenu())
|
||||
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 {
|
||||
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
|
||||
item.target = self
|
||||
return item
|
||||
}
|
||||
func makeItem(_ title: String, action: Selector) -> NSMenuItem {
|
||||
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
|
||||
item.target = self
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
154
sources/vphone-cli/VPhoneMenuSettings.swift
Normal file
154
sources/vphone-cli/VPhoneMenuSettings.swift
Normal file
@@ -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