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