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:
Lakr
2026-03-10 18:16:32 +08:00
parent db752baaec
commit df5d5449b5
17 changed files with 2663 additions and 1189 deletions

View File

@@ -13,362 +13,458 @@
* make vphoned
*/
#import <Foundation/Foundation.h>
#include <CommonCrypto/CommonDigest.h>
#import <Foundation/Foundation.h>
#include <mach-o/dyld.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <unistd.h>
#import "vphoned_protocol.h"
#import "vphoned_hid.h"
#import "vphoned_accessibility.h"
#import "vphoned_apps.h"
#import "vphoned_clipboard.h"
#import "vphoned_devmode.h"
#import "vphoned_location.h"
#import "vphoned_files.h"
#import "vphoned_hid.h"
#import "vphoned_install.h"
#import "vphoned_keychain.h"
#import "vphoned_location.h"
#import "vphoned_protocol.h"
#import "vphoned_settings.h"
#import "vphoned_url.h"
#ifndef AF_VSOCK
#define AF_VSOCK 40
#endif
#define VMADDR_CID_ANY 0xFFFFFFFF
#define VPHONED_PORT 1337
#define VMADDR_CID_ANY 0xFFFFFFFF
#define VPHONED_PORT 1337
#ifndef VPHONED_BUILD_HASH
#define VPHONED_BUILD_HASH "unknown"
#endif
static BOOL gClipboardAvailable = NO;
static BOOL gAppsAvailable = NO;
#define INSTALL_PATH "/usr/bin/vphoned"
#define CACHE_PATH "/var/root/Library/Caches/vphoned"
#define CACHE_DIR "/var/root/Library/Caches"
#define CACHE_PATH "/var/root/Library/Caches/vphoned"
#define CACHE_DIR "/var/root/Library/Caches"
struct sockaddr_vm {
__uint8_t svm_len;
sa_family_t svm_family;
__uint16_t svm_reserved1;
__uint32_t svm_port;
__uint32_t svm_cid;
__uint8_t svm_len;
sa_family_t svm_family;
__uint16_t svm_reserved1;
__uint32_t svm_port;
__uint32_t svm_cid;
};
// MARK: - Self-hash
static NSString *sha256_of_file(const char *path) {
int fd = open(path, O_RDONLY);
if (fd < 0) return nil;
int fd = open(path, O_RDONLY);
if (fd < 0)
return nil;
CC_SHA256_CTX ctx;
CC_SHA256_Init(&ctx);
CC_SHA256_CTX ctx;
CC_SHA256_Init(&ctx);
uint8_t buf[32768];
ssize_t n;
while ((n = read(fd, buf, sizeof(buf))) > 0)
CC_SHA256_Update(&ctx, buf, (CC_LONG)n);
close(fd);
uint8_t buf[32768];
ssize_t n;
while ((n = read(fd, buf, sizeof(buf))) > 0)
CC_SHA256_Update(&ctx, buf, (CC_LONG)n);
close(fd);
unsigned char digest[CC_SHA256_DIGEST_LENGTH];
CC_SHA256_Final(digest, &ctx);
unsigned char digest[CC_SHA256_DIGEST_LENGTH];
CC_SHA256_Final(digest, &ctx);
NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++)
[hex appendFormat:@"%02x", digest[i]];
return hex;
NSMutableString *hex =
[NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++)
[hex appendFormat:@"%02x", digest[i]];
return hex;
}
static const char *self_executable_path(void) {
static char path[4096];
uint32_t size = sizeof(path);
if (_NSGetExecutablePath(path, &size) != 0) return NULL;
return path;
static char path[4096];
uint32_t size = sizeof(path);
if (_NSGetExecutablePath(path, &size) != 0)
return NULL;
return path;
}
// MARK: - Auto-update
/// Receive raw binary from host, write to CACHE_PATH, chmod +x.
static BOOL receive_update(int fd, NSUInteger size) {
mkdir(CACHE_DIR, 0755);
mkdir(CACHE_DIR, 0755);
char tmp_path[] = CACHE_DIR "/vphoned.XXXXXX";
int tmp_fd = mkstemp(tmp_path);
if (tmp_fd < 0) {
NSLog(@"vphoned: mkstemp failed: %s", strerror(errno));
return NO;
char tmp_path[] = CACHE_DIR "/vphoned.XXXXXX";
int tmp_fd = mkstemp(tmp_path);
if (tmp_fd < 0) {
NSLog(@"vphoned: mkstemp failed: %s", strerror(errno));
return NO;
}
uint8_t buf[32768];
NSUInteger remaining = size;
while (remaining > 0) {
size_t chunk = remaining < sizeof(buf) ? remaining : sizeof(buf);
if (!vp_read_fully(fd, buf, chunk)) {
NSLog(@"vphoned: update read failed at %lu/%lu",
(unsigned long)(size - remaining), (unsigned long)size);
close(tmp_fd);
unlink(tmp_path);
return NO;
}
uint8_t buf[32768];
NSUInteger remaining = size;
while (remaining > 0) {
size_t chunk = remaining < sizeof(buf) ? remaining : sizeof(buf);
if (!vp_read_fully(fd, buf, chunk)) {
NSLog(@"vphoned: update read failed at %lu/%lu",
(unsigned long)(size - remaining), (unsigned long)size);
close(tmp_fd);
unlink(tmp_path);
return NO;
}
if (write(tmp_fd, buf, chunk) != (ssize_t)chunk) {
NSLog(@"vphoned: update write failed: %s", strerror(errno));
close(tmp_fd);
unlink(tmp_path);
return NO;
}
remaining -= chunk;
if (write(tmp_fd, buf, chunk) != (ssize_t)chunk) {
NSLog(@"vphoned: update write failed: %s", strerror(errno));
close(tmp_fd);
unlink(tmp_path);
return NO;
}
close(tmp_fd);
chmod(tmp_path, 0755);
remaining -= chunk;
}
close(tmp_fd);
chmod(tmp_path, 0755);
if (rename(tmp_path, CACHE_PATH) != 0) {
NSLog(@"vphoned: rename to cache failed: %s", strerror(errno));
unlink(tmp_path);
return NO;
}
if (rename(tmp_path, CACHE_PATH) != 0) {
NSLog(@"vphoned: rename to cache failed: %s", strerror(errno));
unlink(tmp_path);
return NO;
}
NSLog(@"vphoned: update written to %s (%lu bytes)", CACHE_PATH, (unsigned long)size);
return YES;
NSLog(@"vphoned: update written to %s (%lu bytes)", CACHE_PATH,
(unsigned long)size);
return YES;
}
// MARK: - Command Dispatch
static NSDictionary *handle_command(NSDictionary *msg) {
NSString *type = msg[@"t"];
id reqId = msg[@"id"];
NSString *type = msg[@"t"];
id reqId = msg[@"id"];
if ([type isEqualToString:@"hid"]) {
uint32_t page = [msg[@"page"] unsignedIntValue];
uint32_t usage = [msg[@"usage"] unsignedIntValue];
NSNumber *downVal = msg[@"down"];
if (downVal != nil) {
vp_hid_key(page, usage, [downVal boolValue]);
} else {
vp_hid_press(page, usage);
}
return vp_make_response(@"ok", reqId);
if ([type isEqualToString:@"hid"]) {
uint32_t page = [msg[@"page"] unsignedIntValue];
uint32_t usage = [msg[@"usage"] unsignedIntValue];
NSNumber *downVal = msg[@"down"];
if (downVal != nil) {
vp_hid_key(page, usage, [downVal boolValue]);
} else {
vp_hid_press(page, usage);
}
return vp_make_response(@"ok", reqId);
}
if ([type isEqualToString:@"devmode"]) {
if (!vp_devmode_available()) {
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = @"XPC not available";
return r;
}
NSString *action = msg[@"action"];
if ([action isEqualToString:@"status"]) {
BOOL enabled = vp_devmode_status();
NSMutableDictionary *r = vp_make_response(@"ok", reqId);
r[@"enabled"] = @(enabled);
return r;
}
if ([action isEqualToString:@"enable"]) {
BOOL alreadyEnabled = NO;
BOOL ok = vp_devmode_arm(&alreadyEnabled);
NSMutableDictionary *r = vp_make_response(ok ? @"ok" : @"err", reqId);
if (ok) {
r[@"already_enabled"] = @(alreadyEnabled);
r[@"msg"] = alreadyEnabled
? @"developer mode already enabled"
: @"developer mode armed, reboot to activate";
} else {
r[@"msg"] = @"failed to arm developer mode";
}
return r;
}
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = [NSString stringWithFormat:@"unknown devmode action: %@", action];
return r;
if ([type isEqualToString:@"devmode"]) {
if (!vp_devmode_available()) {
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = @"XPC not available";
return r;
}
if ([type isEqualToString:@"ping"]) {
return vp_make_response(@"pong", reqId);
NSString *action = msg[@"action"];
if ([action isEqualToString:@"status"]) {
BOOL enabled = vp_devmode_status();
NSMutableDictionary *r = vp_make_response(@"ok", reqId);
r[@"enabled"] = @(enabled);
return r;
}
if ([type isEqualToString:@"location"]) {
double lat = [msg[@"lat"] doubleValue];
double lon = [msg[@"lon"] doubleValue];
double alt = [msg[@"alt"] doubleValue];
double hacc = [msg[@"hacc"] doubleValue];
double vacc = [msg[@"vacc"] doubleValue];
double speed = [msg[@"speed"] doubleValue];
double course = [msg[@"course"] doubleValue];
vp_location_simulate(lat, lon, alt, hacc, vacc, speed, course);
return vp_make_response(@"ok", reqId);
if ([action isEqualToString:@"enable"]) {
BOOL alreadyEnabled = NO;
BOOL ok = vp_devmode_arm(&alreadyEnabled);
NSMutableDictionary *r = vp_make_response(ok ? @"ok" : @"err", reqId);
if (ok) {
r[@"already_enabled"] = @(alreadyEnabled);
r[@"msg"] = alreadyEnabled
? @"developer mode already enabled"
: @"developer mode armed, reboot to activate";
} else {
r[@"msg"] = @"failed to arm developer mode";
}
return r;
}
if ([type isEqualToString:@"location_stop"]) {
vp_location_clear();
return vp_make_response(@"ok", reqId);
}
if ([type isEqualToString:@"version"]) {
NSMutableDictionary *r = vp_make_response(@"version", reqId);
r[@"hash"] = @VPHONED_BUILD_HASH;
return r;
}
if ([type isEqualToString:@"ipa_install"]) {
return vp_handle_custom_install(msg);
}
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = [NSString stringWithFormat:@"unknown type: %@", type];
r[@"msg"] =
[NSString stringWithFormat:@"unknown devmode action: %@", action];
return r;
}
if ([type isEqualToString:@"ping"]) {
return vp_make_response(@"pong", reqId);
}
if ([type isEqualToString:@"location"]) {
double lat = [msg[@"lat"] doubleValue];
double lon = [msg[@"lon"] doubleValue];
double alt = [msg[@"alt"] doubleValue];
double hacc = [msg[@"hacc"] doubleValue];
double vacc = [msg[@"vacc"] doubleValue];
double speed = [msg[@"speed"] doubleValue];
double course = [msg[@"course"] doubleValue];
vp_location_simulate(lat, lon, alt, hacc, vacc, speed, course);
return vp_make_response(@"ok", reqId);
}
if ([type isEqualToString:@"location_stop"]) {
vp_location_clear();
return vp_make_response(@"ok", reqId);
}
if ([type isEqualToString:@"version"]) {
NSMutableDictionary *r = vp_make_response(@"version", reqId);
r[@"hash"] = @VPHONED_BUILD_HASH;
return r;
}
if ([type isEqualToString:@"ipa_install"]) {
return vp_handle_custom_install(msg);
}
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = [NSString stringWithFormat:@"unknown type: %@", type];
return r;
}
// MARK: - Client Session
/// Returns YES if daemon should exit for restart (after update).
static BOOL handle_client(int fd) {
BOOL should_restart = NO;
@autoreleasepool {
NSDictionary *hello = vp_read_message(fd);
if (!hello) { close(fd); return NO; }
NSInteger version = [hello[@"v"] integerValue];
NSString *type = hello[@"t"];
if (![type isEqualToString:@"hello"]) {
NSLog(@"vphoned: expected hello, got %@", type);
close(fd);
return NO;
}
if (version != PROTOCOL_VERSION) {
NSLog(@"vphoned: version mismatch (client v%ld, daemon v%d)",
(long)version, PROTOCOL_VERSION);
vp_write_message(fd, @{@"v": @PROTOCOL_VERSION, @"t": @"err",
@"msg": @"version mismatch"});
close(fd);
return NO;
}
// Hash comparison for auto-update
NSString *hostHash = hello[@"bin_hash"];
BOOL needUpdate = NO;
if (hostHash.length > 0) {
const char *selfPath = self_executable_path();
NSString *selfHash = selfPath ? sha256_of_file(selfPath) : nil;
if (selfHash && ![selfHash isEqualToString:hostHash]) {
NSLog(@"vphoned: hash mismatch (self=%@ host=%@)", selfHash, hostHash);
needUpdate = YES;
} else if (selfHash) {
NSLog(@"vphoned: hash OK");
}
}
// Build capabilities list
NSMutableArray *caps = [NSMutableArray arrayWithObjects:@"hid", @"devmode", @"file", @"keychain", nil];
if (vp_location_available()) [caps addObject:@"location"];
if (vp_custom_installer_available()) [caps addObject:@"ipa_install"];
NSMutableDictionary *helloResp = [@{
@"v": @PROTOCOL_VERSION,
@"t": @"hello",
@"name": @"vphoned",
@"caps": caps,
} mutableCopy];
if (needUpdate) helloResp[@"need_update"] = @YES;
if (!vp_write_message(fd, helloResp)) { close(fd); return NO; }
NSLog(@"vphoned: client connected (v%d)%s",
PROTOCOL_VERSION, needUpdate ? " [update pending]" : "");
NSDictionary *msg;
while ((msg = vp_read_message(fd)) != nil) {
@autoreleasepool {
NSString *t = msg[@"t"];
NSLog(@"vphoned: recv cmd: %@", t);
if ([t isEqualToString:@"update"]) {
NSUInteger size = [msg[@"size"] unsignedIntegerValue];
id reqId = msg[@"id"];
NSLog(@"vphoned: receiving update (%lu bytes)", (unsigned long)size);
if (size > 0 && size < 10 * 1024 * 1024 && receive_update(fd, size)) {
NSMutableDictionary *r = vp_make_response(@"ok", reqId);
r[@"msg"] = @"updated, restarting";
vp_write_message(fd, r);
should_restart = YES;
break;
} else {
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = @"update failed";
vp_write_message(fd, r);
}
continue;
}
// File operations (need fd for inline binary transfer)
if ([t hasPrefix:@"file_"]) {
NSDictionary *resp = vp_handle_file_command(fd, msg);
if (resp && !vp_write_message(fd, resp)) break;
continue;
}
// Keychain operations
if ([t hasPrefix:@"keychain_"]) {
NSDictionary *resp = vp_handle_keychain_command(msg);
if (resp && !vp_write_message(fd, resp)) break;
continue;
}
NSDictionary *resp = handle_command(msg);
if (resp && !vp_write_message(fd, resp)) break;
}
}
NSLog(@"vphoned: client disconnected%s", should_restart ? " (restarting for update)" : "");
close(fd);
BOOL should_restart = NO;
@autoreleasepool {
NSDictionary *hello = vp_read_message(fd);
if (!hello) {
close(fd);
return NO;
}
return should_restart;
NSInteger version = [hello[@"v"] integerValue];
NSString *type = hello[@"t"];
if (![type isEqualToString:@"hello"]) {
NSLog(@"vphoned: expected hello, got %@", type);
close(fd);
return NO;
}
if (version != PROTOCOL_VERSION) {
NSLog(@"vphoned: version mismatch (client v%ld, daemon v%d)",
(long)version, PROTOCOL_VERSION);
vp_write_message(
fd, @{
@"v" : @PROTOCOL_VERSION,
@"t" : @"err",
@"msg" : @"version mismatch"
});
close(fd);
return NO;
}
// Hash comparison for auto-update
NSString *hostHash = hello[@"bin_hash"];
BOOL needUpdate = NO;
if (hostHash.length > 0) {
const char *selfPath = self_executable_path();
NSString *selfHash = selfPath ? sha256_of_file(selfPath) : nil;
if (selfHash && ![selfHash isEqualToString:hostHash]) {
NSLog(@"vphoned: hash mismatch (self=%@ host=%@)", selfHash, hostHash);
needUpdate = YES;
} else if (selfHash) {
NSLog(@"vphoned: hash OK");
}
}
// Build capabilities list
NSMutableArray *caps = [NSMutableArray
arrayWithObjects:@"hid", @"devmode", @"file", @"keychain", nil];
if (vp_location_available())
[caps addObject:@"location"];
if (vp_custom_installer_available())
[caps addObject:@"ipa_install"];
if (gClipboardAvailable)
[caps addObject:@"clipboard"];
if (gAppsAvailable)
[caps addObject:@"apps"];
[caps addObject:@"url"];
[caps addObject:@"settings"];
NSMutableDictionary *helloResp = [@{
@"v" : @PROTOCOL_VERSION,
@"t" : @"hello",
@"name" : @"vphoned",
@"caps" : caps,
} mutableCopy];
if (needUpdate)
helloResp[@"need_update"] = @YES;
if (!vp_write_message(fd, helloResp)) {
close(fd);
return NO;
}
NSLog(@"vphoned: client connected (v%d)%s", PROTOCOL_VERSION,
needUpdate ? " [update pending]" : "");
NSDictionary *msg;
while ((msg = vp_read_message(fd)) != nil) {
@autoreleasepool {
NSString *t = msg[@"t"];
NSLog(@"vphoned: recv cmd: %@", t);
if ([t isEqualToString:@"update"]) {
NSUInteger size = [msg[@"size"] unsignedIntegerValue];
id reqId = msg[@"id"];
NSLog(@"vphoned: receiving update (%lu bytes)", (unsigned long)size);
if (size > 0 && size < 10 * 1024 * 1024 && receive_update(fd, size)) {
NSMutableDictionary *r = vp_make_response(@"ok", reqId);
r[@"msg"] = @"updated, restarting";
vp_write_message(fd, r);
should_restart = YES;
break;
} else {
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = @"update failed";
vp_write_message(fd, r);
}
continue;
}
// File operations (need fd for inline binary transfer)
if ([t hasPrefix:@"file_"]) {
NSDictionary *resp = vp_handle_file_command(fd, msg);
if (resp && !vp_write_message(fd, resp))
break;
continue;
}
// Keychain operations
if ([t hasPrefix:@"keychain_"]) {
NSDictionary *resp = vp_handle_keychain_command(msg);
if (resp && !vp_write_message(fd, resp))
break;
continue;
}
// Clipboard operations (need fd for inline binary transfer)
if ([t hasPrefix:@"clipboard_"]) {
NSDictionary *resp = vp_handle_clipboard_command(fd, msg);
if (resp && !vp_write_message(fd, resp))
break;
continue;
}
// App management operations
if ([t hasPrefix:@"app_"]) {
NSDictionary *resp = vp_handle_apps_command(msg);
if (resp && !vp_write_message(fd, resp))
break;
continue;
}
// URL opening
if ([t isEqualToString:@"open_url"]) {
NSDictionary *resp = vp_handle_url_command(msg);
if (resp && !vp_write_message(fd, resp))
break;
continue;
}
// Settings operations
if ([t hasPrefix:@"settings_"]) {
NSDictionary *resp = vp_handle_settings_command(msg);
if (resp && !vp_write_message(fd, resp))
break;
continue;
}
// Accessibility tree
if ([t isEqualToString:@"accessibility_tree"]) {
NSDictionary *resp = vp_handle_accessibility_command(msg);
if (resp && !vp_write_message(fd, resp))
break;
continue;
}
NSDictionary *resp = handle_command(msg);
if (resp && !vp_write_message(fd, resp))
break;
}
}
NSLog(@"vphoned: client disconnected%s",
should_restart ? " (restarting for update)" : "");
close(fd);
}
return should_restart;
}
// MARK: - Main
int main(int argc, char *argv[]) {
@autoreleasepool {
// Bootstrap: if running from install path and a cached update exists, exec it
const char *selfPath = self_executable_path();
if (selfPath && strcmp(selfPath, INSTALL_PATH) == 0 && access(CACHE_PATH, X_OK) == 0) {
NSLog(@"vphoned: found cached binary at %s, exec'ing", CACHE_PATH);
execv(CACHE_PATH, argv);
NSLog(@"vphoned: execv failed: %s — continuing with installed binary", strerror(errno));
unlink(CACHE_PATH);
}
NSLog(@"vphoned: starting (pid=%d, path=%s)", getpid(), selfPath ?: "?");
if (!vp_hid_load()) return 1;
if (!vp_devmode_load()) NSLog(@"vphoned: XPC unavailable, devmode disabled");
vp_location_load();
int sock = socket(AF_VSOCK, SOCK_STREAM, 0);
if (sock < 0) { perror("vphoned: socket(AF_VSOCK)"); return 1; }
int one = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
struct sockaddr_vm addr = {
.svm_len = sizeof(struct sockaddr_vm),
.svm_family = AF_VSOCK,
.svm_port = VPHONED_PORT,
.svm_cid = VMADDR_CID_ANY,
};
if (bind(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("vphoned: bind"); close(sock); return 1;
}
if (listen(sock, 2) < 0) {
perror("vphoned: listen"); close(sock); return 1;
}
NSLog(@"vphoned: listening on vsock port %d", VPHONED_PORT);
for (;;) {
int client = accept(sock, NULL, NULL);
if (client < 0) { perror("vphoned: accept"); sleep(1); continue; }
if (handle_client(client)) {
NSLog(@"vphoned: exiting for update restart");
close(sock);
return 0;
}
}
@autoreleasepool {
// Bootstrap: if running from install path and a cached update exists, exec
// it
const char *selfPath = self_executable_path();
if (selfPath && strcmp(selfPath, INSTALL_PATH) == 0 &&
access(CACHE_PATH, X_OK) == 0) {
NSLog(@"vphoned: found cached binary at %s, exec'ing", CACHE_PATH);
execv(CACHE_PATH, argv);
NSLog(@"vphoned: execv failed: %s — continuing with installed binary",
strerror(errno));
unlink(CACHE_PATH);
}
NSLog(@"vphoned: starting (pid=%d, path=%s)", getpid(), selfPath ?: "?");
if (!vp_hid_load())
return 1;
if (!vp_devmode_load())
NSLog(@"vphoned: XPC unavailable, devmode disabled");
vp_location_load();
gClipboardAvailable = vp_clipboard_load();
gAppsAvailable = vp_apps_load();
int sock = socket(AF_VSOCK, SOCK_STREAM, 0);
if (sock < 0) {
perror("vphoned: socket(AF_VSOCK)");
return 1;
}
int one = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
struct sockaddr_vm addr = {
.svm_len = sizeof(struct sockaddr_vm),
.svm_family = AF_VSOCK,
.svm_port = VPHONED_PORT,
.svm_cid = VMADDR_CID_ANY,
};
if (bind(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("vphoned: bind");
close(sock);
return 1;
}
if (listen(sock, 2) < 0) {
perror("vphoned: listen");
close(sock);
return 1;
}
NSLog(@"vphoned: listening on vsock port %d", VPHONED_PORT);
for (;;) {
int client = accept(sock, NULL, NULL);
if (client < 0) {
perror("vphoned: accept");
sleep(1);
continue;
}
if (handle_client(client)) {
NSLog(@"vphoned: exiting for update restart");
close(sock);
return 0;
}
}
}
}

View File

@@ -0,0 +1,12 @@
/*
* vphoned_accessibility — Accessibility tree query over vsock.
*
* Handles accessibility_tree. Currently a stub — requires XPC research
* to properly query the accessibility tree from a daemon context.
*/
#pragma once
#import <Foundation/Foundation.h>
/// Handle an accessibility_tree command. Returns a response dict.
NSDictionary *vp_handle_accessibility_command(NSDictionary *msg);

View File

@@ -0,0 +1,21 @@
/*
* vphoned_accessibility Accessibility tree query (stub).
*
* TODO: Implement proper accessibility tree retrieval.
* Options under investigation:
* 1. XPC to com.apple.accessibility.AXRuntime
* 2. AXUIElement private API (may not be available on iOS)
* 3. Dylib injection into SpringBoard
* 4. Direct UIAccessibility traversal via task_for_pid
*/
#import "vphoned_accessibility.h"
#import "vphoned_protocol.h"
NSDictionary *vp_handle_accessibility_command(NSDictionary *msg) {
id reqId = msg[@"id"];
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = @"accessibility_tree not yet implemented — requires XPC research";
return r;
}

View File

@@ -0,0 +1,15 @@
/*
* vphoned_apps — App lifecycle management over vsock.
*
* Handles app_list, app_launch, app_terminate, app_foreground using
* private APIs: LSApplicationWorkspace, FBSSystemService, SpringBoardServices.
*/
#pragma once
#import <Foundation/Foundation.h>
/// Load private framework symbols for app management. Returns NO on failure.
BOOL vp_apps_load(void);
/// Handle an app command. Returns a response dict.
NSDictionary *vp_handle_apps_command(NSDictionary *msg);

View 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;
}

View File

@@ -0,0 +1,16 @@
/*
* vphoned_clipboard — Clipboard (pasteboard) read/write over vsock.
*
* Handles clipboard_get and clipboard_set using UIPasteboard via dlopen.
* Supports text and image (PNG) payloads with inline binary transfer.
*/
#pragma once
#import <Foundation/Foundation.h>
/// Load UIKit symbols for clipboard access. Returns NO on failure.
BOOL vp_clipboard_load(void);
/// Handle a clipboard command. May write binary data inline for images.
/// Returns a response dict, or nil if the response was written inline.
NSDictionary *vp_handle_clipboard_command(int fd, NSDictionary *msg);

View File

@@ -0,0 +1,168 @@
/*
* vphoned_clipboard Clipboard read/write via UIPasteboard (dlopen).
*
* UIPasteboard is loaded at runtime since vphoned is a daemon without UIKit.
* Uses objc_msgSend for all UIPasteboard interactions.
*/
#import "vphoned_clipboard.h"
#import "vphoned_protocol.h"
#include <dlfcn.h>
#include <objc/message.h>
#include <unistd.h>
static BOOL gClipboardLoaded = NO;
static Class gPasteboardClass = Nil;
// UIImagePNGRepresentation
static NSData *(*pImagePNGRep)(id) = NULL;
BOOL vp_clipboard_load(void) {
void *h =
dlopen("/System/Library/Frameworks/UIKit.framework/UIKit", RTLD_LAZY);
if (!h) {
NSLog(@"vphoned: dlopen UIKit failed: %s", dlerror());
return NO;
}
gPasteboardClass = NSClassFromString(@"UIPasteboard");
if (!gPasteboardClass) {
NSLog(@"vphoned: UIPasteboard class not found");
return NO;
}
pImagePNGRep = dlsym(h, "UIImagePNGRepresentation");
if (!pImagePNGRep) {
NSLog(@"vphoned: UIImagePNGRepresentation not found (image support "
@"disabled)");
// Non-fatal: text clipboard still works
}
gClipboardLoaded = YES;
NSLog(@"vphoned: clipboard loaded (UIKit)");
return YES;
}
static id get_general_pasteboard(void) {
return ((id (*)(Class, SEL))objc_msgSend)(
gPasteboardClass, sel_registerName("generalPasteboard"));
}
NSDictionary *vp_handle_clipboard_command(int fd, NSDictionary *msg) {
NSString *type = msg[@"t"];
id reqId = msg[@"id"];
if (!gClipboardLoaded) {
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = @"clipboard not available (UIKit not loaded)";
return r;
}
// -- clipboard_get --
if ([type isEqualToString:@"clipboard_get"]) {
id pb = get_general_pasteboard();
if (!pb) {
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = @"failed to get general pasteboard";
return r;
}
NSMutableDictionary *r = vp_make_response(@"clipboard_get", reqId);
// changeCount
NSInteger changeCount = ((NSInteger (*)(id, SEL))objc_msgSend)(
pb, sel_registerName("changeCount"));
r[@"change_count"] = @(changeCount);
// pasteboardTypes
NSArray *types = ((id (*)(id, SEL))objc_msgSend)(
pb, sel_registerName("pasteboardTypes"));
r[@"types"] = types ?: @[];
// string
NSString *str =
((id (*)(id, SEL))objc_msgSend)(pb, sel_registerName("string"));
if (str)
r[@"text"] = str;
// image
id image = ((id (*)(id, SEL))objc_msgSend)(pb, sel_registerName("image"));
NSData *pngData = nil;
if (image && pImagePNGRep) {
pngData = pImagePNGRep(image);
}
if (pngData && pngData.length > 0) {
r[@"has_image"] = @YES;
r[@"image_size"] = @(pngData.length);
// Write JSON header, then binary PNG data
if (!vp_write_message(fd, r))
return nil;
vp_write_fully(fd, pngData.bytes, pngData.length);
return nil; // Already written inline
} else {
r[@"has_image"] = @NO;
return r;
}
}
// -- clipboard_set --
if ([type isEqualToString:@"clipboard_set"]) {
id pb = get_general_pasteboard();
if (!pb) {
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = @"failed to get general pasteboard";
return r;
}
NSString *setType = msg[@"type"];
if ([setType isEqualToString:@"image"]) {
// Image mode: read binary payload
NSUInteger size = [msg[@"size"] unsignedIntegerValue];
if (size == 0 || size > 50 * 1024 * 1024) {
if (size > 0)
vp_drain(fd, size);
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = @"invalid image size";
return r;
}
NSMutableData *imgData = [NSMutableData dataWithLength:size];
if (!vp_read_fully(fd, imgData.mutableBytes, size)) {
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = @"failed to read image data";
return r;
}
// Create UIImage from PNG data and set on pasteboard
Class uiImageClass = NSClassFromString(@"UIImage");
if (uiImageClass) {
id image = ((id (*)(Class, SEL, id))objc_msgSend)(
uiImageClass, sel_registerName("imageWithData:"), imgData);
if (image) {
((void (*)(id, SEL, id))objc_msgSend)(
pb, sel_registerName("setImage:"), image);
}
}
} else {
// Text mode
NSString *text = msg[@"text"];
if (text) {
((void (*)(id, SEL, id))objc_msgSend)(
pb, sel_registerName("setString:"), text);
}
}
NSInteger changeCount = ((NSInteger (*)(id, SEL))objc_msgSend)(
pb, sel_registerName("changeCount"));
NSMutableDictionary *r = vp_make_response(@"clipboard_set", reqId);
r[@"ok"] = @YES;
r[@"change_count"] = @(changeCount);
return r;
}
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] =
[NSString stringWithFormat:@"unknown clipboard command: %@", type];
return r;
}

View File

@@ -0,0 +1,12 @@
/*
* vphoned_settings — System preferences read/write over vsock.
*
* Handles settings_get and settings_set using CFPreferences.
* No extra frameworks needed — CFPreferences is in CoreFoundation.
*/
#pragma once
#import <Foundation/Foundation.h>
/// Handle a settings command. Returns a response dict.
NSDictionary *vp_handle_settings_command(NSDictionary *msg);

View File

@@ -0,0 +1,175 @@
/*
* vphoned_settings System preferences read/write via CFPreferences.
*
* Reads and writes preference domains using CFPreferences API.
* No additional frameworks required.
*/
#import "vphoned_settings.h"
#import "vphoned_protocol.h"
// MARK: - Helpers
/// Map a CFPropertyList value to a JSON-safe representation with type info.
static NSDictionary *serialize_value(id value) {
if (!value || value == (id)kCFNull) {
return @{@"value" : [NSNull null], @"type" : @"null"};
}
if ([value isKindOfClass:[NSNumber class]]) {
// Distinguish boolean from number
if (strcmp([value objCType], @encode(BOOL)) == 0 ||
strcmp([value objCType], @encode(char)) == 0) {
return @{@"value" : value, @"type" : @"boolean"};
}
// Check for float/double
if (strcmp([value objCType], @encode(float)) == 0 ||
strcmp([value objCType], @encode(double)) == 0) {
return @{@"value" : value, @"type" : @"float"};
}
return @{@"value" : value, @"type" : @"integer"};
}
if ([value isKindOfClass:[NSString class]]) {
return @{@"value" : value, @"type" : @"string"};
}
if ([value isKindOfClass:[NSData class]]) {
return @{
@"value" : [(NSData *)value base64EncodedStringWithOptions:0],
@"type" : @"data"
};
}
if ([value isKindOfClass:[NSDate class]]) {
return @{
@"value" : @([(NSDate *)value timeIntervalSince1970]),
@"type" : @"date"
};
}
if ([value isKindOfClass:[NSArray class]] ||
[value isKindOfClass:[NSDictionary class]]) {
// Try JSON serialization
if ([NSJSONSerialization isValidJSONObject:value]) {
return @{@"value" : value, @"type" : @"plist"};
}
return @{@"value" : [value description], @"type" : @"plist"};
}
return @{@"value" : [value description], @"type" : @"unknown"};
}
/// Deserialize a value from the request based on type hint.
static id deserialize_value(id rawValue, NSString *typeHint) {
if (!rawValue || rawValue == (id)[NSNull null])
return nil;
if ([typeHint isEqualToString:@"boolean"]) {
return @([rawValue boolValue]);
}
if ([typeHint isEqualToString:@"integer"]) {
return @([rawValue longLongValue]);
}
if ([typeHint isEqualToString:@"float"]) {
return @([rawValue doubleValue]);
}
if ([typeHint isEqualToString:@"string"]) {
return [rawValue description];
}
if ([typeHint isEqualToString:@"data"]) {
if ([rawValue isKindOfClass:[NSString class]]) {
return [[NSData alloc] initWithBase64EncodedString:rawValue options:0];
}
}
// Default: pass through (JSON types map naturally)
return rawValue;
}
// MARK: - Command Handler
NSDictionary *vp_handle_settings_command(NSDictionary *msg) {
NSString *type = msg[@"t"];
id reqId = msg[@"id"];
// -- settings_get --
if ([type isEqualToString:@"settings_get"]) {
NSString *domain = msg[@"domain"];
NSString *key = msg[@"key"];
if (!domain) {
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = @"missing domain";
return r;
}
if (key && key.length > 0) {
// Single key
CFPropertyListRef value = CFPreferencesCopyAppValue(
(__bridge CFStringRef)key, (__bridge CFStringRef)domain);
NSMutableDictionary *r = vp_make_response(@"settings_get", reqId);
if (value) {
NSDictionary *serialized = serialize_value((__bridge id)value);
r[@"value"] = serialized[@"value"];
r[@"type"] = serialized[@"type"];
CFRelease(value);
} else {
r[@"value"] = [NSNull null];
r[@"type"] = @"null";
}
return r;
} else {
// All keys in domain
CFArrayRef keys = CFPreferencesCopyKeyList((__bridge CFStringRef)domain,
kCFPreferencesCurrentUser,
kCFPreferencesAnyHost);
NSMutableDictionary *r = vp_make_response(@"settings_get", reqId);
if (keys) {
CFDictionaryRef allValues = CFPreferencesCopyMultiple(
keys, (__bridge CFStringRef)domain, kCFPreferencesCurrentUser,
kCFPreferencesAnyHost);
if (allValues) {
// Convert to serializable dict
NSDictionary *dict = (__bridge NSDictionary *)allValues;
NSMutableDictionary *serialized = [NSMutableDictionary dictionary];
for (NSString *k in dict) {
NSDictionary *entry = serialize_value(dict[k]);
serialized[k] = entry;
}
r[@"value"] = serialized;
r[@"type"] = @"dictionary";
CFRelease(allValues);
}
CFRelease(keys);
} else {
r[@"value"] = @{};
r[@"type"] = @"dictionary";
}
return r;
}
}
// -- settings_set --
if ([type isEqualToString:@"settings_set"]) {
NSString *domain = msg[@"domain"];
NSString *key = msg[@"key"];
id rawValue = msg[@"value"];
NSString *typeHint = msg[@"type"];
if (!domain || !key) {
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = @"missing domain or key";
return r;
}
id value = deserialize_value(rawValue, typeHint);
CFPreferencesSetAppValue((__bridge CFStringRef)key,
(__bridge CFPropertyListRef)value,
(__bridge CFStringRef)domain);
CFPreferencesAppSynchronize((__bridge CFStringRef)domain);
NSMutableDictionary *r = vp_make_response(@"settings_set", reqId);
r[@"ok"] = @YES;
return r;
}
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = [NSString stringWithFormat:@"unknown settings command: %@", type];
return r;
}

View File

@@ -0,0 +1,11 @@
/*
* vphoned_url — URL opening over vsock.
*
* Handles open_url using LSApplicationWorkspace.
*/
#pragma once
#import <Foundation/Foundation.h>
/// Handle an open_url command. Returns a response dict.
NSDictionary *vp_handle_url_command(NSDictionary *msg);

View File

@@ -0,0 +1,59 @@
/*
* vphoned_url URL opening via LSApplicationWorkspace.
*
* Uses LSApplicationWorkspace (CoreServices) to open URLs.
* Does not require UIKit works from daemon context.
*/
#import "vphoned_url.h"
#import "vphoned_protocol.h"
#include <objc/message.h>
@interface LSApplicationWorkspace : NSObject
+ (instancetype)defaultWorkspace;
- (BOOL)openURL:(NSURL *)url withOptions:(NSDictionary *)options;
- (BOOL)openSensitiveURL:(NSURL *)url withOptions:(NSDictionary *)options;
@end
NSDictionary *vp_handle_url_command(NSDictionary *msg) {
id reqId = msg[@"id"];
NSString *urlStr = msg[@"url"];
if (!urlStr) {
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = @"missing url";
return r;
}
NSURL *url = [NSURL URLWithString:urlStr];
if (!url) {
NSMutableDictionary *r = vp_make_response(@"err", reqId);
r[@"msg"] = [NSString stringWithFormat:@"invalid url: %@", urlStr];
return r;
}
LSApplicationWorkspace *ws = [LSApplicationWorkspace defaultWorkspace];
BOOL ok = NO;
// Try openURL:withOptions: first
SEL openURLSel = sel_registerName("openURL:withOptions:");
if ([ws respondsToSelector:openURLSel]) {
ok = ((BOOL (*)(id, SEL, id, id))objc_msgSend)(ws, openURLSel, url, nil);
}
if (!ok) {
// Fallback: try openSensitiveURL:withOptions: (requires entitlement)
SEL sensitiveSel = sel_registerName("openSensitiveURL:withOptions:");
if ([ws respondsToSelector:sensitiveSel]) {
ok =
((BOOL (*)(id, SEL, id, id))objc_msgSend)(ws, sensitiveSel, url, nil);
}
}
NSMutableDictionary *r = vp_make_response(@"open_url", reqId);
r[@"ok"] = @(ok);
if (!ok) {
r[@"msg"] = [NSString stringWithFormat:@"failed to open url: %@", urlStr];
}
return r;
}

View File

@@ -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

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

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

View File

@@ -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
}
}

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