mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-04-05 13:09:06 +08:00
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:
29
Makefile
29
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" .
|
||||
|
||||
@@ -22,6 +22,7 @@ let package = Package(
|
||||
linkerSettings: [
|
||||
.linkedFramework("Virtualization"),
|
||||
.linkedFramework("AppKit"),
|
||||
.linkedFramework("SwiftUI"),
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
117
researchs/developer_mode_xpc.md
Normal file
117
researchs/developer_mode_xpc.md
Normal 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.
|
||||
@@ -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..."
|
||||
|
||||
119
scripts/unlock.c
119
scripts/unlock.c
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
BIN
scripts/vphoned/signcert.p12
Executable file
Binary file not shown.
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
227
sources/vphone-cli/VPhoneFileBrowserModel.swift
Normal file
227
sources/vphone-cli/VPhoneFileBrowserModel.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
344
sources/vphone-cli/VPhoneFileBrowserView.swift
Normal file
344
sources/vphone-cli/VPhoneFileBrowserView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
54
sources/vphone-cli/VPhoneFileWindowController.swift
Normal file
54
sources/vphone-cli/VPhoneFileWindowController.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
93
sources/vphone-cli/VPhoneRemoteFile.swift
Normal file
93
sources/vphone-cli/VPhoneRemoteFile.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user