diff --git a/Makefile b/Makefile index e2ba536..1d1494a 100644 --- a/Makefile +++ b/Makefile @@ -40,9 +40,6 @@ help: @echo " make vphoned_sign Sign vphoned (requires cfw_input)" @echo " make install Build + copy to ./bin/" @echo " make clean Remove .build/" - @echo " make unlock Cross-compile unlock for arm64-ios" - @echo " make unlock_deploy Build + deploy unlock to VM via SSH" - @echo "" @echo "VM management:" @echo " make vm_new Create VM directory" @echo " make boot Boot VM (GUI)" @@ -86,7 +83,7 @@ setup_libimobiledevice: # Build # ═══════════════════════════════════════════════════════════════════ -.PHONY: build install clean unlock unlock_deploy +.PHONY: build install clean build: $(BINARY) @@ -127,28 +124,10 @@ vphoned_sign: $(SCRIPTS)/vphoned/vphoned cp $(SCRIPTS)/vphoned/vphoned $(VM_DIR)/.vphoned.signed $(VM_DIR)/$(CFW_INPUT)/tools/ldid_macosx_arm64 \ -S$(SCRIPTS)/vphoned/entitlements.plist \ - -M "-K$(VM_DIR)/$(CFW_INPUT)/signcert.p12" \ + -M "-K$(SCRIPTS)/vphoned/signcert.p12" \ $(VM_DIR)/.vphoned.signed @echo " signed → $(VM_DIR)/.vphoned.signed" -unlock: - clang -arch arm64 -target arm64-apple-ios15.0 \ - -o $(VM_DIR)/unlock $(SCRIPTS)/unlock.c - $(VM_DIR)/cfw_input/tools/ldid_macosx_arm64 -S$(SCRIPTS)/unlock.entitlements -M -K$(VM_DIR)/cfw_input/signcert.p12 $(VM_DIR)/unlock - @echo "Built $(VM_DIR)/unlock (arm64-ios)" - -SSHPASS := $(VM_DIR)/cfw_input/tools/sshpass -SSH_PASS := alpine -VM_SSH_PORT := 22222 -VM_SSH_HOST := root@localhost -VM_SSH_OPTS := -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=3 -q - -unlock_deploy: unlock - @echo "[*] Deploying unlock to localhost:/var/root/unlock..." - $(SSHPASS) -p $(SSH_PASS) ssh $(VM_SSH_OPTS) -p $(VM_SSH_PORT) $(VM_SSH_HOST) \ - '/iosbinpack64/bin/cat > /var/root/unlock && /iosbinpack64/bin/chmod +x /var/root/unlock' < $(VM_DIR)/unlock - @echo "[+] Deployed" - # ═══════════════════════════════════════════════════════════════════ # VM management # ═══════════════════════════════════════════════════════════════════ @@ -224,8 +203,8 @@ ramdisk_send: .PHONY: cfw_install cfw_install_jb -cfw_install: unlock +cfw_install: cd $(VM_DIR) && zsh "$(CURDIR)/$(SCRIPTS)/cfw_install.sh" . -cfw_install_jb: unlock +cfw_install_jb: cd $(VM_DIR) && zsh "$(CURDIR)/$(SCRIPTS)/cfw_install_jb.sh" . diff --git a/Package.swift b/Package.swift index 9ab30e8..ebea693 100644 --- a/Package.swift +++ b/Package.swift @@ -22,6 +22,7 @@ let package = Package( linkerSettings: [ .linkedFramework("Virtualization"), .linkedFramework("AppKit"), + .linkedFramework("SwiftUI"), ] ), ] diff --git a/researchs/developer_mode_xpc.md b/researchs/developer_mode_xpc.md new file mode 100644 index 0000000..1a8e17d --- /dev/null +++ b/researchs/developer_mode_xpc.md @@ -0,0 +1,117 @@ +# Developer Mode via AMFI XPC + +How iOS developer mode is enabled programmatically, based on TrollStore's implementation. + +## XPC Service + +**Mach service:** `com.apple.amfi.xpc` + +AMFI (Apple Mobile File Integrity) daemon exposes an XPC endpoint for developer mode control on iOS 16+. + +## Required Entitlement + +``` +com.apple.private.amfi.developer-mode-control = true +``` + +Without this entitlement the XPC connection to amfid is rejected. + +## Message Protocol + +Messages are serialized using private CoreFoundation-XPC bridge functions: + +```objc +extern xpc_object_t _CFXPCCreateXPCMessageWithCFObject(CFTypeRef obj); +extern CFTypeRef _CFXPCCreateCFObjectFromXPCMessage(xpc_object_t obj); +``` + +### Request + +NSDictionary with a single key: + +```objc +@{@"action": @(action)} +``` + +### Actions + +| Action | Value | Behavior | +|--------|-------|---------| +| `kAMFIActionArm` | 0 | Arm developer mode — takes effect on next reboot, user must select "Turn On" | +| `kAMFIActionDisable` | 1 | Disable developer mode immediately | +| `kAMFIActionStatus` | 2 | Query current state | + +### Response + +XPC reply dict contains a `"cfreply"` key holding the CF-serialized response: + +```objc +xpc_object_t cfReply = xpc_dictionary_get_value(reply, "cfreply"); +NSDictionary *dict = _CFXPCCreateCFObjectFromXPCMessage(cfReply); +``` + +Response fields: + +| Key | Type | Description | +|-----|------|-------------| +| `success` | BOOL | Whether the XPC call succeeded | +| `status` | BOOL | Current developer mode state (for Status action) | +| `armed` | BOOL | Whether armed for reboot (for Arm action) | +| `error` | NSString | Error description if success is false | + +## Arming Flow + +1. Query status (`kAMFIActionStatus`) +2. If already enabled, done +3. Send arm (`kAMFIActionArm`) +4. Device must reboot; user selects "Turn On" in the prompt +5. Developer mode is now active + +Arming does **not** enable developer mode immediately. It sets a flag that triggers the enable prompt on the next reboot. Disabling (`kAMFIActionDisable`) takes effect immediately. + +## TrollStore Reference + +Source: `references/TrollStore/RootHelper/devmode.m` + +TrollStore separates privileges: the main app has no AMFI entitlement; all privileged operations go through RootHelper which has `com.apple.private.amfi.developer-mode-control`. + +Key functions: +- `checkDeveloperMode()` — returns current state, YES on iOS <16 (devmode doesn't exist) +- `armDeveloperMode(BOOL *alreadyEnabled)` — check + arm in one call +- `startConnection()` — creates and resumes XPC connection to `com.apple.amfi.xpc` +- `sendXPCRequest()` — CF dict → XPC message → sync reply → CF dict + +## vphoned Implementation + +Added as `devmode` capability in vphoned guest agent: + +### Protocol Messages + +**Status query:** +```json +{"t": "devmode", "action": "status"} +→ {"t": "ok", "enabled": true} +``` + +**Enable (arm):** +```json +{"t": "devmode", "action": "enable"} +→ {"t": "ok", "already_enabled": false, "msg": "developer mode armed, reboot to activate"} +``` + +### Entitlements + +Added to `scripts/vphoned/entitlements.plist`: +```xml +com.apple.private.amfi.developer-mode-control + +``` + +### Host-Side API (VPhoneControl.swift) + +```swift +control.sendDevModeStatus() // query current state +control.sendDevModeEnable() // arm developer mode +``` + +Responses arrive via the existing read loop and are logged to console. diff --git a/scripts/cfw_install.sh b/scripts/cfw_install.sh index e6de242..877e302 100755 --- a/scripts/cfw_install.sh +++ b/scripts/cfw_install.sh @@ -342,19 +342,6 @@ ssh_cmd "/bin/rm -f /mnt1/iosbinpack64.tar" echo " [+] iosbinpack64 installed" -# ═══════════ 4.5 INSTALL VPHOME (unlock tool) ════════════════ -UNLOCK_BIN="$VM_DIR/unlock" -if [[ -f "$UNLOCK_BIN" ]]; then - echo "" - echo "[*] Installing unlock..." - scp_to "$UNLOCK_BIN" "/mnt1/iosbinpack64/bin/unlock" - ssh_cmd "/bin/chmod 0755 /mnt1/iosbinpack64/bin/unlock" - echo " [+] unlock installed to /iosbinpack64/bin/unlock" -else - echo "" - echo "[*] Skipping unlock (not built — run 'make unlock' first)" -fi - # ═══════════ 5/7 PATCH LAUNCHD_CACHE_LOADER ══════════════════ echo "" echo "[5/7] Patching launchd_cache_loader..." diff --git a/scripts/unlock.c b/scripts/unlock.c deleted file mode 100644 index c602a43..0000000 --- a/scripts/unlock.c +++ /dev/null @@ -1,119 +0,0 @@ -// unlock — dispatch Consumer Menu (Home) IOHIDEvent from inside the VM -// Replicates TrollVNC STHIDEventGenerator._sendHIDEvent pattern: -// - dispatch_once client creation -// - dispatch_async on serial queue with setSenderID inside block - -#include -#include -#include -#include -#include -#include -#include - -typedef const void *CFTypeRef; -typedef const void *CFAllocatorRef; - -#define kHIDPage_Consumer 0x0C -#define kHIDUsage_Csmr_Menu 0x40 -#define kIOHIDEventOptionNone 0 - -typedef CFTypeRef (*CreateClient_t)(CFAllocatorRef); -typedef CFTypeRef (*CreateKbEvent_t)(CFAllocatorRef, uint64_t, uint32_t, uint32_t, int, uint32_t); -typedef void (*SetSenderID_t)(CFTypeRef, uint64_t); -typedef void (*DispatchEvent_t)(CFTypeRef, CFTypeRef); -typedef void (*CFRelease_t)(CFTypeRef); -typedef CFTypeRef (*CFRetain_t)(CFTypeRef); - -static CreateClient_t g_createClient; -static CreateKbEvent_t g_createKbEvent; -static SetSenderID_t g_setSenderID; -static DispatchEvent_t g_dispatchEvent; -static CFRelease_t g_cfRelease; -static CFRetain_t g_cfRetain; -static CFAllocatorRef g_alloc; - -static CFTypeRef get_client(void) { - static CFTypeRef client = NULL; - static dispatch_once_t once; - dispatch_once(&once, ^{ - client = g_createClient(g_alloc); - printf("[unlock] client=%p\n", client); - }); - return client; -} - -static void send_hid_event(CFTypeRef event, dispatch_queue_t queue) { - if (!event) return; - CFTypeRef retained = g_cfRetain(event); - dispatch_async(queue, ^{ - g_setSenderID(retained, 0x8000000817319372ULL); - g_dispatchEvent(get_client(), retained); - g_cfRelease(retained); - }); -} - -int main(void) { - void *cf = dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", RTLD_NOW); - if (!cf) { fprintf(stderr, "[unlock] dlopen CF: %s\n", dlerror()); return 1; } - - g_cfRelease = (CFRelease_t)dlsym(cf, "CFRelease"); - g_cfRetain = (CFRetain_t)dlsym(cf, "CFRetain"); - CFAllocatorRef *pAlloc = (CFAllocatorRef *)dlsym(cf, "kCFAllocatorDefault"); - if (!g_cfRelease || !g_cfRetain || !pAlloc) { fprintf(stderr, "[unlock] CF syms\n"); return 1; } - g_alloc = *pAlloc; - - void *iokit = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_NOW); - if (!iokit) { fprintf(stderr, "[unlock] dlopen IOKit: %s\n", dlerror()); return 1; } - - g_createClient = (CreateClient_t)dlsym(iokit, "IOHIDEventSystemClientCreate"); - g_createKbEvent = (CreateKbEvent_t)dlsym(iokit, "IOHIDEventCreateKeyboardEvent"); - g_setSenderID = (SetSenderID_t)dlsym(iokit, "IOHIDEventSetSenderID"); - g_dispatchEvent = (DispatchEvent_t)dlsym(iokit, "IOHIDEventSystemClientDispatchEvent"); - - if (!g_createClient || !g_createKbEvent || !g_setSenderID || !g_dispatchEvent) { - fprintf(stderr, "[unlock] IOKit syms\n"); return 1; - } - - dispatch_queue_attr_t attr = dispatch_queue_attr_make_with_qos_class( - DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INTERACTIVE, 0); - dispatch_queue_t queue = dispatch_queue_create("com.unlock.hid-events", attr); - - printf("[unlock] sending Menu (Home) x2 (1.5s gap)...\n"); - - // First press — wakes screen - CFTypeRef d1 = g_createKbEvent(g_alloc, mach_absolute_time(), - kHIDPage_Consumer, kHIDUsage_Csmr_Menu, 1, kIOHIDEventOptionNone); - send_hid_event(d1, queue); - g_cfRelease(d1); - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 50 * NSEC_PER_MSEC), queue, ^{ - CFTypeRef u1 = g_createKbEvent(g_alloc, mach_absolute_time(), - kHIDPage_Consumer, kHIDUsage_Csmr_Menu, 0, kIOHIDEventOptionNone); - send_hid_event(u1, queue); - g_cfRelease(u1); - - // Second press — unlocks (1.5s delay avoids App Switcher double-tap) - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1500 * NSEC_PER_MSEC), queue, ^{ - CFTypeRef d2 = g_createKbEvent(g_alloc, mach_absolute_time(), - kHIDPage_Consumer, kHIDUsage_Csmr_Menu, 1, kIOHIDEventOptionNone); - send_hid_event(d2, queue); - g_cfRelease(d2); - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 50 * NSEC_PER_MSEC), queue, ^{ - CFTypeRef u2 = g_createKbEvent(g_alloc, mach_absolute_time(), - kHIDPage_Consumer, kHIDUsage_Csmr_Menu, 0, kIOHIDEventOptionNone); - send_hid_event(u2, queue); - g_cfRelease(u2); - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 200 * NSEC_PER_MSEC), queue, ^{ - printf("[unlock] done\n"); - exit(0); - }); - }); - }); - }); - - dispatch_main(); - return 0; -} diff --git a/scripts/unlock.entitlements b/scripts/unlock.entitlements deleted file mode 100644 index d48ff8c..0000000 --- a/scripts/unlock.entitlements +++ /dev/null @@ -1,22 +0,0 @@ - - - - - platform-application - - com.apple.private.security.no-container - - com.apple.private.hid.client.event-dispatch - - com.apple.private.hid.client.event-filter - - com.apple.private.hid.client.event-monitor - - com.apple.private.hid.client.service-protected - - com.apple.private.hid.manager.client - - com.apple.backboard.client - - - diff --git a/scripts/vphoned/entitlements.plist b/scripts/vphoned/entitlements.plist index 9720cf6..f57f16d 100644 --- a/scripts/vphoned/entitlements.plist +++ b/scripts/vphoned/entitlements.plist @@ -63,6 +63,8 @@ com.apple.private.allow-explicit-graphics-priority + com.apple.private.amfi.developer-mode-control + com.apple.private.hid.client.event-dispatch com.apple.private.hid.client.event-filter @@ -515,8 +517,16 @@ com.apple.usernotifications.legacy-extension + com.apple.security.exception.files.absolute-path.read-write + + / + file-read-data + file-read-metadata + + file-write-data + inter-app-audio keychain-access-groups diff --git a/scripts/vphoned/signcert.p12 b/scripts/vphoned/signcert.p12 new file mode 100755 index 0000000..1043a7e Binary files /dev/null and b/scripts/vphoned/signcert.p12 differ diff --git a/scripts/vphoned/vphoned.m b/scripts/vphoned/vphoned.m index bbe505b..985f391 100644 --- a/scripts/vphoned/vphoned.m +++ b/scripts/vphoned/vphoned.m @@ -10,7 +10,8 @@ * code in main() exec's the cached binary. * * Capabilities: - * hid — inject HID events (Home, Power, Lock, Unlock) via IOKit + * hid — inject HID events (Home, Power, Lock, Unlock) via IOKit + * devmode — enable developer mode via AMFI XPC * * Protocol: * Each message: [uint32 big-endian length][UTF-8 JSON payload] @@ -147,6 +148,108 @@ static void press(uint32_t page, uint32_t usage) { CFRelease(up); } +// MARK: - Developer Mode (AMFI XPC) +// +// Talks to com.apple.amfi.xpc to query / arm developer mode. +// Reference: TrollStore RootHelper/devmode.m +// Requires entitlement: com.apple.private.amfi.developer-mode-control + +// XPC functions resolved via dlsym to avoid iOS SDK availability +// guards (xpc_connection_create_mach_service is marked unavailable +// on iOS but works at runtime with the right entitlements). + +typedef void *xpc_conn_t; // opaque, avoids typedef conflict with SDK +typedef void *xpc_obj_t; + +static xpc_conn_t (*pXpcCreateMach)(const char *, dispatch_queue_t, uint64_t); +static void (*pXpcSetHandler)(xpc_conn_t, void (^)(xpc_obj_t)); +static void (*pXpcResume)(xpc_conn_t); +static void (*pXpcCancel)(xpc_conn_t); +static xpc_obj_t (*pXpcSendSync)(xpc_conn_t, xpc_obj_t); +static xpc_obj_t (*pXpcDictGet)(xpc_obj_t, const char *); +static xpc_obj_t (*pCFToXPC)(CFTypeRef); +static CFTypeRef (*pXPCToCF)(xpc_obj_t); + +static BOOL load_xpc(void) { + void *libxpc = dlopen("/usr/lib/system/libxpc.dylib", RTLD_NOW); + if (!libxpc) { NSLog(@"vphoned: dlopen libxpc failed"); return NO; } + + void *libcf = dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", RTLD_NOW); + if (!libcf) { NSLog(@"vphoned: dlopen CoreFoundation failed"); return NO; } + + pXpcCreateMach = dlsym(libxpc, "xpc_connection_create_mach_service"); + pXpcSetHandler = dlsym(libxpc, "xpc_connection_set_event_handler"); + pXpcResume = dlsym(libxpc, "xpc_connection_resume"); + pXpcCancel = dlsym(libxpc, "xpc_connection_cancel"); + pXpcSendSync = dlsym(libxpc, "xpc_connection_send_message_with_reply_sync"); + pXpcDictGet = dlsym(libxpc, "xpc_dictionary_get_value"); + pCFToXPC = dlsym(libcf, "_CFXPCCreateXPCMessageWithCFObject"); + pXPCToCF = dlsym(libcf, "_CFXPCCreateCFObjectFromXPCMessage"); + + if (!pXpcCreateMach || !pXpcSetHandler || !pXpcResume || !pXpcCancel || + !pXpcSendSync || !pXpcDictGet || !pCFToXPC || !pXPCToCF) { + NSLog(@"vphoned: missing XPC/CF symbols"); + return NO; + } + + NSLog(@"vphoned: XPC loaded"); + return YES; +} + +typedef enum { + kAMFIActionArm = 0, // arm developer mode (prompts on next reboot) + kAMFIActionDisable = 1, // disable developer mode immediately + kAMFIActionStatus = 2, // query: {success, status, armed} +} AMFIXPCAction; + +static NSDictionary *amfi_send(AMFIXPCAction action) { + xpc_conn_t conn = pXpcCreateMach("com.apple.amfi.xpc", NULL, 0); + if (!conn) { + NSLog(@"vphoned: amfi xpc connection failed"); + return nil; + } + pXpcSetHandler(conn, ^(xpc_obj_t event) {}); + pXpcResume(conn); + + xpc_obj_t msg = pCFToXPC((__bridge CFDictionaryRef)@{@"action": @(action)}); + xpc_obj_t reply = pXpcSendSync(conn, msg); + pXpcCancel(conn); + if (!reply) { + NSLog(@"vphoned: amfi xpc no reply"); + return nil; + } + + xpc_obj_t cfReply = pXpcDictGet(reply, "cfreply"); + if (!cfReply) { + NSLog(@"vphoned: amfi xpc no cfreply"); + return nil; + } + + NSDictionary *dict = (__bridge_transfer NSDictionary *)pXPCToCF(cfReply); + NSLog(@"vphoned: amfi reply: %@", dict); + return dict; +} + +static BOOL devmode_status(void) { + NSDictionary *reply = amfi_send(kAMFIActionStatus); + if (!reply) return NO; + NSNumber *success = reply[@"success"]; + if (!success || ![success boolValue]) return NO; + NSNumber *status = reply[@"status"]; + return [status boolValue]; +} + +static BOOL devmode_arm(BOOL *alreadyEnabled) { + BOOL enabled = devmode_status(); + if (alreadyEnabled) *alreadyEnabled = enabled; + if (enabled) return YES; + + NSDictionary *reply = amfi_send(kAMFIActionArm); + if (!reply) return NO; + NSNumber *success = reply[@"success"]; + return success && [success boolValue]; +} + // MARK: - Protocol Framing static BOOL read_fully(int fd, void *buf, size_t count) { @@ -159,6 +262,16 @@ static BOOL read_fully(int fd, void *buf, size_t count) { return YES; } +static BOOL write_fully(int fd, const void *buf, size_t count) { + size_t offset = 0; + while (offset < count) { + ssize_t n = write(fd, (const uint8_t *)buf + offset, count - offset); + if (n <= 0) return NO; + offset += n; + } + return YES; +} + static NSDictionary *read_message(int fd) { uint32_t header = 0; if (!read_fully(fd, &header, 4)) return nil; @@ -194,6 +307,259 @@ static NSMutableDictionary *make_response(NSString *type, id reqId) { return r; } +// MARK: - File Operations +// +// Handle file_list, file_get, file_put, file_mkdir, file_delete, file_rename. +// file_get and file_put perform inline binary I/O on the socket, so they +// need the fd directly (can't use the simple return-dict pattern). + +/// Handle a file command. Returns a response dict, or nil if the response +/// was already written inline (file_get with streaming data). +static NSDictionary *handle_file_command(int fd, NSDictionary *msg) { + NSString *type = msg[@"t"]; + id reqId = msg[@"id"]; + + // -- file_list: list directory contents -- + if ([type isEqualToString:@"file_list"]) { + NSString *path = msg[@"path"]; + if (!path) { + NSMutableDictionary *r = make_response(@"err", reqId); + r[@"msg"] = @"missing path"; + return r; + } + + NSFileManager *fm = [NSFileManager defaultManager]; + NSError *err = nil; + NSArray *contents = [fm contentsOfDirectoryAtPath:path error:&err]; + if (!contents) { + NSMutableDictionary *r = make_response(@"err", reqId); + r[@"msg"] = err.localizedDescription ?: @"list failed"; + return r; + } + + NSMutableArray *entries = [NSMutableArray arrayWithCapacity:contents.count]; + for (NSString *name in contents) { + NSString *full = [path stringByAppendingPathComponent:name]; + NSDictionary *attrs = [fm attributesOfItemAtPath:full error:nil]; + if (!attrs) continue; + + NSString *fileType = attrs[NSFileType]; + NSString *typeStr = @"file"; + if ([fileType isEqualToString:NSFileTypeDirectory]) typeStr = @"dir"; + else if ([fileType isEqualToString:NSFileTypeSymbolicLink]) typeStr = @"link"; + + NSNumber *size = attrs[NSFileSize] ?: @0; + NSDate *mtime = attrs[NSFileModificationDate]; + NSNumber *posixPerms = attrs[NSFilePosixPermissions]; + + [entries addObject:@{ + @"name": name, + @"type": typeStr, + @"size": size, + @"perm": [NSString stringWithFormat:@"%lo", [posixPerms unsignedLongValue]], + @"mtime": @(mtime ? [mtime timeIntervalSince1970] : 0), + }]; + } + + NSMutableDictionary *r = make_response(@"ok", reqId); + r[@"entries"] = entries; + return r; + } + + // -- file_get: download file from guest to host -- + if ([type isEqualToString:@"file_get"]) { + NSString *path = msg[@"path"]; + if (!path) { + NSMutableDictionary *r = make_response(@"err", reqId); + r[@"msg"] = @"missing path"; + return r; + } + + struct stat st; + if (stat([path fileSystemRepresentation], &st) != 0) { + NSMutableDictionary *r = make_response(@"err", reqId); + r[@"msg"] = [NSString stringWithFormat:@"stat failed: %s", strerror(errno)]; + return r; + } + if (!S_ISREG(st.st_mode)) { + NSMutableDictionary *r = make_response(@"err", reqId); + r[@"msg"] = @"not a regular file"; + return r; + } + + int fileFd = open([path fileSystemRepresentation], O_RDONLY); + if (fileFd < 0) { + NSMutableDictionary *r = make_response(@"err", reqId); + r[@"msg"] = [NSString stringWithFormat:@"open failed: %s", strerror(errno)]; + return r; + } + + // Send header with file size + NSMutableDictionary *header = make_response(@"file_data", reqId); + header[@"size"] = @((unsigned long long)st.st_size); + if (!write_message(fd, header)) { + close(fileFd); + return nil; + } + + // Stream file data in chunks + uint8_t buf[32768]; + ssize_t n; + while ((n = read(fileFd, buf, sizeof(buf))) > 0) { + if (!write_fully(fd, buf, (size_t)n)) { + NSLog(@"vphoned: file_get write failed for %@", path); + close(fileFd); + return nil; + } + } + close(fileFd); + return nil; // Response already written inline + } + + // -- file_put: upload file from host to guest -- + if ([type isEqualToString:@"file_put"]) { + NSString *path = msg[@"path"]; + NSUInteger size = [msg[@"size"] unsignedIntegerValue]; + NSString *perm = msg[@"perm"]; + + if (!path) { + // Must still drain the raw bytes to keep protocol in sync + if (size > 0) { + uint8_t drain[32768]; + NSUInteger remaining = size; + while (remaining > 0) { + size_t chunk = remaining < sizeof(drain) ? remaining : sizeof(drain); + if (!read_fully(fd, drain, chunk)) break; + remaining -= chunk; + } + } + NSMutableDictionary *r = make_response(@"err", reqId); + r[@"msg"] = @"missing path"; + return r; + } + + // Create parent directories if needed + NSString *parent = [path stringByDeletingLastPathComponent]; + [[NSFileManager defaultManager] createDirectoryAtPath:parent + withIntermediateDirectories:YES + attributes:nil + error:nil]; + + // Write to temp file, then rename (atomic, same pattern as receive_update) + char tmp_path[PATH_MAX]; + snprintf(tmp_path, sizeof(tmp_path), "%s.XXXXXX", [path fileSystemRepresentation]); + int tmp_fd = mkstemp(tmp_path); + if (tmp_fd < 0) { + // Drain bytes + uint8_t drain[32768]; + NSUInteger remaining = size; + while (remaining > 0) { + size_t chunk = remaining < sizeof(drain) ? remaining : sizeof(drain); + if (!read_fully(fd, drain, chunk)) break; + remaining -= chunk; + } + NSMutableDictionary *r = make_response(@"err", reqId); + r[@"msg"] = [NSString stringWithFormat:@"mkstemp failed: %s", strerror(errno)]; + return r; + } + + uint8_t buf[32768]; + NSUInteger remaining = size; + BOOL ok = YES; + while (remaining > 0) { + size_t chunk = remaining < sizeof(buf) ? remaining : sizeof(buf); + if (!read_fully(fd, buf, chunk)) { ok = NO; break; } + if (write(tmp_fd, buf, chunk) != (ssize_t)chunk) { ok = NO; break; } + remaining -= chunk; + } + close(tmp_fd); + + if (!ok) { + unlink(tmp_path); + NSMutableDictionary *r = make_response(@"err", reqId); + r[@"msg"] = @"file transfer failed"; + return r; + } + + // Set permissions + if (perm) { + unsigned long mode = strtoul([perm UTF8String], NULL, 8); + chmod(tmp_path, (mode_t)mode); + } else { + chmod(tmp_path, 0644); + } + + if (rename(tmp_path, [path fileSystemRepresentation]) != 0) { + unlink(tmp_path); + NSMutableDictionary *r = make_response(@"err", reqId); + r[@"msg"] = [NSString stringWithFormat:@"rename failed: %s", strerror(errno)]; + return r; + } + + NSLog(@"vphoned: file_put %@ (%lu bytes)", path, (unsigned long)size); + return make_response(@"ok", reqId); + } + + // -- file_mkdir -- + if ([type isEqualToString:@"file_mkdir"]) { + NSString *path = msg[@"path"]; + if (!path) { + NSMutableDictionary *r = make_response(@"err", reqId); + r[@"msg"] = @"missing path"; + return r; + } + NSError *err = nil; + if (![[NSFileManager defaultManager] createDirectoryAtPath:path + withIntermediateDirectories:YES + attributes:nil + error:&err]) { + NSMutableDictionary *r = make_response(@"err", reqId); + r[@"msg"] = err.localizedDescription ?: @"mkdir failed"; + return r; + } + return make_response(@"ok", reqId); + } + + // -- file_delete -- + if ([type isEqualToString:@"file_delete"]) { + NSString *path = msg[@"path"]; + if (!path) { + NSMutableDictionary *r = make_response(@"err", reqId); + r[@"msg"] = @"missing path"; + return r; + } + NSError *err = nil; + if (![[NSFileManager defaultManager] removeItemAtPath:path error:&err]) { + NSMutableDictionary *r = make_response(@"err", reqId); + r[@"msg"] = err.localizedDescription ?: @"delete failed"; + return r; + } + return make_response(@"ok", reqId); + } + + // -- file_rename -- + if ([type isEqualToString:@"file_rename"]) { + NSString *from = msg[@"from"]; + NSString *to = msg[@"to"]; + if (!from || !to) { + NSMutableDictionary *r = make_response(@"err", reqId); + r[@"msg"] = @"missing from/to"; + return r; + } + NSError *err = nil; + if (![[NSFileManager defaultManager] moveItemAtPath:from toPath:to error:&err]) { + NSMutableDictionary *r = make_response(@"err", reqId); + r[@"msg"] = err.localizedDescription ?: @"rename failed"; + return r; + } + return make_response(@"ok", reqId); + } + + NSMutableDictionary *r = make_response(@"err", reqId); + r[@"msg"] = [NSString stringWithFormat:@"unknown file command: %@", type]; + return r; +} + // MARK: - Command Dispatch static NSDictionary *handle_command(NSDictionary *msg) { @@ -203,10 +569,51 @@ static NSDictionary *handle_command(NSDictionary *msg) { if ([type isEqualToString:@"hid"]) { uint32_t page = [msg[@"page"] unsignedIntValue]; uint32_t usage = [msg[@"usage"] unsignedIntValue]; - press(page, usage); + NSNumber *downVal = msg[@"down"]; + if (downVal != nil) { + // Single down or up event (for modifier combos) + IOHIDEventRef ev = pKeyboard(kCFAllocatorDefault, mach_absolute_time(), + page, usage, [downVal boolValue] ? 1 : 0, 0); + if (ev) { send_hid_event(ev); CFRelease(ev); } + } else { + // Full press (down + 100ms + up) + press(page, usage); + } return make_response(@"ok", reqId); } + if ([type isEqualToString:@"devmode"]) { + if (!pXpcCreateMach) { + NSMutableDictionary *r = make_response(@"err", reqId); + r[@"msg"] = @"XPC not available"; + return r; + } + NSString *action = msg[@"action"]; + if ([action isEqualToString:@"status"]) { + BOOL enabled = devmode_status(); + NSMutableDictionary *r = make_response(@"ok", reqId); + r[@"enabled"] = @(enabled); + return r; + } + if ([action isEqualToString:@"enable"]) { + BOOL alreadyEnabled = NO; + BOOL ok = devmode_arm(&alreadyEnabled); + NSMutableDictionary *r = 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 = make_response(@"err", reqId); + r[@"msg"] = [NSString stringWithFormat:@"unknown devmode action: %@", action]; + return r; + } + if ([type isEqualToString:@"ping"]) { return make_response(@"pong", reqId); } @@ -307,7 +714,7 @@ static BOOL handle_client(int fd) { @"v": @PROTOCOL_VERSION, @"t": @"hello", @"name": @"vphoned", - @"caps": @[@"hid"], + @"caps": @[@"hid", @"devmode", @"file"], } mutableCopy]; if (needUpdate) helloResp[@"need_update"] = @YES; @@ -338,6 +745,13 @@ static BOOL handle_client(int fd) { continue; } + // File operations (need fd for inline binary transfer) + if ([t hasPrefix:@"file_"]) { + NSDictionary *resp = handle_file_command(fd, msg); + if (resp && !write_message(fd, resp)) break; + continue; + } + NSDictionary *resp = handle_command(msg); if (resp && !write_message(fd, resp)) break; } @@ -365,6 +779,7 @@ int main(int argc, char *argv[]) { NSLog(@"vphoned: starting (pid=%d, path=%s)", getpid(), selfPath ?: "?"); if (!load_iokit()) return 1; + if (!load_xpc()) NSLog(@"vphoned: XPC unavailable, devmode disabled"); int sock = socket(AF_VSOCK, SOCK_STREAM, 0); if (sock < 0) { perror("vphoned: socket(AF_VSOCK)"); return 1; } diff --git a/sources/vphone-cli/VPhoneAppDelegate.swift b/sources/vphone-cli/VPhoneAppDelegate.swift index 6ce5b92..a1a6fc6 100644 --- a/sources/vphone-cli/VPhoneAppDelegate.swift +++ b/sources/vphone-cli/VPhoneAppDelegate.swift @@ -8,6 +8,7 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { private var control: VPhoneControl? private var windowController: VPhoneWindowController? private var menuController: VPhoneMenuController? + private var fileWindowController: VPhoneFileWindowController? private var sigintSource: DispatchSourceSignal? init(cli: VPhoneCLI) { @@ -57,7 +58,9 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { print("MachID: \(cli.machineId)") print("CPU : \(cli.cpu)") print("Memory: \(cli.memory) MB") - print("Screen: \(cli.screenWidth)x\(cli.screenHeight) @ \(cli.screenPpi) PPI (scale \(cli.screenScale)x)") + print( + "Screen: \(cli.screenWidth)x\(cli.screenHeight) @ \(cli.screenPpi) PPI (scale \(cli.screenScale)x)" + ) print("SEP : enabled") print(" storage: \(cli.sepStorage)") print(" rom : \(cli.sepRom)") @@ -84,13 +87,15 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { try await vm.start(forceDFU: cli.dfu) let control = VPhoneControl() - let vphonedURL = URL(fileURLWithPath: cli.vphonedBin) - if FileManager.default.fileExists(atPath: vphonedURL.path) { - control.guestBinaryURL = vphonedURL - } self.control = control - if let device = vm.virtualMachine.socketDevices.first as? VZVirtioSocketDevice { - control.connect(device: device) + if !cli.dfu { + let vphonedURL = URL(fileURLWithPath: cli.vphonedBin) + if FileManager.default.fileExists(atPath: vphonedURL.path) { + control.guestBinaryURL = vphonedURL + } + if let device = vm.virtualMachine.socketDevices.first as? VZVirtioSocketDevice { + control.connect(device: device) + } } if !cli.noGraphics { @@ -101,14 +106,20 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { screenWidth: cli.screenWidth, screenHeight: cli.screenHeight, screenScale: cli.screenScale, - keyHelper: keyHelper + keyHelper: keyHelper, + control: control ) windowController = wc - menuController = VPhoneMenuController(keyHelper: keyHelper) - if !cli.dfu { - keyHelper.autoUnlock(delay: 8) + let fileWC = VPhoneFileWindowController() + fileWindowController = fileWC + + let mc = VPhoneMenuController(keyHelper: keyHelper, control: control) + mc.onFilesPressed = { [weak fileWC, weak control] in + guard let fileWC, let control else { return } + fileWC.showWindow(control: control) } + menuController = mc } } diff --git a/sources/vphone-cli/VPhoneControl.swift b/sources/vphone-cli/VPhoneControl.swift index d2ed974..40fbf7a 100644 --- a/sources/vphone-cli/VPhoneControl.swift +++ b/sources/vphone-cli/VPhoneControl.swift @@ -30,18 +30,69 @@ class VPhoneControl { private var guestBinaryHash: String? private var nextRequestId: UInt64 = 0 + // MARK: - Pending Requests + + /// Callback for a pending request. Called on the read-loop queue. + private struct PendingRequest: @unchecked Sendable { + let handler: (Result<([String: Any], Data?), any Error>) -> Void + } + + private let pendingLock = NSLock() + private nonisolated(unsafe) var pendingRequests: [String: PendingRequest] = [:] + + private nonisolated func addPending( + id: String, handler: @escaping (Result<([String: Any], Data?), any Error>) -> Void + ) { + pendingLock.lock() + pendingRequests[id] = PendingRequest(handler: handler) + pendingLock.unlock() + } + + private nonisolated func removePending(id: String) -> PendingRequest? { + pendingLock.lock() + defer { pendingLock.unlock() } + return pendingRequests.removeValue(forKey: id) + } + + private nonisolated func failAllPending() { + pendingLock.lock() + let pending = pendingRequests + pendingRequests.removeAll() + pendingLock.unlock() + for (_, req) in pending { + req.handler(.failure(ControlError.notConnected)) + } + } + + enum ControlError: Error, CustomStringConvertible { + case notConnected + case protocolError(String) + case guestError(String) + + var description: String { + switch self { + case .notConnected: "not connected to vphoned" + case let .protocolError(msg): "protocol error: \(msg)" + case let .guestError(msg): msg + } + } + } + // MARK: - Guest Binary Hash private func loadGuestBinary() { guard let url = guestBinaryURL, - let data = try? Data(contentsOf: url) else { + let data = try? Data(contentsOf: url) + else { guestBinaryData = nil guestBinaryHash = nil return } guestBinaryData = data guestBinaryHash = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() - print("[control] vphoned binary: \(url.lastPathComponent) (\(data.count) bytes, \(guestBinaryHash!.prefix(12))...)") + print( + "[control] vphoned binary: \(url.lastPathComponent) (\(data.count) bytes, \(guestBinaryHash!.prefix(12))...)" + ) } // MARK: - Connect @@ -54,13 +105,14 @@ class VPhoneControl { private func attemptConnect() { guard let device else { return } - device.connect(toPort: Self.vsockPort) { [weak self] (result: Result) in + device.connect(toPort: Self.vsockPort) { + [weak self] (result: Result) in Task { @MainActor in switch result { - case .success(let conn): + case let .success(conn): self?.connection = conn self?.performHandshake(fd: conn.fileDescriptor) - case .failure(let error): + case let .failure(error): print("[control] vsock: \(error.localizedDescription), retrying...") DispatchQueue.main.asyncAfter(deadline: .now() + 3) { self?.attemptConnect() @@ -101,7 +153,9 @@ class VPhoneControl { Task { @MainActor in guard let self else { return } guard type == "hello", version == Self.protocolVersion else { - print("[control] handshake: version mismatch (guest v\(version), host v\(Self.protocolVersion))") + print( + "[control] handshake: version mismatch (guest v\(version), host v\(Self.protocolVersion))" + ) self.disconnect() return } @@ -130,7 +184,10 @@ class VPhoneControl { print("[control] pushing update (\(data.count) bytes)...") nextRequestId += 1 - let header: [String: Any] = ["v": Self.protocolVersion, "t": "update", "id": String(nextRequestId, radix: 16), "size": data.count] + let header: [String: Any] = [ + "v": Self.protocolVersion, "t": "update", "id": String(nextRequestId, radix: 16), + "size": data.count, + ] guard writeMessage(fd: fd, dict: header) else { print("[control] update: failed to send header") disconnect() @@ -153,27 +210,176 @@ class VPhoneControl { // MARK: - Send Commands func sendHIDPress(page: UInt32, usage: UInt32) { + sendHID(page: page, usage: usage, down: nil) + } + + func sendHIDDown(page: UInt32, usage: UInt32) { + sendHID(page: page, usage: usage, down: true) + } + + func sendHIDUp(page: UInt32, usage: UInt32) { + sendHID(page: page, usage: usage, down: false) + } + + private func sendHID(page: UInt32, usage: UInt32, down: Bool?) { nextRequestId += 1 - let msg: [String: Any] = [ + var msg: [String: Any] = [ "v": Self.protocolVersion, "t": "hid", "id": String(nextRequestId, radix: 16), "page": page, "usage": usage, ] + if let down { msg["down"] = down } guard let fd = connection?.fileDescriptor, writeMessage(fd: fd, dict: msg) else { print("[control] send failed (not connected)") return } - print("[control] hid page=0x\(String(page, radix: 16)) usage=0x\(String(usage, radix: 16))") + let suffix = down.map { $0 ? " down" : " up" } ?? "" + print( + "[control] hid page=0x\(String(page, radix: 16)) usage=0x\(String(usage, radix: 16))\(suffix)" + ) + } + + // MARK: - Developer Mode + + func sendDevModeStatus() { + nextRequestId += 1 + let msg: [String: Any] = [ + "v": Self.protocolVersion, + "t": "devmode", + "id": String(nextRequestId, radix: 16), + "action": "status", + ] + guard let fd = connection?.fileDescriptor, writeMessage(fd: fd, dict: msg) else { + print("[control] send failed (not connected)") + return + } + print("[control] devmode status query sent") + } + + func sendDevModeEnable() { + nextRequestId += 1 + let msg: [String: Any] = [ + "v": Self.protocolVersion, + "t": "devmode", + "id": String(nextRequestId, radix: 16), + "action": "enable", + ] + guard let fd = connection?.fileDescriptor, writeMessage(fd: fd, dict: msg) else { + print("[control] send failed (not connected)") + return + } + print("[control] devmode enable sent") } func sendPing() { nextRequestId += 1 - let msg: [String: Any] = ["v": Self.protocolVersion, "t": "ping", "id": String(nextRequestId, radix: 16)] + let msg: [String: Any] = [ + "v": Self.protocolVersion, "t": "ping", "id": String(nextRequestId, radix: 16), + ] guard let fd = connection?.fileDescriptor, writeMessage(fd: fd, dict: msg) else { return } } + // MARK: - Async Request-Response + + /// Send a request and await the response. Returns the response dict and optional raw data. + func sendRequest(_ dict: [String: Any]) async throws -> ([String: Any], Data?) { + guard let fd = connection?.fileDescriptor else { + throw ControlError.notConnected + } + + nextRequestId += 1 + let reqId = String(nextRequestId, radix: 16) + var msg = dict + msg["v"] = Self.protocolVersion + msg["id"] = reqId + + return try await withCheckedThrowingContinuation { continuation in + addPending(id: reqId) { result in + nonisolated(unsafe) let r = result + continuation.resume(with: r) + } + guard writeMessage(fd: fd, dict: msg) else { + _ = removePending(id: reqId) + continuation.resume(throwing: ControlError.notConnected) + return + } + } + } + + // MARK: - File Operations + + func listFiles(path: String) async throws -> [[String: Any]] { + let (resp, _) = try await sendRequest(["t": "file_list", "path": path]) + guard let entries = resp["entries"] as? [[String: Any]] else { + throw ControlError.protocolError("missing entries in response") + } + return entries + } + + func downloadFile(path: String) async throws -> Data { + let (_, data) = try await sendRequest(["t": "file_get", "path": path]) + guard let data else { + throw ControlError.protocolError("no file data received") + } + return data + } + + func uploadFile(path: String, data: Data, permissions: String = "644") async throws { + guard let fd = connection?.fileDescriptor else { + throw ControlError.notConnected + } + + nextRequestId += 1 + let reqId = String(nextRequestId, radix: 16) + let header: [String: Any] = [ + "v": Self.protocolVersion, + "t": "file_put", + "id": reqId, + "path": path, + "size": data.count, + "perm": permissions, + ] + + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + addPending(id: reqId) { result in + switch result { + case .success: continuation.resume() + case let .failure(error): continuation.resume(throwing: error) + } + } + + // Write header + raw data atomically (same pattern as pushUpdate) + guard writeMessage(fd: fd, dict: header) else { + _ = removePending(id: reqId) + continuation.resume(throwing: ControlError.notConnected) + return + } + let ok = data.withUnsafeBytes { buf in + Self.writeFully(fd: fd, buf: buf.baseAddress!, count: data.count) + } + guard ok else { + _ = removePending(id: reqId) + continuation.resume(throwing: ControlError.protocolError("failed to write file data")) + return + } + } + } + + func createDirectory(path: String) async throws { + _ = try await sendRequest(["t": "file_mkdir", "path": path]) + } + + func deleteFile(path: String) async throws { + _ = try await sendRequest(["t": "file_delete", "path": path]) + } + + func renameFile(from: String, to: String) async throws { + _ = try await sendRequest(["t": "file_rename", "from": from, "to": to]) + } + // MARK: - Disconnect & Reconnect private func disconnect() { @@ -183,6 +389,9 @@ class VPhoneControl { guestName = "" guestCaps = [] + // Fail all pending requests + failAllPending() + if wasConnected, device != nil { print("[control] reconnecting in 3s...") DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in @@ -197,7 +406,43 @@ class VPhoneControl { private func startReadLoop(fd: Int32) { DispatchQueue.global(qos: .utility).async { [weak self] in while let msg = Self.readMessage(fd: fd) { + guard let self else { break } let type = msg["t"] as? String ?? "" + let reqId = msg["id"] as? String + + // Check for pending request callback + if let reqId, let pending = removePending(id: reqId) { + if type == "err" { + let detail = msg["msg"] as? String ?? "unknown error" + pending.handler(.failure(ControlError.guestError(detail))) + continue + } + + // For file_data, read inline binary payload + if type == "file_data" { + let size = msg["size"] as? Int ?? 0 + if size > 0 { + let buf = UnsafeMutablePointer.allocate(capacity: size) + if Self.readFully(fd: fd, buf: buf, count: size) { + let data = Data(bytes: buf, count: size) + buf.deallocate() + pending.handler(.success((msg, data))) + } else { + buf.deallocate() + pending.handler(.failure(ControlError.protocolError("failed to read file data"))) + } + } else { + pending.handler(.success((msg, Data()))) + } + continue + } + + // Normal response (ok, pong, etc.) + pending.handler(.success((msg, nil))) + continue + } + + // No pending request — handle as before (fire-and-forget) switch type { case "ok": let detail = msg["msg"] as? String ?? "" @@ -234,7 +479,7 @@ class VPhoneControl { } } - nonisolated private static func readMessage(fd: Int32) -> [String: Any]? { + private nonisolated static func readMessage(fd: Int32) -> [String: Any]? { var header: UInt32 = 0 let hRead = withUnsafeMutableBytes(of: &header) { buf in readFully(fd: fd, buf: buf.baseAddress!, count: 4) @@ -252,7 +497,9 @@ class VPhoneControl { return try? JSONSerialization.jsonObject(with: data) as? [String: Any] } - nonisolated private static func readFully(fd: Int32, buf: UnsafeMutableRawPointer, count: Int) -> Bool { + private nonisolated static func readFully(fd: Int32, buf: UnsafeMutableRawPointer, count: Int) + -> Bool + { var offset = 0 while offset < count { let n = Darwin.read(fd, buf + offset, count - offset) @@ -262,7 +509,7 @@ class VPhoneControl { return true } - nonisolated private static func writeFully(fd: Int32, buf: UnsafeRawPointer, count: Int) -> Bool { + private nonisolated static func writeFully(fd: Int32, buf: UnsafeRawPointer, count: Int) -> Bool { var offset = 0 while offset < count { let n = Darwin.write(fd, buf + offset, count - offset) diff --git a/sources/vphone-cli/VPhoneFileBrowserModel.swift b/sources/vphone-cli/VPhoneFileBrowserModel.swift new file mode 100644 index 0000000..7c30d0f --- /dev/null +++ b/sources/vphone-cli/VPhoneFileBrowserModel.swift @@ -0,0 +1,227 @@ +import Foundation +import Observation + +@Observable +@MainActor +class VPhoneFileBrowserModel { + let control: VPhoneControl + + var currentPath = "/var/mobile" + var files: [VPhoneRemoteFile] = [] + var isLoading = false + var error: String? + var searchText = "" + var selection = Set() + var sortOrder = [KeyPathComparator(\VPhoneRemoteFile.name)] + + // Transfer progress + var transferName: String? + var transferCurrent: Int64 = 0 + var transferTotal: Int64 = 0 + var isTransferring: Bool { + transferName != nil + } + + /// Navigation stack + private var pathHistory: [String] = [] + + init(control: VPhoneControl) { + self.control = control + } + + // MARK: - Computed + + var breadcrumbs: [(name: String, path: String)] { + var result = [("/", "/")] + let components = currentPath.split(separator: "/", omittingEmptySubsequences: true) + var running = "" + for c in components { + running += "/\(c)" + result.append((String(c), running)) + } + return result + } + + var filteredFiles: [VPhoneRemoteFile] { + let list: [VPhoneRemoteFile] + if searchText.isEmpty { + list = files + } else { + let query = searchText.lowercased() + list = files.filter { $0.name.lowercased().contains(query) } + } + return list.sorted(using: sortOrder) + } + + var statusText: String { + let count = filteredFiles.count + let suffix = count == 1 ? "item" : "items" + if !searchText.isEmpty { + return "\(count) \(suffix) (filtered)" + } + return "\(count) \(suffix)" + } + + // MARK: - Navigation + + func navigate(to path: String) { + pathHistory.append(currentPath) + currentPath = path + selection.removeAll() + Task { await refresh() } + } + + func goBack() { + guard let prev = pathHistory.popLast() else { return } + currentPath = prev + selection.removeAll() + Task { await refresh() } + } + + func goToBreadcrumb(_ path: String) { + if path == currentPath { return } + pathHistory.append(currentPath) + currentPath = path + selection.removeAll() + Task { await refresh() } + } + + var canGoBack: Bool { + !pathHistory.isEmpty + } + + func openItem(_ file: VPhoneRemoteFile) { + if file.isDirectory { + navigate(to: file.path) + } + } + + // MARK: - Refresh + + func refresh() async { + isLoading = true + error = nil + do { + let entries = try await control.listFiles(path: currentPath) + files = entries.compactMap { VPhoneRemoteFile(dir: currentPath, entry: $0) } + } catch { + self.error = "\(error)" + files = [] + } + isLoading = false + } + + // MARK: - File Operations + + func downloadSelected(to directory: URL) async { + let selected = files.filter { selection.contains($0.id) } + for file in selected { + if file.isDirectory { + await downloadDirectory(remotePath: file.path, name: file.name, to: directory) + } else { + await downloadFile(remotePath: file.path, name: file.name, size: file.size, to: directory) + } + if error != nil { break } + } + transferName = nil + } + + private func downloadFile(remotePath: String, name: String, size: UInt64, to directory: URL) async { + transferName = name + transferTotal = Int64(size) + transferCurrent = 0 + do { + let data = try await control.downloadFile(path: remotePath) + transferCurrent = Int64(data.count) + let dest = directory.appendingPathComponent(name) + try data.write(to: dest) + print("[files] downloaded \(remotePath) (\(data.count) bytes)") + } catch { + self.error = "Download failed: \(error)" + } + } + + private func downloadDirectory(remotePath: String, name: String, to localParent: URL) async { + let localDir = localParent.appendingPathComponent(name) + do { + try FileManager.default.createDirectory(at: localDir, withIntermediateDirectories: true) + } catch { + self.error = "Create directory failed: \(error)" + return + } + + let entries: [[String: Any]] + do { + entries = try await control.listFiles(path: remotePath) + } catch { + self.error = "List directory failed: \(error)" + return + } + + let children = entries.compactMap { VPhoneRemoteFile(dir: remotePath, entry: $0) } + for child in children { + if child.isDirectory { + await downloadDirectory(remotePath: child.path, name: child.name, to: localDir) + } else { + await downloadFile( + remotePath: child.path, name: child.name, size: child.size, to: localDir + ) + } + if error != nil { return } + } + } + + func uploadFiles(urls: [URL]) async { + for url in urls { + guard let data = try? Data(contentsOf: url) else { continue } + let name = url.lastPathComponent + let dest = (currentPath as NSString).appendingPathComponent(name) + transferName = name + transferTotal = Int64(data.count) + transferCurrent = 0 + do { + try await control.uploadFile(path: dest, data: data) + transferCurrent = Int64(data.count) + print("[files] uploaded \(name) (\(data.count) bytes)") + } catch { + self.error = "Upload failed: \(error)" + } + } + transferName = nil + await refresh() + } + + func createNewFolder(name: String) async { + let path = (currentPath as NSString).appendingPathComponent(name) + do { + try await control.createDirectory(path: path) + await refresh() + } catch { + self.error = "Create folder failed: \(error)" + } + } + + func deleteSelected() async { + let selected = files.filter { selection.contains($0.id) } + for file in selected { + do { + try await control.deleteFile(path: file.path) + } catch { + self.error = "Delete failed: \(error)" + return + } + } + selection.removeAll() + await refresh() + } + + func renameFile(_ file: VPhoneRemoteFile, to newName: String) async { + let newPath = (file.dir as NSString).appendingPathComponent(newName) + do { + try await control.renameFile(from: file.path, to: newPath) + await refresh() + } catch { + self.error = "Rename failed: \(error)" + } + } +} diff --git a/sources/vphone-cli/VPhoneFileBrowserView.swift b/sources/vphone-cli/VPhoneFileBrowserView.swift new file mode 100644 index 0000000..88f0c95 --- /dev/null +++ b/sources/vphone-cli/VPhoneFileBrowserView.swift @@ -0,0 +1,344 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct VPhoneFileBrowserView: View { + @Bindable var model: VPhoneFileBrowserModel + + @State private var showNewFolder = false + @State private var newFolderName = "" + + private let controlBarHeight: CGFloat = 24 + + var body: some View { + ZStack { + tableView + .padding(.bottom, controlBarHeight) + .overlay(controlBar.frame(maxHeight: .infinity, alignment: .bottom)) + .opacity(model.isTransferring ? 0.25 : 1) + .searchable(text: $model.searchText, prompt: "Filter files") + .onDrop(of: [.fileURL], isTargeted: nil, perform: dropFiles) + .disabled(model.isTransferring) + .toolbar { toolbarContent } + if model.isTransferring { + progressOverlay + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.thickMaterial) + .zIndex(100) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { await model.refresh() } + .alert( + "Error", + isPresented: .init( + get: { model.error != nil }, + set: { if !$0 { model.error = nil } } + ) + ) { + Button("OK") { model.error = nil } + } message: { + Text(model.error ?? "") + } + .sheet(isPresented: $showNewFolder) { + newFolderSheet + } + } + + // MARK: - Table + + var tableView: some View { + Table(of: VPhoneRemoteFile.self, selection: $model.selection, sortOrder: $model.sortOrder) { + TableColumn("", value: \.name) { file in + Image(systemName: file.icon) + .foregroundStyle(file.isDirectory ? .blue : .secondary) + .frame(width: 20) + } + .width(28) + + TableColumn("Name", value: \.name) { file in + Text(file.name) + .lineLimit(1) + .help(file.name) + } + .width(min: 100, ideal: 200, max: .infinity) + + TableColumn("Permissions", value: \.permissions) { file in + Text(file.permissions) + .font(.system(.body, design: .monospaced)) + } + .width(80) + + TableColumn("Size", value: \.size) { file in + Text(file.displaySize) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(file.isDirectory ? .secondary : .primary) + } + .width(min: 50, ideal: 80, max: .infinity) + + TableColumn("Modified", value: \.modified) { file in + Text(file.displayDate) + } + .width(min: 80, ideal: 140, max: .infinity) + } rows: { + ForEach(model.filteredFiles) { file in + TableRow(file) + } + } + .contextMenu(forSelectionType: VPhoneRemoteFile.ID.self) { ids in + contextMenu(for: ids) + } primaryAction: { ids in + primaryAction(for: ids) + } + } + + // MARK: - Control Bar + + var controlBar: some View { + HStack(spacing: 6) { + // Status dot + Circle() + .fill(model.control.isConnected ? Color.green : Color.orange) + .frame(width: 8, height: 8) + + Divider() + + // Breadcrumbs + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(Array(model.breadcrumbs.enumerated()), id: \.offset) { _, crumb in + if crumb.path != "/" || model.breadcrumbs.count == 1 { + Image(systemName: "chevron.right") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(.tertiary) + } + Button(crumb.name) { + model.goToBreadcrumb(crumb.path) + } + .buttonStyle(.plain) + } + } + .font(.system(size: 11, weight: .medium, design: .monospaced)) + } + + Divider() + + // Item count + Text(model.statusText) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(minWidth: 60) + } + .padding(.horizontal, 8) + .frame(height: controlBarHeight) + .frame(maxWidth: .infinity) + .background(.bar) + } + + // MARK: - Progress Overlay + + var progressOverlay: some View { + VStack(spacing: 12) { + ProgressView() + .progressViewStyle(.circular) + if model.transferTotal > 0 { + ProgressView(value: Double(model.transferCurrent), total: Double(model.transferTotal)) + .progressViewStyle(.linear) + } + HStack { + Text(model.transferName ?? "Transferring...") + .lineLimit(1) + Spacer() + if model.transferTotal > 0 { + Text("\(formatBytes(model.transferCurrent)) / \(formatBytes(model.transferTotal))") + } + } + .font(.system(.footnote, design: .monospaced)) + } + .frame(maxWidth: 300) + .padding(24) + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .navigation) { + Button { + model.goBack() + } label: { + Label("Back", systemImage: "chevron.left") + } + .disabled(!model.canGoBack) + .keyboardShortcut(.leftArrow, modifiers: .command) + } + ToolbarItem { + Button { + Task { await model.refresh() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .keyboardShortcut("r", modifiers: .command) + } + ToolbarItem { + Button { + newFolderName = "" + showNewFolder = true + } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } + .keyboardShortcut("n", modifiers: .command) + } + ToolbarItem { + Button { + uploadAction() + } label: { + Label("Upload", systemImage: "square.and.arrow.up") + } + } + ToolbarItem { + Button { + downloadAction() + } label: { + Label("Download", systemImage: "square.and.arrow.down") + } + .disabled(model.selection.isEmpty) + } + ToolbarItem { + Button { + Task { await model.deleteSelected() } + } label: { + Label("Delete", systemImage: "trash") + } + .disabled(model.selection.isEmpty) + } + } + + // MARK: - Context Menu + + @ViewBuilder + func contextMenu(for ids: Set) -> some View { + Button("Open") { primaryAction(for: ids) } + Button("Download") { + model.selection = ids + downloadAction() + } + Button("Delete") { + model.selection = ids + Task { await model.deleteSelected() } + } + Divider() + Button("Refresh") { Task { await model.refresh() } } + Divider() + Button("Copy Name") { copyNames(ids: ids) } + Button("Copy Path") { copyPaths(ids: ids) } + Divider() + Button("Upload...") { uploadAction() } + Button("New Folder...") { + newFolderName = "" + showNewFolder = true + } + } + + // MARK: - New Folder Sheet + + var newFolderSheet: some View { + VStack(spacing: 16) { + Text("New Folder").font(.headline) + TextField("Folder name", text: $newFolderName) + .textFieldStyle(.roundedBorder) + .onSubmit { createFolder() } + HStack { + Button("Cancel") { showNewFolder = false } + .keyboardShortcut(.cancelAction) + Spacer() + Button("Create") { createFolder() } + .keyboardShortcut(.defaultAction) + .disabled(newFolderName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding(20) + .frame(width: 300) + } + + // MARK: - Actions + + func primaryAction(for ids: Set) { + guard let id = ids.first, + let file = model.filteredFiles.first(where: { $0.id == id }) + else { return } + model.openItem(file) + } + + func uploadAction() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseFiles = true + panel.canChooseDirectories = false + guard panel.runModal() == .OK else { return } + Task { await model.uploadFiles(urls: panel.urls) } + } + + func downloadAction() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.prompt = "Save Here" + guard panel.runModal() == .OK, let url = panel.url else { return } + Task { await model.downloadSelected(to: url) } + } + + func createFolder() { + let name = newFolderName.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return } + showNewFolder = false + Task { await model.createNewFolder(name: name) } + } + + func dropFiles(_ providers: [NSItemProvider]) -> Bool { + let validProviders = providers.filter { $0.canLoadObject(ofClass: URL.self) } + guard !validProviders.isEmpty else { return false } + Task { @MainActor in + var urls: [URL] = [] + for provider in validProviders { + if let url = try? await provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) + as? URL + { + urls.append(url) + } else if let data = try? await provider.loadItem( + forTypeIdentifier: UTType.fileURL.identifier + ) as? Data, + let url = URL(dataRepresentation: data, relativeTo: nil) + { + urls.append(url) + } + } + if !urls.isEmpty { + await model.uploadFiles(urls: urls) + } + } + return true + } + + func copyNames(ids: Set) { + let names = model.filteredFiles + .filter { ids.contains($0.id) } + .map(\.name) + .joined(separator: "\n") + NSPasteboard.general.prepareForNewContents() + NSPasteboard.general.setString(names, forType: .string) + } + + func copyPaths(ids: Set) { + let paths = model.filteredFiles + .filter { ids.contains($0.id) } + .map(\.path) + .joined(separator: "\n") + NSPasteboard.general.prepareForNewContents() + NSPasteboard.general.setString(paths, forType: .string) + } + + func formatBytes(_ bytes: Int64) -> String { + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } +} diff --git a/sources/vphone-cli/VPhoneFileWindowController.swift b/sources/vphone-cli/VPhoneFileWindowController.swift new file mode 100644 index 0000000..0c86b54 --- /dev/null +++ b/sources/vphone-cli/VPhoneFileWindowController.swift @@ -0,0 +1,54 @@ +import AppKit +import SwiftUI + +@MainActor +class VPhoneFileWindowController { + private var window: NSWindow? + private var model: VPhoneFileBrowserModel? + + func showWindow(control: VPhoneControl) { + // Reuse existing window + if let window { + window.makeKeyAndOrderFront(nil) + return + } + + let model = VPhoneFileBrowserModel(control: control) + self.model = model + + let view = VPhoneFileBrowserView(model: model) + let hostingView = NSHostingView(rootView: view) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 500), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.title = "Files" + window.subtitle = "vphone" + window.contentView = hostingView + window.contentMinSize = NSSize(width: 500, height: 300) + window.center() + window.toolbarStyle = .unified + + // Add toolbar so the unified title bar shows + let toolbar = NSToolbar(identifier: "vphone-files-toolbar") + toolbar.displayMode = .iconOnly + window.toolbar = toolbar + + window.makeKeyAndOrderFront(nil) + self.window = window + + NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.window = nil + self?.model = nil + } + } + } +} diff --git a/sources/vphone-cli/VPhoneHardwareModel.swift b/sources/vphone-cli/VPhoneHardwareModel.swift index a3595c1..2e2c21e 100644 --- a/sources/vphone-cli/VPhoneHardwareModel.swift +++ b/sources/vphone-cli/VPhoneHardwareModel.swift @@ -20,9 +20,10 @@ enum VPhoneHardware { desc.setBoardID(NSNumber(value: UInt32(0x90))) desc.setISA(NSNumber(value: Int64(2))) - let model = Dynamic.VZMacHardwareModel - ._hardwareModelWithDescriptor(desc.asObject) - .asObject as! VZMacHardwareModel + let model = + Dynamic.VZMacHardwareModel + ._hardwareModelWithDescriptor(desc.asObject) + .asObject as! VZMacHardwareModel guard model.isSupported else { throw VPhoneError.hardwareModelNotSupported diff --git a/sources/vphone-cli/VPhoneKeyHelper.swift b/sources/vphone-cli/VPhoneKeyHelper.swift index 261f048..0a3c826 100644 --- a/sources/vphone-cli/VPhoneKeyHelper.swift +++ b/sources/vphone-cli/VPhoneKeyHelper.swift @@ -9,200 +9,67 @@ import Virtualization class VPhoneKeyHelper { private let vm: VZVirtualMachine private let control: VPhoneControl + weak var window: NSWindow? - /// First _VZKeyboard from the VM's internal keyboard array. + /// First _VZKeyboard from the VM's internal keyboard array (used by typeString). private var firstKeyboard: AnyObject? { guard let arr = Dynamic(vm)._keyboards.asObject as? NSArray, arr.count > 0 else { return nil } return arr.object(at: 0) as AnyObject } - /// Get _deviceIdentifier from _VZKeyboard via KVC (it's an ivar, not a property). - private func keyboardDeviceId(_ keyboard: AnyObject) -> UInt32 { - if let obj = keyboard as? NSObject, - let val = obj.value(forKey: "_deviceIdentifier") as? UInt32 - { - return val - } - print("[keys] WARNING: Could not read _deviceIdentifier, defaulting to 1") - return 1 - } - init(vm: VPhoneVM, control: VPhoneControl) { self.vm = vm.virtualMachine self.control = control } - // MARK: - Send Key via _VZKeyEvent + // MARK: - Connection Guard - /// Send key down + up through _VZKeyEvent → _VZKeyboard.sendKeyEvents: pipeline. - private func sendKeyPress(keyCode: UInt16) { - guard let keyboard = firstKeyboard else { - print("[keys] No keyboard found") - return - } - - let down = Dynamic._VZKeyEvent(type: 0, keyCode: keyCode) - let up = Dynamic._VZKeyEvent(type: 1, keyCode: keyCode) - - guard let downObj = down.asAnyObject, let upObj = up.asAnyObject else { - print("[keys] Failed to create _VZKeyEvent") - return - } - - Dynamic(keyboard).sendKeyEvents([downObj, upObj] as NSArray) - print("[keys] Sent VK 0x\(String(keyCode, radix: 16)) (down+up)") - } - - // MARK: - Fn+Key Combos (iOS Full Keyboard Access) - - /// Send modifier+key combo via _VZKeyEvent (mod down → key down → key up → mod up). - private func sendVKCombo(modifierVK: UInt16, keyVK: UInt16) { - guard let keyboard = firstKeyboard else { - print("[keys] No keyboard found") - return - } - - var events: [AnyObject] = [] - if let obj = Dynamic._VZKeyEvent(type: 0, keyCode: modifierVK).asAnyObject { events.append(obj) } - if let obj = Dynamic._VZKeyEvent(type: 0, keyCode: keyVK).asAnyObject { events.append(obj) } - if let obj = Dynamic._VZKeyEvent(type: 1, keyCode: keyVK).asAnyObject { events.append(obj) } - if let obj = Dynamic._VZKeyEvent(type: 1, keyCode: modifierVK).asAnyObject { events.append(obj) } - - print("[keys] events: \(events)") - Dynamic(keyboard).sendKeyEvents(events as NSArray) - print("[keys] VK combo: 0x\(String(modifierVK, radix: 16))+0x\(String(keyVK, radix: 16))") - } - - // MARK: - Vector Injection (for keys with no VK code) - - /// Bypass _VZKeyEvent by calling sendKeyboardEvents:keyboardID: directly - /// with a crafted std::vector. Packed: (intermediate_index << 32) | is_key_down. - private func sendRawKeyPress(index: UInt64) { - guard let keyboard = firstKeyboard else { - print("[keys] No keyboard found") - return - } - let deviceId = keyboardDeviceId(keyboard) - - sendRawKeyEvent(index: index, isKeyDown: true, deviceId: deviceId) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [self] in - sendRawKeyEvent(index: index, isKeyDown: false, deviceId: deviceId) - } - } - - private func sendRawKeyEvent(index: UInt64, isKeyDown: Bool, deviceId: UInt32) { - let packed = (index << 32) | (isKeyDown ? 1 : 0) - - let data = UnsafeMutablePointer.allocate(capacity: 1) - defer { data.deallocate() } - data.pointee = packed - - var vec = (data, data.advanced(by: 1), data.advanced(by: 1)) - withUnsafeMutablePointer(to: &vec) { vecPtr in - _ = Dynamic(vm).sendKeyboardEvents(UnsafeMutableRawPointer(vecPtr), keyboardID: deviceId) - } - } - - // MARK: - Unlock via Serial Console - - /// Unlock screen via vsock HID injection (Power to wake + Home to unlock). - func sendUnlock() { - guard control.isConnected else { - print("[unlock] vphoned not connected, skipping unlock") - return - } - print("[unlock] Sending unlock via vphoned HID") - control.sendHIDPress(page: 0x0C, usage: 0x30) // Power (wake) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.control.sendHIDPress(page: 0x0C, usage: 0x40) // Home (unlock) - } - } - - /// Auto-unlock: wait for vphoned connection, then send unlock. - func autoUnlock(delay: TimeInterval = 8) { - print("[unlock] Auto-unlock: will unlock in \(Int(delay))s") - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.sendUnlock() - } - } - - // MARK: - Named Key Actions - - /// Home button — vsock HID injection if connected, Cmd+H fallback otherwise. - func sendHome() { - if control.isConnected { - control.sendHIDPress(page: 0x0C, usage: 0x40) + private func requireConnection() -> Bool { + if control.isConnected { return true } + let alert = NSAlert() + alert.messageText = "vphoned Not Connected" + alert.informativeText = + "The guest agent is not connected. Key injection requires vphoned running inside the VM." + alert.alertStyle = .warning + alert.addButton(withTitle: "OK") + if let window { + alert.beginSheetModal(for: window) } else { - print("[keys] vphoned not connected, falling back to Cmd+H") - sendVKCombo(modifierVK: 0x37, keyVK: 0x04) + alert.runModal() } + return false } - func sendSpotlight() { - sendVKCombo(modifierVK: 0x37, keyVK: 0x31) + // MARK: - Hardware Keys (Consumer Page 0x0C) + + func sendHome() { + guard requireConnection() else { return } + control.sendHIDPress(page: 0x0C, usage: 0x40) } - /// Standard keyboard keys - func sendReturn() { - sendKeyPress(keyCode: 0x24) + func sendPower() { + guard requireConnection() else { return } + control.sendHIDPress(page: 0x0C, usage: 0x30) } - func sendEscape() { - sendKeyPress(keyCode: 0x35) - } - - func sendSpace() { - sendKeyPress(keyCode: 0x31) - } - - func sendTab() { - sendKeyPress(keyCode: 0x30) - } - - func sendDeleteKey() { - sendKeyPress(keyCode: 0x33) - } - - func sendArrowUp() { - sendKeyPress(keyCode: 0x7E) - } - - func sendArrowDown() { - sendKeyPress(keyCode: 0x7D) - } - - func sendArrowLeft() { - sendKeyPress(keyCode: 0x7B) - } - - func sendArrowRight() { - sendKeyPress(keyCode: 0x7C) - } - - func sendShift() { - sendKeyPress(keyCode: 0x38) - } - - func sendCommand() { - sendKeyPress(keyCode: 0x37) - } - - /// Volume (Apple VK codes) func sendVolumeUp() { - sendKeyPress(keyCode: 0x48) + guard requireConnection() else { return } + control.sendHIDPress(page: 0x0C, usage: 0xE9) } func sendVolumeDown() { - sendKeyPress(keyCode: 0x49) + guard requireConnection() else { return } + control.sendHIDPress(page: 0x0C, usage: 0xEA) } - /// Power — vsock HID injection if connected, vector injection fallback. - func sendPower() { - if control.isConnected { - control.sendHIDPress(page: 0x0C, usage: 0x30) - } else { - sendRawKeyPress(index: 0x72) - } + // MARK: - Combos + + func sendSpotlight() { + guard requireConnection() else { return } + // Cmd+Space: messages are processed sequentially by vphoned + control.sendHIDDown(page: 0x07, usage: 0xE3) // Cmd down + control.sendHIDPress(page: 0x07, usage: 0x2C) // Space press + control.sendHIDUp(page: 0x07, usage: 0xE3) // Cmd up } // MARK: - Type ASCII from Clipboard @@ -234,12 +101,20 @@ class VPhoneKeyHelper { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { var events: [AnyObject] = [] if needsShift { - if let obj = Dynamic._VZKeyEvent(type: 0, keyCode: UInt16(0x38)).asAnyObject { events.append(obj) } + if let obj = Dynamic._VZKeyEvent(type: 0, keyCode: UInt16(0x38)).asAnyObject { + events.append(obj) + } + } + if let obj = Dynamic._VZKeyEvent(type: 0, keyCode: keyCode).asAnyObject { + events.append(obj) + } + if let obj = Dynamic._VZKeyEvent(type: 1, keyCode: keyCode).asAnyObject { + events.append(obj) } - if let obj = Dynamic._VZKeyEvent(type: 0, keyCode: keyCode).asAnyObject { events.append(obj) } - if let obj = Dynamic._VZKeyEvent(type: 1, keyCode: keyCode).asAnyObject { events.append(obj) } if needsShift { - if let obj = Dynamic._VZKeyEvent(type: 1, keyCode: UInt16(0x38)).asAnyObject { events.append(obj) } + if let obj = Dynamic._VZKeyEvent(type: 1, keyCode: UInt16(0x38)).asAnyObject { + events.append(obj) + } } Dynamic(keyboard).sendKeyEvents(events as NSArray) } @@ -252,56 +127,104 @@ class VPhoneKeyHelper { private func asciiToVK(_ char: Character) -> (UInt16, Bool)? { switch char { - case "a": (0x00, false) case "b": (0x0B, false) - case "c": (0x08, false) case "d": (0x02, false) - case "e": (0x0E, false) case "f": (0x03, false) - case "g": (0x05, false) case "h": (0x04, false) - case "i": (0x22, false) case "j": (0x26, false) - case "k": (0x28, false) case "l": (0x25, false) - case "m": (0x2E, false) case "n": (0x2D, false) - case "o": (0x1F, false) case "p": (0x23, false) - case "q": (0x0C, false) case "r": (0x0F, false) - case "s": (0x01, false) case "t": (0x11, false) - case "u": (0x20, false) case "v": (0x09, false) - case "w": (0x0D, false) case "x": (0x07, false) - case "y": (0x10, false) case "z": (0x06, false) - case "A": (0x00, true) case "B": (0x0B, true) - case "C": (0x08, true) case "D": (0x02, true) - case "E": (0x0E, true) case "F": (0x03, true) - case "G": (0x05, true) case "H": (0x04, true) - case "I": (0x22, true) case "J": (0x26, true) - case "K": (0x28, true) case "L": (0x25, true) - case "M": (0x2E, true) case "N": (0x2D, true) - case "O": (0x1F, true) case "P": (0x23, true) - case "Q": (0x0C, true) case "R": (0x0F, true) - case "S": (0x01, true) case "T": (0x11, true) - case "U": (0x20, true) case "V": (0x09, true) - case "W": (0x0D, true) case "X": (0x07, true) - case "Y": (0x10, true) case "Z": (0x06, true) - case "0": (0x1D, false) case "1": (0x12, false) - case "2": (0x13, false) case "3": (0x14, false) - case "4": (0x15, false) case "5": (0x17, false) - case "6": (0x16, false) case "7": (0x1A, false) - case "8": (0x1C, false) case "9": (0x19, false) - case "-": (0x1B, false) case "=": (0x18, false) - case "[": (0x21, false) case "]": (0x1E, false) - case "\\": (0x2A, false) case ";": (0x29, false) - case "'": (0x27, false) case ",": (0x2B, false) - case ".": (0x2F, false) case "/": (0x2C, false) + case "a": (0x00, false) + case "b": (0x0B, false) + case "c": (0x08, false) + case "d": (0x02, false) + case "e": (0x0E, false) + case "f": (0x03, false) + case "g": (0x05, false) + case "h": (0x04, false) + case "i": (0x22, false) + case "j": (0x26, false) + case "k": (0x28, false) + case "l": (0x25, false) + case "m": (0x2E, false) + case "n": (0x2D, false) + case "o": (0x1F, false) + case "p": (0x23, false) + case "q": (0x0C, false) + case "r": (0x0F, false) + case "s": (0x01, false) + case "t": (0x11, false) + case "u": (0x20, false) + case "v": (0x09, false) + case "w": (0x0D, false) + case "x": (0x07, false) + case "y": (0x10, false) + case "z": (0x06, false) + case "A": (0x00, true) + case "B": (0x0B, true) + case "C": (0x08, true) + case "D": (0x02, true) + case "E": (0x0E, true) + case "F": (0x03, true) + case "G": (0x05, true) + case "H": (0x04, true) + case "I": (0x22, true) + case "J": (0x26, true) + case "K": (0x28, true) + case "L": (0x25, true) + case "M": (0x2E, true) + case "N": (0x2D, true) + case "O": (0x1F, true) + case "P": (0x23, true) + case "Q": (0x0C, true) + case "R": (0x0F, true) + case "S": (0x01, true) + case "T": (0x11, true) + case "U": (0x20, true) + case "V": (0x09, true) + case "W": (0x0D, true) + case "X": (0x07, true) + case "Y": (0x10, true) + case "Z": (0x06, true) + case "0": (0x1D, false) + case "1": (0x12, false) + case "2": (0x13, false) + case "3": (0x14, false) + case "4": (0x15, false) + case "5": (0x17, false) + case "6": (0x16, false) + case "7": (0x1A, false) + case "8": (0x1C, false) + case "9": (0x19, false) + case "-": (0x1B, false) + case "=": (0x18, false) + case "[": (0x21, false) + case "]": (0x1E, false) + case "\\": (0x2A, false) + case ";": (0x29, false) + case "'": (0x27, false) + case ",": (0x2B, false) + case ".": (0x2F, false) + case "/": (0x2C, false) case "`": (0x32, false) - case "!": (0x12, true) case "@": (0x13, true) - case "#": (0x14, true) case "$": (0x15, true) - case "%": (0x17, true) case "^": (0x16, true) - case "&": (0x1A, true) case "*": (0x1C, true) - case "(": (0x19, true) case ")": (0x1D, true) - case "_": (0x1B, true) case "+": (0x18, true) - case "{": (0x21, true) case "}": (0x1E, true) - case "|": (0x2A, true) case ":": (0x29, true) - case "\"": (0x27, true) case "<": (0x2B, true) - case ">": (0x2F, true) case "?": (0x2C, true) + case "!": (0x12, true) + case "@": (0x13, true) + case "#": (0x14, true) + case "$": (0x15, true) + case "%": (0x17, true) + case "^": (0x16, true) + case "&": (0x1A, true) + case "*": (0x1C, true) + case "(": (0x19, true) + case ")": (0x1D, true) + case "_": (0x1B, true) + case "+": (0x18, true) + case "{": (0x21, true) + case "}": (0x1E, true) + case "|": (0x2A, true) + case ":": (0x29, true) + case "\"": (0x27, true) + case "<": (0x2B, true) + case ">": (0x2F, true) + case "?": (0x2C, true) case "~": (0x32, true) - case " ": (0x31, false) case "\t": (0x30, false) - case "\n": (0x24, false) case "\r": (0x24, false) + case " ": (0x31, false) + case "\t": (0x30, false) + case "\n": (0x24, false) + case "\r": (0x24, false) default: nil } } diff --git a/sources/vphone-cli/VPhoneMenuController.swift b/sources/vphone-cli/VPhoneMenuController.swift index 436941f..60dce72 100644 --- a/sources/vphone-cli/VPhoneMenuController.swift +++ b/sources/vphone-cli/VPhoneMenuController.swift @@ -6,9 +6,13 @@ import Foundation @MainActor class VPhoneMenuController { private let keyHelper: VPhoneKeyHelper + private let control: VPhoneControl - init(keyHelper: VPhoneKeyHelper) { + var onFilesPressed: (() -> Void)? + + init(keyHelper: VPhoneKeyHelper, control: VPhoneControl) { self.keyHelper = keyHelper + self.control = control setupMenuBar() } @@ -20,37 +24,21 @@ class VPhoneMenuController { // App menu let appMenuItem = NSMenuItem() let appMenu = NSMenu(title: "vphone") - appMenu.addItem(withTitle: "Quit vphone", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + appMenu.addItem( + withTitle: "Quit vphone", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q" + ) appMenuItem.submenu = appMenu mainMenu.addItem(appMenuItem) - // Keys menu — NO key equivalents to avoid intercepting VM keyboard input + // Keys menu — hardware buttons that need vphoned HID injection let keysMenuItem = NSMenuItem() let keysMenu = NSMenu(title: "Keys") - - // iOS hardware keyboard shortcuts - keysMenu.addItem(makeItem("Home Screen (Cmd+H)", action: #selector(sendHome))) - keysMenu.addItem(makeItem("Unlock", action: #selector(sendUnlock))) - keysMenu.addItem(makeItem("Spotlight (Cmd+Space)", action: #selector(sendSpotlight))) - keysMenu.addItem(NSMenuItem.separator()) - keysMenu.addItem(makeItem("Return", action: #selector(sendReturn))) - keysMenu.addItem(makeItem("Escape", action: #selector(sendEscape))) - keysMenu.addItem(makeItem("Space", action: #selector(sendSpace))) - keysMenu.addItem(makeItem("Tab", action: #selector(sendTab))) - keysMenu.addItem(makeItem("Delete", action: #selector(sendDeleteKey))) - keysMenu.addItem(NSMenuItem.separator()) - keysMenu.addItem(makeItem("Arrow Up", action: #selector(sendArrowUp))) - keysMenu.addItem(makeItem("Arrow Down", action: #selector(sendArrowDown))) - keysMenu.addItem(makeItem("Arrow Left", action: #selector(sendArrowLeft))) - keysMenu.addItem(makeItem("Arrow Right", action: #selector(sendArrowRight))) - keysMenu.addItem(NSMenuItem.separator()) + keysMenu.addItem(makeItem("Home Screen", action: #selector(sendHome))) keysMenu.addItem(makeItem("Power", action: #selector(sendPower))) keysMenu.addItem(makeItem("Volume Up", action: #selector(sendVolumeUp))) keysMenu.addItem(makeItem("Volume Down", action: #selector(sendVolumeDown))) keysMenu.addItem(NSMenuItem.separator()) - keysMenu.addItem(makeItem("Shift (tap)", action: #selector(sendShift))) - keysMenu.addItem(makeItem("Command (tap)", action: #selector(sendCommand))) - + keysMenu.addItem(makeItem("Spotlight (Cmd+Space)", action: #selector(sendSpotlight))) keysMenuItem.submenu = keysMenu mainMenu.addItem(keysMenuItem) @@ -61,6 +49,18 @@ class VPhoneMenuController { typeMenuItem.submenu = typeMenu mainMenu.addItem(typeMenuItem) + // Connect menu — guest agent commands + let agentMenuItem = NSMenuItem() + let agentMenu = NSMenu(title: "Connect") + agentMenu.addItem(makeItem("File Browser", action: #selector(openFiles))) + agentMenu.addItem(NSMenuItem.separator()) + agentMenu.addItem(makeItem("Developer Mode Status", action: #selector(devModeStatus))) + agentMenu.addItem(makeItem("Enable Developer Mode", action: #selector(devModeEnable))) + agentMenu.addItem(NSMenuItem.separator()) + agentMenu.addItem(makeItem("Ping", action: #selector(sendPing))) + agentMenuItem.submenu = agentMenu + mainMenu.addItem(agentMenuItem) + NSApp.mainMenu = mainMenu } @@ -70,56 +70,12 @@ class VPhoneMenuController { return item } - // MARK: - Menu Actions (delegate to helper) + // MARK: - Keys (hardware buttons via vphoned HID) @objc private func sendHome() { keyHelper.sendHome() } - @objc private func sendUnlock() { - keyHelper.sendUnlock() - } - - @objc private func sendSpotlight() { - keyHelper.sendSpotlight() - } - - @objc private func sendReturn() { - keyHelper.sendReturn() - } - - @objc private func sendEscape() { - keyHelper.sendEscape() - } - - @objc private func sendSpace() { - keyHelper.sendSpace() - } - - @objc private func sendTab() { - keyHelper.sendTab() - } - - @objc private func sendDeleteKey() { - keyHelper.sendDeleteKey() - } - - @objc private func sendArrowUp() { - keyHelper.sendArrowUp() - } - - @objc private func sendArrowDown() { - keyHelper.sendArrowDown() - } - - @objc private func sendArrowLeft() { - keyHelper.sendArrowLeft() - } - - @objc private func sendArrowRight() { - keyHelper.sendArrowRight() - } - @objc private func sendPower() { keyHelper.sendPower() } @@ -132,15 +88,29 @@ class VPhoneMenuController { keyHelper.sendVolumeDown() } - @objc private func sendShift() { - keyHelper.sendShift() - } - - @objc private func sendCommand() { - keyHelper.sendCommand() + @objc private func sendSpotlight() { + keyHelper.sendSpotlight() } @objc private func typeFromClipboard() { keyHelper.typeFromClipboard() } + + // MARK: - vphoned Agent Commands + + @objc private func openFiles() { + onFilesPressed?() + } + + @objc private func devModeStatus() { + control.sendDevModeStatus() + } + + @objc private func devModeEnable() { + control.sendDevModeEnable() + } + + @objc private func sendPing() { + control.sendPing() + } } diff --git a/sources/vphone-cli/VPhoneRemoteFile.swift b/sources/vphone-cli/VPhoneRemoteFile.swift new file mode 100644 index 0000000..ddf260f --- /dev/null +++ b/sources/vphone-cli/VPhoneRemoteFile.swift @@ -0,0 +1,93 @@ +import Foundation + +struct VPhoneRemoteFile: Identifiable, Hashable { + let dir: String + let name: String + let type: FileType + let size: UInt64 + let permissions: String + let modified: Date + + var id: String { + path + } + + var path: String { + (dir as NSString).appendingPathComponent(name) + } + + var isDirectory: Bool { + type == .directory + } + + var isSymbolicLink: Bool { + type == .symbolicLink + } + + var displaySize: String { + if isDirectory || isSymbolicLink { return "—" } + return ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file) + } + + var displayDate: String { + Self.dateFormatter.string(from: modified) + } + + var icon: String { + switch type { + case .directory: "folder.fill" + case .symbolicLink: "link" + case .file: fileIcon(for: name) + } + } + + enum FileType: String, Hashable { + case file + case directory = "dir" + case symbolicLink = "link" + } + + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .short + f.timeStyle = .short + return f + }() + + private func fileIcon(for name: String) -> String { + let ext = (name as NSString).pathExtension.lowercased() + switch ext { + case "png", "jpg", "jpeg", "gif", "bmp", "tiff", "heic": + return "photo" + case "mov", "mp4", "m4v": + return "film" + case "txt", "md", "log": + return "doc.text" + case "plist", "json", "xml", "yaml": + return "doc.badge.gearshape" + case "dylib", "framework": + return "shippingbox" + case "app": + return "app.dashed" + default: + return "doc" + } + } +} + +extension VPhoneRemoteFile { + /// Parse from the dict returned by vphoned file_list entries. + init?(dir: String, entry: [String: Any]) { + guard let name = entry["name"] as? String, + let typeStr = entry["type"] as? String, + let type = FileType(rawValue: typeStr) + else { return nil } + + self.dir = dir + self.name = name + self.type = type + size = (entry["size"] as? NSNumber)?.uint64Value ?? 0 + permissions = entry["perm"] as? String ?? "---" + modified = Date(timeIntervalSince1970: (entry["mtime"] as? Double) ?? 0) + } +} diff --git a/sources/vphone-cli/VPhoneVM.swift b/sources/vphone-cli/VPhoneVM.swift index d50acaa..1e66a35 100644 --- a/sources/vphone-cli/VPhoneVM.swift +++ b/sources/vphone-cli/VPhoneVM.swift @@ -56,9 +56,10 @@ class VPhoneVM: NSObject, VZVirtualMachineDelegate { // Set NVRAM boot-args to enable serial output let bootArgs = "serial=3 debug=0x104c04" if let bootArgsData = bootArgs.data(using: .utf8) { - let ok = Dynamic(auxStorage) - ._setDataValue(bootArgsData, forNVRAMVariableNamed: "boot-args", error: nil) - .asBool ?? false + let ok = + Dynamic(auxStorage) + ._setDataValue(bootArgsData, forNVRAMVariableNamed: "boot-args", error: nil) + .asBool ?? false if ok { print("[vphone] NVRAM boot-args: \(bootArgs)") } } @@ -71,7 +72,9 @@ class VPhoneVM: NSObject, VZVirtualMachineDelegate { config.bootLoader = bootloader config.platform = platform config.cpuCount = max(options.cpuCount, VZVirtualMachineConfiguration.minimumAllowedCPUCount) - config.memorySize = max(options.memorySize, VZVirtualMachineConfiguration.minimumAllowedMemorySize) + config.memorySize = max( + options.memorySize, VZVirtualMachineConfiguration.minimumAllowedMemorySize + ) // Display let gfx = VZMacGraphicsDeviceConfiguration() @@ -105,7 +108,9 @@ class VPhoneVM: NSObject, VZVirtualMachineDelegate { config.networkDevices = [net] // Serial port (PL011 UART — pipes for input/output with boot detection) - if let serialPort = Dynamic._VZPL011SerialPortConfiguration().asObject as? VZSerialPortConfiguration { + if let serialPort = Dynamic._VZPL011SerialPortConfiguration().asObject + as? VZSerialPortConfiguration + { let inputPipe = Pipe() let outputPipe = Pipe() @@ -202,9 +207,10 @@ class VPhoneVM: NSObject, VZVirtualMachineDelegate { exit(EXIT_FAILURE) } - nonisolated func virtualMachine(_: VZVirtualMachine, networkDevice _: VZNetworkDevice, - attachmentWasDisconnectedWithError error: Error) - { + nonisolated func virtualMachine( + _: VZVirtualMachine, networkDevice _: VZNetworkDevice, + attachmentWasDisconnectedWithError error: Error + ) { print("[vphone] Network error: \(error)") } } diff --git a/sources/vphone-cli/VPhoneVMView.swift b/sources/vphone-cli/VPhoneVMView.swift index c979519..740ac02 100644 --- a/sources/vphone-cli/VPhoneVMView.swift +++ b/sources/vphone-cli/VPhoneVMView.swift @@ -25,7 +25,10 @@ class VPhoneVMView: VZVirtualMachineView { override func mouseDown(with event: NSEvent) { // macOS 16+: VZVirtualMachineView handles mouse-to-touch natively - if #available(macOS 16.0, *) { super.mouseDown(with: event); return } + if #available(macOS 16.0, *) { + super.mouseDown(with: event) + return + } let localPoint = convert(event.locationInWindow, from: nil) @@ -39,7 +42,10 @@ class VPhoneVMView: VZVirtualMachineView { } override func mouseDragged(with event: NSEvent) { - if #available(macOS 16.0, *) { super.mouseDragged(with: event); return } + if #available(macOS 16.0, *) { + super.mouseDragged(with: event) + return + } let localPoint = convert(event.locationInWindow, from: nil) sendTouchEvent( @@ -51,7 +57,10 @@ class VPhoneVMView: VZVirtualMachineView { } override func mouseUp(with event: NSEvent) { - if #available(macOS 16.0, *) { super.mouseUp(with: event); return } + if #available(macOS 16.0, *) { + super.mouseUp(with: event) + return + } let localPoint = convert(event.locationInWindow, from: nil) sendTouchEvent( diff --git a/sources/vphone-cli/VPhoneWindowController.swift b/sources/vphone-cli/VPhoneWindowController.swift index fc92305..9f1c731 100644 --- a/sources/vphone-cli/VPhoneWindowController.swift +++ b/sources/vphone-cli/VPhoneWindowController.swift @@ -3,10 +3,19 @@ import Foundation import Virtualization @MainActor -class VPhoneWindowController { +class VPhoneWindowController: NSObject, NSToolbarDelegate { private var windowController: NSWindowController? + private var statusTimer: Timer? + private weak var control: VPhoneControl? + + private nonisolated static let homeItemID = NSToolbarItem.Identifier("home") + + func showWindow( + for vm: VZVirtualMachine, screenWidth: Int, screenHeight: Int, screenScale: Double, + keyHelper: VPhoneKeyHelper, control: VPhoneControl + ) { + self.control = control - func showWindow(for vm: VZVirtualMachine, screenWidth: Int, screenHeight: Int, screenScale: Double, keyHelper: VPhoneKeyHelper) { let view = VPhoneVMView() view.virtualMachine = vm view.capturesSystemKeys = true @@ -14,7 +23,9 @@ class VPhoneWindowController { let vmView: NSView = view let scale = CGFloat(screenScale) - let windowSize = NSSize(width: CGFloat(screenWidth) / scale, height: CGFloat(screenHeight) / scale) + let windowSize = NSSize( + width: CGFloat(screenWidth) / scale, height: CGFloat(screenHeight) / scale + ) let window = NSWindow( contentRect: NSRect(origin: .zero, size: windowSize), @@ -25,14 +36,68 @@ class VPhoneWindowController { window.contentAspectRatio = windowSize window.title = "vphone" + window.subtitle = "daemon connecting..." window.contentView = vmView window.center() + // Toolbar with unified style for two-line title + let toolbar = NSToolbar(identifier: "vphone-toolbar") + toolbar.delegate = self + toolbar.displayMode = .iconOnly + window.toolbar = toolbar + window.toolbarStyle = .unified + let controller = NSWindowController(window: window) controller.showWindow(nil) windowController = controller + keyHelper.window = window window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) + + // Poll vphoned status for subtitle + statusTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { + [weak self, weak window] _ in + Task { @MainActor in + guard let self, let window, let control = self.control else { return } + window.subtitle = control.isConnected ? "daemon connected" : "daemon connecting..." + } + } + } + + // MARK: - NSToolbarDelegate + + nonisolated func toolbar( + _: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar _: Bool + ) -> NSToolbarItem? { + MainActor.assumeIsolated { + if itemIdentifier == Self.homeItemID { + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.label = "Home" + item.toolTip = "Home Button" + item.image = NSImage( + systemSymbolName: "circle.circle", accessibilityDescription: "Home" + ) + item.target = self + item.action = #selector(homePressed) + return item + } + return nil + } + } + + nonisolated func toolbarDefaultItemIdentifiers(_: NSToolbar) -> [NSToolbarItem.Identifier] { + [.flexibleSpace, Self.homeItemID] + } + + nonisolated func toolbarAllowedItemIdentifiers(_: NSToolbar) -> [NSToolbarItem.Identifier] { + [Self.homeItemID, .flexibleSpace, .space] + } + + // MARK: - Actions + + @objc private func homePressed() { + control?.sendHIDPress(page: 0x0C, usage: 0x40) } }