Skip vsock control connection in DFU mode

No guest OS is running in DFU, so attempting to connect causes
endless "Connection reset by peer, retrying..." log spam.
This commit is contained in:
Lakr
2026-03-02 18:36:12 +08:00
parent 4c74692ac2
commit e5fdad341f
21 changed files with 1835 additions and 517 deletions

View File

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

View File

@@ -22,6 +22,7 @@ let package = Package(
linkerSettings: [
.linkedFramework("Virtualization"),
.linkedFramework("AppKit"),
.linkedFramework("SwiftUI"),
]
),
]

View File

@@ -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
<key>com.apple.private.amfi.developer-mode-control</key>
<true/>
```
### 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.

View File

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

View File

@@ -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 <dlfcn.h>
#include <dispatch/dispatch.h>
#include <mach/mach_time.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
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;
}

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>platform-application</key>
<true/>
<key>com.apple.private.security.no-container</key>
<true/>
<key>com.apple.private.hid.client.event-dispatch</key>
<true/>
<key>com.apple.private.hid.client.event-filter</key>
<true/>
<key>com.apple.private.hid.client.event-monitor</key>
<true/>
<key>com.apple.private.hid.client.service-protected</key>
<true/>
<key>com.apple.private.hid.manager.client</key>
<true/>
<key>com.apple.backboard.client</key>
<true/>
</dict>
</plist>

View File

@@ -63,6 +63,8 @@
<true/>
<key>com.apple.private.allow-explicit-graphics-priority</key>
<true/>
<key>com.apple.private.amfi.developer-mode-control</key>
<true/>
<key>com.apple.private.hid.client.event-dispatch</key>
<true/>
<key>com.apple.private.hid.client.event-filter</key>
@@ -515,8 +517,16 @@
<true/>
<key>com.apple.usernotifications.legacy-extension</key>
<true/>
<key>com.apple.security.exception.files.absolute-path.read-write</key>
<array>
<string>/</string>
</array>
<key>file-read-data</key>
<true/>
<key>file-read-metadata</key>
<true/>
<key>file-write-data</key>
<true/>
<key>inter-app-audio</key>
<true/>
<key>keychain-access-groups</key>

BIN
scripts/vphoned/signcert.p12 Executable file

Binary file not shown.

View File

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

View File

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

View File

@@ -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<VZVirtioSocketConnection, any Error>) in
device.connect(toPort: Self.vsockPort) {
[weak self] (result: Result<VZVirtioSocketConnection, any Error>) 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<Void, any Error>) 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<UInt8>.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)

View File

@@ -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<VPhoneRemoteFile.ID>()
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)"
}
}
}

View File

@@ -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<VPhoneRemoteFile.ID>) -> 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<VPhoneRemoteFile.ID>) {
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<VPhoneRemoteFile.ID>) {
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<VPhoneRemoteFile.ID>) {
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)
}
}

View File

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

View File

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

View File

@@ -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<uint64_t>. 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<UInt64>.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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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