From 8c7d9911a29dd0dd3ce843ae8ab6e0219843670f Mon Sep 17 00:00:00 2001 From: Lakr Date: Mon, 2 Mar 2026 19:28:53 +0800 Subject: [PATCH] Clean up location passthrough PR: consistent formatting and logging --- .gitignore | 2 + AGENTS.md | 52 +- Makefile | 18 +- scripts/vphoned/vphoned.m | 868 +++--------------- scripts/vphoned/vphoned_devmode.h | 22 + scripts/vphoned/vphoned_devmode.m | 107 +++ scripts/vphoned/vphoned_files.h | 13 + scripts/vphoned/vphoned_files.m | 236 +++++ scripts/vphoned/vphoned_hid.h | 18 + scripts/vphoned/vphoned_hid.m | 72 ++ scripts/vphoned/vphoned_location.h | 24 + scripts/vphoned/vphoned_location.m | 164 ++++ scripts/vphoned/vphoned_protocol.h | 22 + scripts/vphoned/vphoned_protocol.m | 64 ++ sources/vphone-cli/VPhoneAppDelegate.swift | 54 +- sources/vphone-cli/VPhoneControl.swift | 76 +- .../VPhoneFileWindowController.swift | 1 + sources/vphone-cli/VPhoneKeyHelper.swift | 2 +- .../vphone-cli/VPhoneLocationProvider.swift | 15 +- sources/vphone-cli/VPhoneMenuConnect.swift | 91 ++ sources/vphone-cli/VPhoneMenuController.swift | 90 +- sources/vphone-cli/VPhoneMenuKeys.swift | 38 + sources/vphone-cli/VPhoneMenuLocation.swift | 37 + sources/vphone-cli/VPhoneMenuType.swift | 17 + ...oneVM.swift => VPhoneVirtualMachine.swift} | 2 +- ...w.swift => VPhoneVirtualMachineView.swift} | 2 +- .../vphone-cli/VPhoneWindowController.swift | 3 +- 27 files changed, 1205 insertions(+), 905 deletions(-) create mode 100644 scripts/vphoned/vphoned_devmode.h create mode 100644 scripts/vphoned/vphoned_devmode.m create mode 100644 scripts/vphoned/vphoned_files.h create mode 100644 scripts/vphoned/vphoned_files.m create mode 100644 scripts/vphoned/vphoned_hid.h create mode 100644 scripts/vphoned/vphoned_hid.m create mode 100644 scripts/vphoned/vphoned_location.h create mode 100644 scripts/vphoned/vphoned_location.m create mode 100644 scripts/vphoned/vphoned_protocol.h create mode 100644 scripts/vphoned/vphoned_protocol.m create mode 100644 sources/vphone-cli/VPhoneMenuConnect.swift create mode 100644 sources/vphone-cli/VPhoneMenuKeys.swift create mode 100644 sources/vphone-cli/VPhoneMenuLocation.swift create mode 100644 sources/vphone-cli/VPhoneMenuType.swift rename sources/vphone-cli/{VPhoneVM.swift => VPhoneVirtualMachine.swift} (99%) rename sources/vphone-cli/{VPhoneVMView.swift => VPhoneVirtualMachineView.swift} (98%) diff --git a/.gitignore b/.gitignore index 0adcfdc..044828a 100644 --- a/.gitignore +++ b/.gitignore @@ -317,3 +317,5 @@ __marimo__/ TODO.md /references/ scripts/vphoned/vphoned +sources/vphone-cli/VPhoneBuildInfo.swift +setup_logs/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 19b3ee0..033d4cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,14 +35,46 @@ sources/ ├── main.swift # Entry point — NSApplication + AppDelegate ├── VPhoneAppDelegate.swift # App lifecycle, SIGINT, VM start/stop ├── VPhoneCLI.swift # ArgumentParser options (no execution logic) - ├── VPhoneVM.swift # @MainActor VM configuration and lifecycle + ├── VPhoneBuildInfo.swift # Auto-generated build-time commit hash + │ + │ # VM core + ├── VPhoneVirtualMachine.swift # @MainActor VM configuration and lifecycle ├── VPhoneHardwareModel.swift # PV=3 hardware model via Dynamic - ├── VPhoneVMView.swift # Touch-enabled VZVirtualMachineView + helpers - ├── VPhoneWindowController.swift # @MainActor window management + ├── VPhoneVirtualMachineView.swift # Touch-enabled VZVirtualMachineView + helpers ├── VPhoneError.swift # Error types - └── MainActor+Isolated.swift # MainActor.isolated helper + │ + │ # Guest daemon client (vsock) + ├── VPhoneControl.swift # Host-side vsock client for vphoned (length-prefixed JSON) + │ + │ # Window & UI + ├── VPhoneWindowController.swift # @MainActor VM window management + toolbar + ├── VPhoneKeyHelper.swift # Keyboard/hardware key event dispatch to VM + ├── VPhoneLocationProvider.swift # CoreLocation → guest forwarding over vsock + │ + │ # Menu bar (extensions on VPhoneMenuController) + ├── VPhoneMenuController.swift # Menu bar controller (builds Keys, Type, Location, Connect) + ├── VPhoneMenuKeys.swift # Keys menu — home, power, volume, spotlight buttons + ├── VPhoneMenuType.swift # Type menu — paste ASCII text to guest + ├── VPhoneMenuLocation.swift # Location menu — host location sync toggle + ├── VPhoneMenuConnect.swift # Connect menu — devmode, ping, version, file browser + │ + │ # File browser (SwiftUI) + ├── VPhoneFileWindowController.swift # File browser window (NSHostingController) + ├── VPhoneFileBrowserView.swift # SwiftUI file browser with search + drag-drop + ├── VPhoneFileBrowserModel.swift # @Observable file browser state + transfers + └── VPhoneRemoteFile.swift # Remote file data model (path, size, permissions) scripts/ +├── vphoned/ # Guest daemon (Objective-C, runs inside iOS VM) +│ ├── vphoned.m # Main — vsock listener, message dispatch, auto-update +│ ├── vphoned_protocol.{h,m} # Length-prefixed JSON framing (shared with host) +│ ├── vphoned_hid.{h,m} # HID event injection (IOHIDEvent) +│ ├── vphoned_devmode.{h,m} # Developer Mode query/enable via XPC +│ ├── vphoned_location.{h,m} # CLLocationManager spoofing +│ ├── vphoned_files.{h,m} # File operations (list, get, put, mkdir, delete, rename) +│ ├── vphoned.plist # LaunchDaemon plist +│ ├── entitlements.plist # Guest entitlements +│ └── signcert.p12 # Signing certificate for re-signing ├── patchers/ # Python patcher package │ ├── iboot.py # Dynamic iBoot patcher (iBSS/iBEC/LLB) │ ├── iboot_jb.py # JB extension iBoot patcher (nonce skip) @@ -76,9 +108,14 @@ researchs/ - **Private API access:** Private Virtualization.framework APIs are called via the [Dynamic](https://github.com/mhdhejazi/Dynamic) library (runtime method dispatch from pure Swift). No ObjC bridge needed. - **App lifecycle:** Explicit `main.swift` creates `NSApplication` + `VPhoneAppDelegate`. CLI args parsed before the run loop starts. AppDelegate drives VM start, window, and shutdown. -- **Configuration:** CLI options parsed via `ArgumentParser`, converted to `VPhoneVM.Options` struct, then used to build `VZVirtualMachineConfiguration`. +- **Configuration:** CLI options parsed via `ArgumentParser`, converted to `VPhoneVirtualMachine.Options` struct, then used to build `VZVirtualMachineConfiguration`. - **Error handling:** `VPhoneError` enum with `CustomStringConvertible` for user-facing messages. -- **Window management:** `VPhoneWindowController` wraps `NSWindow` + `VZVirtualMachineView`. Window size derived from configurable screen dimensions and scale factor. Touch input translated from mouse events to multi-touch via `VPhoneVMView`. +- **Window management:** `VPhoneWindowController` wraps `NSWindow` + `VZVirtualMachineView`. Window size derived from configurable screen dimensions and scale factor. Touch input translated from mouse events to multi-touch via `VPhoneVirtualMachineView`. +- **Guest daemon (vphoned):** ObjC daemon running inside the iOS VM as a LaunchDaemon. Communicates with host over vsock port 1337 using length-prefixed JSON (`[uint32 BE length][UTF-8 JSON]`). Handles HID injection, developer mode, location spoofing, and file operations. Host side is `VPhoneControl` which auto-reconnects and supports binary auto-update on connect. +- **Control protocol:** All commands use async request-response via `VPhoneControl.sendRequest()` with pending request tracking. Menu actions (`VPhoneMenuConnect`) await responses and show results as `NSAlert` sheets on the VM window. +- **Menu system:** `VPhoneMenuController` owns the menu bar, built from extensions in separate files per menu (Keys, Type, Location, Connect). Each extension has its own `build*Menu()` method. +- **File browser:** SwiftUI-based (`VPhoneFileBrowserView` + `VPhoneFileBrowserModel`) hosted in a separate `NSWindow` via `NSHostingController`. Supports search, sort, upload/download, drag-drop. File operations go through `VPhoneControl` async APIs. +- **Location sync:** `VPhoneLocationProvider` wraps `CLLocationManager`, forwards host Mac's GPS coordinates to the guest over vsock when toggled from the Location menu. --- @@ -255,8 +292,9 @@ AVPBooter (ROM, PCC) - **Sections:** Use `// MARK: -` to organize code within files. - **Access control:** Default (internal). Only mark `private` when needed for clarity. - **Concurrency:** `@MainActor` for VM and UI classes. `nonisolated` delegate methods use `MainActor.isolated {}` to hop back safely. -- **Naming:** Types are `VPhone`-prefixed (`VPhoneVM`, `VPhoneWindowController`). Match Apple framework conventions. +- **Naming:** Types are `VPhone`-prefixed (`VPhoneVirtualMachine`, `VPhoneWindowController`). Match Apple framework conventions. - **Private APIs:** Use `Dynamic()` for runtime method dispatch. Touch objects use `NSClassFromString` + KVC to avoid designated initializer crashes. +- **NSWindow `isReleasedWhenClosed`:** Always set `window.isReleasedWhenClosed = false` for programmatically created windows managed by an `NSWindowController`. The default is `true`, which causes the window to be released on close while `NSWindowController` and `_NSWindowTransformAnimation` still hold references — `objc_release` crashes on a dangling pointer during CA transaction commit. Nib-loaded windows handled by `NSWindowController` get this set automatically, but programmatic windows do not. ### Shell Scripts diff --git a/Makefile b/Makefile index 03f9954..deef1ed 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,10 @@ MEMORY ?= 8192 DISK_SIZE ?= 64 CFW_INPUT ?= cfw_input +# ─── Build info ────────────────────────────────────────────────── +GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +BUILD_INFO := sources/vphone-cli/VPhoneBuildInfo.swift + # ─── Paths ──────────────────────────────────────────────────────── SCRIPTS := scripts BINARY := .build/release/vphone-cli @@ -91,7 +95,9 @@ setup_libimobiledevice: build: $(BINARY) $(BINARY): $(SWIFT_SOURCES) Package.swift $(ENTITLEMENTS) - @echo "=== Building vphone-cli ===" + @echo "=== Building vphone-cli ($(GIT_HASH)) ===" + @echo '// Auto-generated — do not edit' > $(BUILD_INFO) + @echo 'enum VPhoneBuildInfo { static let commitHash = "$(GIT_HASH)" }' >> $(BUILD_INFO) swift build -c release 2>&1 | tail -5 @echo "" @echo "=== Signing with entitlements ===" @@ -114,15 +120,21 @@ clean: swift package clean rm -rf .build rm -f $(SCRIPTS)/vphoned/vphoned + rm -f $(BUILD_INFO) # Cross-compile vphoned daemon for iOS arm64 (installed into VM by cfw_install) .PHONY: vphoned vphoned: $(SCRIPTS)/vphoned/vphoned -$(SCRIPTS)/vphoned/vphoned: $(SCRIPTS)/vphoned/vphoned.m +VPHONED_SRCS := $(addprefix $(SCRIPTS)/vphoned/, \ + vphoned.m vphoned_protocol.m vphoned_hid.m \ + vphoned_devmode.m vphoned_location.m vphoned_files.m) +$(SCRIPTS)/vphoned/vphoned: $(VPHONED_SRCS) @echo "=== Building vphoned (arm64, iphoneos) ===" xcrun -sdk iphoneos clang -arch arm64 -Os -fobjc-arc \ - -o $@ $< -framework Foundation + -I$(SCRIPTS)/vphoned \ + -DVPHONED_BUILD_HASH='"$(GIT_HASH)"' \ + -o $@ $(VPHONED_SRCS) -framework Foundation @echo " built OK" # Sign vphoned with entitlements using cfw_input tools (requires make cfw_install to have unpacked cfw_input) diff --git a/scripts/vphoned/vphoned.m b/scripts/vphoned/vphoned.m index e98529e..a7a96a4 100644 --- a/scripts/vphoned/vphoned.m +++ b/scripts/vphoned/vphoned.m @@ -9,39 +9,33 @@ * it to CACHE_PATH and exit — launchd restarts us, and the bootstrap * code in main() exec's the cached binary. * - * Capabilities: - * 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] - * Every JSON object carries "v" (protocol version), "t" (type), - * and optionally "id" (request ID, echoed in responses). - * * Build: - * xcrun -sdk iphoneos clang -arch arm64 -Os -fobjc-arc \ - * -o vphoned vphoned.m -framework Foundation + * make vphoned */ #import #include -#include -#include -#include #include -#include -#include #include #include #include +#import "vphoned_protocol.h" +#import "vphoned_hid.h" +#import "vphoned_devmode.h" +#import "vphoned_location.h" +#import "vphoned_files.h" + #ifndef AF_VSOCK #define AF_VSOCK 40 #endif #define VMADDR_CID_ANY 0xFFFFFFFF #define VPHONED_PORT 1337 -#define PROTOCOL_VERSION 1 + +#ifndef VPHONED_BUILD_HASH +#define VPHONED_BUILD_HASH "unknown" +#endif #define INSTALL_PATH "/usr/bin/vphoned" #define CACHE_PATH "/var/root/Library/Caches/vphoned" @@ -86,729 +80,9 @@ static const char *self_executable_path(void) { return path; } -// MARK: - IOKit (matches TrollVNC's STHIDEventGenerator) - -typedef void *IOHIDEventSystemClientRef; -typedef void *IOHIDEventRef; - -static IOHIDEventSystemClientRef (*pCreate)(CFAllocatorRef); -static IOHIDEventRef (*pKeyboard)(CFAllocatorRef, uint64_t, - uint32_t, uint32_t, int, int); -static void (*pSetSender)(IOHIDEventRef, uint64_t); -static void (*pDispatch)(IOHIDEventSystemClientRef, IOHIDEventRef); - -static IOHIDEventSystemClientRef gClient; -static dispatch_queue_t gHIDQueue; - -static BOOL load_iokit(void) { - void *h = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_NOW); - if (!h) { NSLog(@"vphoned: dlopen IOKit failed"); return NO; } - - pCreate = dlsym(h, "IOHIDEventSystemClientCreate"); - pKeyboard = dlsym(h, "IOHIDEventCreateKeyboardEvent"); - pSetSender = dlsym(h, "IOHIDEventSetSenderID"); - pDispatch = dlsym(h, "IOHIDEventSystemClientDispatchEvent"); - - if (!pCreate || !pKeyboard || !pSetSender || !pDispatch) { - NSLog(@"vphoned: missing IOKit symbols"); - return NO; - } - - gClient = pCreate(kCFAllocatorDefault); - if (!gClient) { NSLog(@"vphoned: IOHIDEventSystemClientCreate returned NULL"); return NO; } - - dispatch_queue_attr_t attr = dispatch_queue_attr_make_with_qos_class( - DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INTERACTIVE, 0); - gHIDQueue = dispatch_queue_create("com.vphone.vphoned.hid", attr); - - NSLog(@"vphoned: IOKit loaded"); - return YES; -} - -static void send_hid_event(IOHIDEventRef event) { - IOHIDEventRef strong = (IOHIDEventRef)CFRetain(event); - dispatch_async(gHIDQueue, ^{ - pSetSender(strong, 0x8000000817319372); - pDispatch(gClient, strong); - CFRelease(strong); - }); -} - -static void press(uint32_t page, uint32_t usage) { - IOHIDEventRef down = pKeyboard(kCFAllocatorDefault, mach_absolute_time(), - page, usage, 1, 0); - if (!down) return; - send_hid_event(down); - CFRelease(down); - - usleep(100000); - - IOHIDEventRef up = pKeyboard(kCFAllocatorDefault, mach_absolute_time(), - page, usage, 0, 0); - if (!up) return; - send_hid_event(up); - 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: - CoreLocation Simulation - -static id gSimManager = nil; -static SEL gSetLocationSel = NULL; -static SEL gClearLocationsSel = NULL; -static SEL gFlushSel = NULL; -static SEL gStartSimSel = NULL; -static BOOL gLocationLoaded = NO; - -static BOOL load_corelocation(void) { - void *h = dlopen("/System/Library/Frameworks/CoreLocation.framework/CoreLocation", RTLD_NOW); - if (!h) { NSLog(@"vphoned: dlopen CoreLocation failed"); return NO; } - - Class cls = NSClassFromString(@"CLSimulationManager"); - if (!cls) { NSLog(@"vphoned: CLSimulationManager not found"); return NO; } - - gSimManager = [[cls alloc] init]; - if (!gSimManager) { NSLog(@"vphoned: CLSimulationManager alloc/init failed"); return NO; } - - // Probe available selectors for setting location - SEL candidates[] = { - NSSelectorFromString(@"setSimulatedLocation:"), - NSSelectorFromString(@"appendSimulatedLocation:"), - NSSelectorFromString(@"setLocation:"), - }; - for (int i = 0; i < 3; i++) { - if ([gSimManager respondsToSelector:candidates[i]]) { - gSetLocationSel = candidates[i]; - break; - } - } - if (!gSetLocationSel) { - NSLog(@"vphoned: no set-location selector found, dumping methods:"); - unsigned int count = 0; - Method *methods = class_copyMethodList([gSimManager class], &count); - for (unsigned int i = 0; i < count; i++) { - NSLog(@" %s", sel_getName(method_getName(methods[i]))); - } - free(methods); - return NO; - } - - // Probe clear selector - SEL clearCandidates[] = { - NSSelectorFromString(@"clearSimulatedLocations"), - NSSelectorFromString(@"stopLocationSimulation"), - }; - for (int i = 0; i < 2; i++) { - if ([gSimManager respondsToSelector:clearCandidates[i]]) { - gClearLocationsSel = clearCandidates[i]; - break; - } - } - - // Probe flush selector - SEL flushCandidates[] = { - NSSelectorFromString(@"flush"), - NSSelectorFromString(@"flushSimulatedLocations"), - }; - for (int i = 0; i < 2; i++) { - if ([gSimManager respondsToSelector:flushCandidates[i]]) { - gFlushSel = flushCandidates[i]; - break; - } - } - - // Probe startLocationSimulation selector - SEL startCandidates[] = { - NSSelectorFromString(@"startLocationSimulation"), - NSSelectorFromString(@"startSimulation"), - }; - for (int i = 0; i < 2; i++) { - if ([gSimManager respondsToSelector:startCandidates[i]]) { - gStartSimSel = startCandidates[i]; - break; - } - } - - // Start simulation session if available - if (gStartSimSel) { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - [gSimManager performSelector:gStartSimSel]; -#pragma clang diagnostic pop - } - - NSLog(@"vphoned: CoreLocation simulation loaded (set=%s, clear=%s, flush=%s, start=%s)", - sel_getName(gSetLocationSel), - gClearLocationsSel ? sel_getName(gClearLocationsSel) : "(none)", - gFlushSel ? sel_getName(gFlushSel) : "(none)", - gStartSimSel ? sel_getName(gStartSimSel) : "(none)"); - gLocationLoaded = YES; - return YES; -} - -static void simulate_location(double lat, double lon, double alt, - double hacc, double vacc, - double speed, double course) { - if (!gLocationLoaded || !gSimManager || !gSetLocationSel) return; - - @try { - // CLLocationCoordinate2D is {double latitude, double longitude} - typedef struct { double latitude; double longitude; } CLCoord2D; - CLCoord2D coord = {lat, lon}; - - // Use full CLLocation init including speed and course - Class locClass = NSClassFromString(@"CLLocation"); - id locInst = [locClass alloc]; - SEL initSel = NSSelectorFromString( - @"initWithCoordinate:altitude:horizontalAccuracy:verticalAccuracy:course:speed:timestamp:"); - if (![locInst respondsToSelector:initSel]) { - // Fallback to simpler init - initSel = NSSelectorFromString( - @"initWithCoordinate:altitude:horizontalAccuracy:verticalAccuracy:timestamp:"); - typedef id (*InitFunc5)(id, SEL, CLCoord2D, double, double, double, id); - id location = ((InitFunc5)objc_msgSend)(locInst, initSel, coord, alt, hacc, vacc, [NSDate date]); - -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - [gSimManager performSelector:gSetLocationSel withObject:location]; - if (gFlushSel) [gSimManager performSelector:gFlushSel]; -#pragma clang diagnostic pop - - NSLog(@"vphoned: simulate_location lat=%.6f lon=%.6f (fallback init) sel=%s%s", - lat, lon, sel_getName(gSetLocationSel), - gFlushSel ? " (flushed)" : ""); - return; - } - - typedef id (*InitFunc7)(id, SEL, CLCoord2D, double, double, double, double, double, id); - id location = ((InitFunc7)objc_msgSend)(locInst, initSel, coord, alt, hacc, vacc, course, speed, [NSDate date]); - -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - [gSimManager performSelector:gSetLocationSel withObject:location]; - if (gFlushSel) { - [gSimManager performSelector:gFlushSel]; - } -#pragma clang diagnostic pop - - NSLog(@"vphoned: simulate_location lat=%.6f lon=%.6f alt=%.1f spd=%.1f crs=%.1f sel=%s%s", - lat, lon, alt, speed, course, sel_getName(gSetLocationSel), - gFlushSel ? " (flushed)" : ""); - } @catch (NSException *e) { - NSLog(@"vphoned: simulate_location exception: %@", e); - } -} - -static void clear_simulated_location(void) { - if (!gLocationLoaded || !gSimManager) return; - @try { - if (gClearLocationsSel) { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - [gSimManager performSelector:gClearLocationsSel]; -#pragma clang diagnostic pop - NSLog(@"vphoned: cleared simulated location"); - } - } @catch (NSException *e) { - NSLog(@"vphoned: clear_simulated_location exception: %@", e); - } -} - -// MARK: - Protocol Framing - -static BOOL read_fully(int fd, void *buf, size_t count) { - size_t offset = 0; - while (offset < count) { - ssize_t n = read(fd, (uint8_t *)buf + offset, count - offset); - if (n <= 0) return NO; - offset += n; - } - 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; - uint32_t length = ntohl(header); - if (length == 0 || length > 4 * 1024 * 1024) return nil; - - NSMutableData *payload = [NSMutableData dataWithLength:length]; - if (!read_fully(fd, payload.mutableBytes, length)) return nil; - - NSError *err = nil; - id obj = [NSJSONSerialization JSONObjectWithData:payload options:0 error:&err]; - if (![obj isKindOfClass:[NSDictionary class]]) return nil; - return obj; -} - -static BOOL write_message(int fd, NSDictionary *dict) { - NSError *err = nil; - NSData *json = [NSJSONSerialization dataWithJSONObject:dict options:0 error:&err]; - if (!json) return NO; - - uint32_t header = htonl((uint32_t)json.length); - if (write(fd, &header, 4) != 4) return NO; - if (write(fd, json.bytes, json.length) != (ssize_t)json.length) return NO; - return YES; -} - -// MARK: - Response Helper - -/// Build a response dict with protocol version, type, and optional request ID echo. -static NSMutableDictionary *make_response(NSString *type, id reqId) { - NSMutableDictionary *r = [@{@"v": @PROTOCOL_VERSION, @"t": type} mutableCopy]; - if (reqId) r[@"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) { - NSString *type = msg[@"t"]; - id reqId = msg[@"id"]; - - if ([type isEqualToString:@"hid"]) { - uint32_t page = [msg[@"page"] unsignedIntValue]; - uint32_t usage = [msg[@"usage"] unsignedIntValue]; - NSNumber *downVal = msg[@"down"]; - if (downVal != nil) { - // 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); - } - - if ([type isEqualToString:@"location"]) { - double lat = [msg[@"lat"] doubleValue]; - double lon = [msg[@"lon"] doubleValue]; - double alt = [msg[@"alt"] doubleValue]; - double hacc = [msg[@"hacc"] doubleValue]; - double vacc = [msg[@"vacc"] doubleValue]; - double speed = [msg[@"speed"] doubleValue]; - double course = [msg[@"course"] doubleValue]; - simulate_location(lat, lon, alt, hacc, vacc, speed, course); - return make_response(@"ok", reqId); - } - - if ([type isEqualToString:@"location_stop"]) { - clear_simulated_location(); - return make_response(@"ok", reqId); - } - - NSMutableDictionary *r = make_response(@"err", reqId); - r[@"msg"] = [NSString stringWithFormat:@"unknown type: %@", type]; - return r; -} - // MARK: - Auto-update /// Receive raw binary from host, write to CACHE_PATH, chmod +x. -/// Returns YES on success. static BOOL receive_update(int fd, NSUInteger size) { mkdir(CACHE_DIR, 0755); @@ -823,7 +97,7 @@ static BOOL receive_update(int fd, NSUInteger size) { NSUInteger remaining = size; while (remaining > 0) { size_t chunk = remaining < sizeof(buf) ? remaining : sizeof(buf); - if (!read_fully(fd, buf, chunk)) { + if (!vp_read_fully(fd, buf, chunk)) { NSLog(@"vphoned: update read failed at %lu/%lu", (unsigned long)(size - remaining), (unsigned long)size); close(tmp_fd); @@ -851,13 +125,95 @@ static BOOL receive_update(int fd, NSUInteger size) { return YES; } +// MARK: - Command Dispatch + +static NSDictionary *handle_command(NSDictionary *msg) { + NSString *type = msg[@"t"]; + id reqId = msg[@"id"]; + + if ([type isEqualToString:@"hid"]) { + uint32_t page = [msg[@"page"] unsignedIntValue]; + uint32_t usage = [msg[@"usage"] unsignedIntValue]; + NSNumber *downVal = msg[@"down"]; + if (downVal != nil) { + vp_hid_key(page, usage, [downVal boolValue]); + } else { + vp_hid_press(page, usage); + } + return vp_make_response(@"ok", reqId); + } + + if ([type isEqualToString:@"devmode"]) { + if (!vp_devmode_available()) { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = @"XPC not available"; + return r; + } + NSString *action = msg[@"action"]; + if ([action isEqualToString:@"status"]) { + BOOL enabled = vp_devmode_status(); + NSMutableDictionary *r = vp_make_response(@"ok", reqId); + r[@"enabled"] = @(enabled); + return r; + } + if ([action isEqualToString:@"enable"]) { + BOOL alreadyEnabled = NO; + BOOL ok = vp_devmode_arm(&alreadyEnabled); + NSMutableDictionary *r = vp_make_response(ok ? @"ok" : @"err", reqId); + if (ok) { + r[@"already_enabled"] = @(alreadyEnabled); + r[@"msg"] = alreadyEnabled + ? @"developer mode already enabled" + : @"developer mode armed, reboot to activate"; + } else { + r[@"msg"] = @"failed to arm developer mode"; + } + return r; + } + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = [NSString stringWithFormat:@"unknown devmode action: %@", action]; + return r; + } + + if ([type isEqualToString:@"ping"]) { + return vp_make_response(@"pong", reqId); + } + + if ([type isEqualToString:@"location"]) { + double lat = [msg[@"lat"] doubleValue]; + double lon = [msg[@"lon"] doubleValue]; + double alt = [msg[@"alt"] doubleValue]; + double hacc = [msg[@"hacc"] doubleValue]; + double vacc = [msg[@"vacc"] doubleValue]; + double speed = [msg[@"speed"] doubleValue]; + double course = [msg[@"course"] doubleValue]; + vp_location_simulate(lat, lon, alt, hacc, vacc, speed, course); + return vp_make_response(@"ok", reqId); + } + + if ([type isEqualToString:@"location_stop"]) { + vp_location_clear(); + return vp_make_response(@"ok", reqId); + } + + if ([type isEqualToString:@"version"]) { + NSMutableDictionary *r = vp_make_response(@"version", reqId); + r[@"hash"] = @VPHONED_BUILD_HASH; + return r; + } + + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = [NSString stringWithFormat:@"unknown type: %@", type]; + return r; +} + // MARK: - Client Session /// Returns YES if daemon should exit for restart (after update). static BOOL handle_client(int fd) { BOOL should_restart = NO; @autoreleasepool { - NSDictionary *hello = read_message(fd); + NSDictionary *hello = vp_read_message(fd); if (!hello) { close(fd); return NO; } NSInteger version = [hello[@"v"] integerValue]; @@ -872,8 +228,8 @@ static BOOL handle_client(int fd) { if (version != PROTOCOL_VERSION) { NSLog(@"vphoned: version mismatch (client v%ld, daemon v%d)", (long)version, PROTOCOL_VERSION); - write_message(fd, @{@"v": @PROTOCOL_VERSION, @"t": @"err", - @"msg": @"version mismatch"}); + vp_write_message(fd, @{@"v": @PROTOCOL_VERSION, @"t": @"err", + @"msg": @"version mismatch"}); close(fd); return NO; } @@ -892,20 +248,24 @@ static BOOL handle_client(int fd) { } } + // Build capabilities list + NSMutableArray *caps = [NSMutableArray arrayWithObjects:@"hid", @"devmode", @"file", nil]; + if (vp_location_available()) [caps addObject:@"location"]; + NSMutableDictionary *helloResp = [@{ @"v": @PROTOCOL_VERSION, @"t": @"hello", @"name": @"vphoned", - @"caps": gLocationLoaded ? @[@"hid", @"devmode", @"file", @"location"] : @[@"hid", @"devmode", @"file"], + @"caps": caps, } mutableCopy]; if (needUpdate) helloResp[@"need_update"] = @YES; - if (!write_message(fd, helloResp)) { close(fd); return NO; } + if (!vp_write_message(fd, helloResp)) { close(fd); return NO; } NSLog(@"vphoned: client connected (v%d)%s", PROTOCOL_VERSION, needUpdate ? " [update pending]" : ""); NSDictionary *msg; - while ((msg = read_message(fd)) != nil) { + while ((msg = vp_read_message(fd)) != nil) { @autoreleasepool { NSString *t = msg[@"t"]; NSLog(@"vphoned: recv cmd: %@", t); @@ -915,28 +275,28 @@ static BOOL handle_client(int fd) { id reqId = msg[@"id"]; NSLog(@"vphoned: receiving update (%lu bytes)", (unsigned long)size); if (size > 0 && size < 10 * 1024 * 1024 && receive_update(fd, size)) { - NSMutableDictionary *r = make_response(@"ok", reqId); + NSMutableDictionary *r = vp_make_response(@"ok", reqId); r[@"msg"] = @"updated, restarting"; - write_message(fd, r); + vp_write_message(fd, r); should_restart = YES; break; } else { - NSMutableDictionary *r = make_response(@"err", reqId); + NSMutableDictionary *r = vp_make_response(@"err", reqId); r[@"msg"] = @"update failed"; - write_message(fd, r); + vp_write_message(fd, r); } 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; + NSDictionary *resp = vp_handle_file_command(fd, msg); + if (resp && !vp_write_message(fd, resp)) break; continue; } NSDictionary *resp = handle_command(msg); - if (resp && !write_message(fd, resp)) break; + if (resp && !vp_write_message(fd, resp)) break; } } @@ -961,9 +321,9 @@ 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"); - load_corelocation(); + if (!vp_hid_load()) return 1; + if (!vp_devmode_load()) NSLog(@"vphoned: XPC unavailable, devmode disabled"); + vp_location_load(); int sock = socket(AF_VSOCK, SOCK_STREAM, 0); if (sock < 0) { perror("vphoned: socket(AF_VSOCK)"); return 1; } diff --git a/scripts/vphoned/vphoned_devmode.h b/scripts/vphoned/vphoned_devmode.h new file mode 100644 index 0000000..ad89848 --- /dev/null +++ b/scripts/vphoned/vphoned_devmode.h @@ -0,0 +1,22 @@ +/* + * vphoned_devmode — Developer mode control via 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 + */ + +#pragma once +#import + +/// Load XPC/CoreFoundation symbols. Returns NO on failure (devmode disabled). +BOOL vp_devmode_load(void); + +/// Whether devmode XPC is available (load succeeded). +BOOL vp_devmode_available(void); + +/// Query current developer mode status. +BOOL vp_devmode_status(void); + +/// Arm developer mode. Returns YES on success. Sets *alreadyEnabled if already on. +BOOL vp_devmode_arm(BOOL *alreadyEnabled); diff --git a/scripts/vphoned/vphoned_devmode.m b/scripts/vphoned/vphoned_devmode.m new file mode 100644 index 0000000..0df931d --- /dev/null +++ b/scripts/vphoned/vphoned_devmode.m @@ -0,0 +1,107 @@ +#import "vphoned_devmode.h" +#include + +// 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 gXPCLoaded = NO; + +BOOL vp_devmode_load(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"); + gXPCLoaded = YES; + return YES; +} + +BOOL vp_devmode_available(void) { + return gXPCLoaded; +} + +// MARK: - AMFI XPC + +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; +} + +BOOL vp_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]; +} + +BOOL vp_devmode_arm(BOOL *alreadyEnabled) { + BOOL enabled = vp_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]; +} diff --git a/scripts/vphoned/vphoned_files.h b/scripts/vphoned/vphoned_files.h new file mode 100644 index 0000000..8758147 --- /dev/null +++ b/scripts/vphoned/vphoned_files.h @@ -0,0 +1,13 @@ +/* + * vphoned_files — Remote file operations over vsock. + * + * Handles 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. + */ + +#pragma once +#import + +/// Handle a file command. Returns a response dict, or nil if the response +/// was already written inline (e.g. file_get with streaming data). +NSDictionary *vp_handle_file_command(int fd, NSDictionary *msg); diff --git a/scripts/vphoned/vphoned_files.m b/scripts/vphoned/vphoned_files.m new file mode 100644 index 0000000..c58506f --- /dev/null +++ b/scripts/vphoned/vphoned_files.m @@ -0,0 +1,236 @@ +#import "vphoned_files.h" +#import "vphoned_protocol.h" +#include +#include + +NSDictionary *vp_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 = vp_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 = vp_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 = vp_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 = vp_make_response(@"err", reqId); + r[@"msg"] = @"missing path"; + return r; + } + + int fileFd = open([path fileSystemRepresentation], O_RDONLY); + if (fileFd < 0) { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = [NSString stringWithFormat:@"open failed: %s", strerror(errno)]; + return r; + } + + struct stat st; + if (fstat(fileFd, &st) != 0) { + close(fileFd); + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = [NSString stringWithFormat:@"stat failed: %s", strerror(errno)]; + return r; + } + if (!S_ISREG(st.st_mode)) { + close(fileFd); + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = @"not a regular file"; + return r; + } + + // Send header with file size + NSMutableDictionary *header = vp_make_response(@"file_data", reqId); + header[@"size"] = @((unsigned long long)st.st_size); + if (!vp_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 (!vp_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) vp_drain(fd, size); + NSMutableDictionary *r = vp_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) + 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) { + vp_drain(fd, size); + NSMutableDictionary *r = vp_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 (!vp_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 = vp_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 = vp_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 vp_make_response(@"ok", reqId); + } + + // -- file_mkdir -- + if ([type isEqualToString:@"file_mkdir"]) { + NSString *path = msg[@"path"]; + if (!path) { + NSMutableDictionary *r = vp_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 = vp_make_response(@"err", reqId); + r[@"msg"] = err.localizedDescription ?: @"mkdir failed"; + return r; + } + return vp_make_response(@"ok", reqId); + } + + // -- file_delete -- + if ([type isEqualToString:@"file_delete"]) { + NSString *path = msg[@"path"]; + if (!path) { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = @"missing path"; + return r; + } + NSError *err = nil; + if (![[NSFileManager defaultManager] removeItemAtPath:path error:&err]) { + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = err.localizedDescription ?: @"delete failed"; + return r; + } + return vp_make_response(@"ok", reqId); + } + + // -- file_rename -- + if ([type isEqualToString:@"file_rename"]) { + NSString *from = msg[@"from"]; + NSString *to = msg[@"to"]; + if (!from || !to) { + NSMutableDictionary *r = vp_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 = vp_make_response(@"err", reqId); + r[@"msg"] = err.localizedDescription ?: @"rename failed"; + return r; + } + return vp_make_response(@"ok", reqId); + } + + NSMutableDictionary *r = vp_make_response(@"err", reqId); + r[@"msg"] = [NSString stringWithFormat:@"unknown file command: %@", type]; + return r; +} diff --git a/scripts/vphoned/vphoned_hid.h b/scripts/vphoned/vphoned_hid.h new file mode 100644 index 0000000..510d616 --- /dev/null +++ b/scripts/vphoned/vphoned_hid.h @@ -0,0 +1,18 @@ +/* + * vphoned_hid — HID event injection via IOKit private API. + * + * Matches TrollVNC's STHIDEventGenerator approach: create an + * IOHIDEventSystemClient, fabricate keyboard events, and dispatch. + */ + +#pragma once +#import + +/// Load IOKit symbols and create HID event client. Returns NO on failure. +BOOL vp_hid_load(void); + +/// Send a full key press (down + 100ms delay + up). +void vp_hid_press(uint32_t page, uint32_t usage); + +/// Send a single key down or key up event. +void vp_hid_key(uint32_t page, uint32_t usage, BOOL down); diff --git a/scripts/vphoned/vphoned_hid.m b/scripts/vphoned/vphoned_hid.m new file mode 100644 index 0000000..463641e --- /dev/null +++ b/scripts/vphoned/vphoned_hid.m @@ -0,0 +1,72 @@ +#import "vphoned_hid.h" +#include +#include +#include + +typedef void *IOHIDEventSystemClientRef; +typedef void *IOHIDEventRef; + +static IOHIDEventSystemClientRef (*pCreate)(CFAllocatorRef); +static IOHIDEventRef (*pKeyboard)(CFAllocatorRef, uint64_t, + uint32_t, uint32_t, int, int); +static void (*pSetSender)(IOHIDEventRef, uint64_t); +static void (*pDispatch)(IOHIDEventSystemClientRef, IOHIDEventRef); + +static IOHIDEventSystemClientRef gClient; +static dispatch_queue_t gHIDQueue; + +BOOL vp_hid_load(void) { + void *h = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_NOW); + if (!h) { NSLog(@"vphoned: dlopen IOKit failed"); return NO; } + + pCreate = dlsym(h, "IOHIDEventSystemClientCreate"); + pKeyboard = dlsym(h, "IOHIDEventCreateKeyboardEvent"); + pSetSender = dlsym(h, "IOHIDEventSetSenderID"); + pDispatch = dlsym(h, "IOHIDEventSystemClientDispatchEvent"); + + if (!pCreate || !pKeyboard || !pSetSender || !pDispatch) { + NSLog(@"vphoned: missing IOKit symbols"); + return NO; + } + + gClient = pCreate(kCFAllocatorDefault); + if (!gClient) { NSLog(@"vphoned: IOHIDEventSystemClientCreate returned NULL"); return NO; } + + dispatch_queue_attr_t attr = dispatch_queue_attr_make_with_qos_class( + DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INTERACTIVE, 0); + gHIDQueue = dispatch_queue_create("com.vphone.vphoned.hid", attr); + + NSLog(@"vphoned: IOKit loaded"); + return YES; +} + +static void send_hid_event(IOHIDEventRef event) { + IOHIDEventRef strong = (IOHIDEventRef)CFRetain(event); + dispatch_async(gHIDQueue, ^{ + pSetSender(strong, 0x8000000817319372); + pDispatch(gClient, strong); + CFRelease(strong); + }); +} + +void vp_hid_press(uint32_t page, uint32_t usage) { + IOHIDEventRef down = pKeyboard(kCFAllocatorDefault, mach_absolute_time(), + page, usage, 1, 0); + if (!down) return; + send_hid_event(down); + CFRelease(down); + + usleep(100000); + + IOHIDEventRef up = pKeyboard(kCFAllocatorDefault, mach_absolute_time(), + page, usage, 0, 0); + if (!up) return; + send_hid_event(up); + CFRelease(up); +} + +void vp_hid_key(uint32_t page, uint32_t usage, BOOL down) { + IOHIDEventRef ev = pKeyboard(kCFAllocatorDefault, mach_absolute_time(), + page, usage, down ? 1 : 0, 0); + if (ev) { send_hid_event(ev); CFRelease(ev); } +} diff --git a/scripts/vphoned/vphoned_location.h b/scripts/vphoned/vphoned_location.h new file mode 100644 index 0000000..62fb7ff --- /dev/null +++ b/scripts/vphoned/vphoned_location.h @@ -0,0 +1,24 @@ +/* + * vphoned_location — CoreLocation simulation via CLSimulationManager. + * + * Uses private CLSimulationManager API to inject simulated GPS coordinates + * into the guest. Probes available selectors at runtime since the API + * varies across iOS versions. + */ + +#pragma once +#import + +/// Load CoreLocation and probe CLSimulationManager selectors. Returns NO on failure. +BOOL vp_location_load(void); + +/// Whether location simulation is available (load succeeded). +BOOL vp_location_available(void); + +/// Simulate a GPS location update. +void vp_location_simulate(double lat, double lon, double alt, + double hacc, double vacc, + double speed, double course); + +/// Clear simulated location. +void vp_location_clear(void); diff --git a/scripts/vphoned/vphoned_location.m b/scripts/vphoned/vphoned_location.m new file mode 100644 index 0000000..e27bf84 --- /dev/null +++ b/scripts/vphoned/vphoned_location.m @@ -0,0 +1,164 @@ +#import "vphoned_location.h" +#include +#include +#include + +static id gSimManager = nil; +static SEL gSetLocationSel = NULL; +static SEL gClearLocationsSel = NULL; +static SEL gFlushSel = NULL; +static SEL gStartSimSel = NULL; +static BOOL gLocationLoaded = NO; + +BOOL vp_location_load(void) { + void *h = dlopen("/System/Library/Frameworks/CoreLocation.framework/CoreLocation", RTLD_NOW); + if (!h) { NSLog(@"vphoned: dlopen CoreLocation failed"); return NO; } + + Class cls = NSClassFromString(@"CLSimulationManager"); + if (!cls) { NSLog(@"vphoned: CLSimulationManager not found"); return NO; } + + gSimManager = [[cls alloc] init]; + if (!gSimManager) { NSLog(@"vphoned: CLSimulationManager alloc/init failed"); return NO; } + + // Probe available selectors for setting location + SEL candidates[] = { + NSSelectorFromString(@"setSimulatedLocation:"), + NSSelectorFromString(@"appendSimulatedLocation:"), + NSSelectorFromString(@"setLocation:"), + }; + for (int i = 0; i < 3; i++) { + if ([gSimManager respondsToSelector:candidates[i]]) { + gSetLocationSel = candidates[i]; + break; + } + } + if (!gSetLocationSel) { + NSLog(@"vphoned: no set-location selector found, dumping methods:"); + unsigned int count = 0; + Method *methods = class_copyMethodList([gSimManager class], &count); + for (unsigned int i = 0; i < count; i++) { + NSLog(@" %s", sel_getName(method_getName(methods[i]))); + } + free(methods); + return NO; + } + + // Probe clear selector + SEL clearCandidates[] = { + NSSelectorFromString(@"clearSimulatedLocations"), + NSSelectorFromString(@"stopLocationSimulation"), + }; + for (int i = 0; i < 2; i++) { + if ([gSimManager respondsToSelector:clearCandidates[i]]) { + gClearLocationsSel = clearCandidates[i]; + break; + } + } + + // Probe flush selector + SEL flushCandidates[] = { + NSSelectorFromString(@"flush"), + NSSelectorFromString(@"flushSimulatedLocations"), + }; + for (int i = 0; i < 2; i++) { + if ([gSimManager respondsToSelector:flushCandidates[i]]) { + gFlushSel = flushCandidates[i]; + break; + } + } + + // Probe startLocationSimulation selector + SEL startCandidates[] = { + NSSelectorFromString(@"startLocationSimulation"), + NSSelectorFromString(@"startSimulation"), + }; + for (int i = 0; i < 2; i++) { + if ([gSimManager respondsToSelector:startCandidates[i]]) { + gStartSimSel = startCandidates[i]; + break; + } + } + + // Start simulation session if available + if (gStartSimSel) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [gSimManager performSelector:gStartSimSel]; +#pragma clang diagnostic pop + } + + NSLog(@"vphoned: CoreLocation simulation loaded (set=%s, clear=%s, flush=%s, start=%s)", + sel_getName(gSetLocationSel), + gClearLocationsSel ? sel_getName(gClearLocationsSel) : "(none)", + gFlushSel ? sel_getName(gFlushSel) : "(none)", + gStartSimSel ? sel_getName(gStartSimSel) : "(none)"); + gLocationLoaded = YES; + return YES; +} + +BOOL vp_location_available(void) { + return gLocationLoaded; +} + +void vp_location_simulate(double lat, double lon, double alt, + double hacc, double vacc, + double speed, double course) { + if (!gLocationLoaded || !gSimManager || !gSetLocationSel) return; + + @try { + typedef struct { double latitude; double longitude; } CLCoord2D; + CLCoord2D coord = {lat, lon}; + + Class locClass = NSClassFromString(@"CLLocation"); + id locInst = [locClass alloc]; + + // Try full init including speed and course + SEL initSel = NSSelectorFromString( + @"initWithCoordinate:altitude:horizontalAccuracy:verticalAccuracy:course:speed:timestamp:"); + if (![locInst respondsToSelector:initSel]) { + // Fallback to simpler init + initSel = NSSelectorFromString( + @"initWithCoordinate:altitude:horizontalAccuracy:verticalAccuracy:timestamp:"); + typedef id (*InitFunc5)(id, SEL, CLCoord2D, double, double, double, id); + id location = ((InitFunc5)objc_msgSend)(locInst, initSel, coord, alt, hacc, vacc, [NSDate date]); + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [gSimManager performSelector:gSetLocationSel withObject:location]; + if (gFlushSel) [gSimManager performSelector:gFlushSel]; +#pragma clang diagnostic pop + + NSLog(@"vphoned: simulate_location lat=%.6f lon=%.6f (fallback init)", lat, lon); + return; + } + + typedef id (*InitFunc7)(id, SEL, CLCoord2D, double, double, double, double, double, id); + id location = ((InitFunc7)objc_msgSend)(locInst, initSel, coord, alt, hacc, vacc, course, speed, [NSDate date]); + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [gSimManager performSelector:gSetLocationSel withObject:location]; + if (gFlushSel) [gSimManager performSelector:gFlushSel]; +#pragma clang diagnostic pop + + NSLog(@"vphoned: simulate_location lat=%.6f lon=%.6f alt=%.1f spd=%.1f crs=%.1f", + lat, lon, alt, speed, course); + } @catch (NSException *e) { + NSLog(@"vphoned: simulate_location exception: %@", e); + } +} + +void vp_location_clear(void) { + if (!gLocationLoaded || !gSimManager) return; + @try { + if (gClearLocationsSel) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [gSimManager performSelector:gClearLocationsSel]; +#pragma clang diagnostic pop + NSLog(@"vphoned: cleared simulated location"); + } + } @catch (NSException *e) { + NSLog(@"vphoned: clear_simulated_location exception: %@", e); + } +} diff --git a/scripts/vphoned/vphoned_protocol.h b/scripts/vphoned/vphoned_protocol.h new file mode 100644 index 0000000..1d41a39 --- /dev/null +++ b/scripts/vphoned/vphoned_protocol.h @@ -0,0 +1,22 @@ +/* + * vphoned_protocol — Length-prefixed JSON framing over vsock. + * + * Each message: [uint32 big-endian length][UTF-8 JSON payload] + */ + +#pragma once +#import + +#define PROTOCOL_VERSION 1 + +BOOL vp_read_fully(int fd, void *buf, size_t count); +BOOL vp_write_fully(int fd, const void *buf, size_t count); + +/// Discard exactly `size` bytes from fd. Used to keep protocol in sync on error paths. +void vp_drain(int fd, size_t size); + +NSDictionary *vp_read_message(int fd); +BOOL vp_write_message(int fd, NSDictionary *dict); + +/// Build a response dict with protocol version, type, and optional request ID echo. +NSMutableDictionary *vp_make_response(NSString *type, id reqId); diff --git a/scripts/vphoned/vphoned_protocol.m b/scripts/vphoned/vphoned_protocol.m new file mode 100644 index 0000000..c7edf28 --- /dev/null +++ b/scripts/vphoned/vphoned_protocol.m @@ -0,0 +1,64 @@ +#import "vphoned_protocol.h" +#include + +BOOL vp_read_fully(int fd, void *buf, size_t count) { + size_t offset = 0; + while (offset < count) { + ssize_t n = read(fd, (uint8_t *)buf + offset, count - offset); + if (n <= 0) return NO; + offset += n; + } + return YES; +} + +BOOL vp_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; +} + +void vp_drain(int fd, size_t size) { + uint8_t buf[32768]; + size_t remaining = size; + while (remaining > 0) { + size_t chunk = remaining < sizeof(buf) ? remaining : sizeof(buf); + if (!vp_read_fully(fd, buf, chunk)) break; + remaining -= chunk; + } +} + +NSDictionary *vp_read_message(int fd) { + uint32_t header = 0; + if (!vp_read_fully(fd, &header, 4)) return nil; + uint32_t length = ntohl(header); + if (length == 0 || length > 4 * 1024 * 1024) return nil; + + NSMutableData *payload = [NSMutableData dataWithLength:length]; + if (!vp_read_fully(fd, payload.mutableBytes, length)) return nil; + + NSError *err = nil; + id obj = [NSJSONSerialization JSONObjectWithData:payload options:0 error:&err]; + if (![obj isKindOfClass:[NSDictionary class]]) return nil; + return obj; +} + +BOOL vp_write_message(int fd, NSDictionary *dict) { + NSError *err = nil; + NSData *json = [NSJSONSerialization dataWithJSONObject:dict options:0 error:&err]; + if (!json) return NO; + + uint32_t header = htonl((uint32_t)json.length); + if (!vp_write_fully(fd, &header, 4)) return NO; + if (!vp_write_fully(fd, json.bytes, json.length)) return NO; + return YES; +} + +NSMutableDictionary *vp_make_response(NSString *type, id reqId) { + NSMutableDictionary *r = [@{@"v": @PROTOCOL_VERSION, @"t": type} mutableCopy]; + if (reqId) r[@"id"] = reqId; + return r; +} diff --git a/sources/vphone-cli/VPhoneAppDelegate.swift b/sources/vphone-cli/VPhoneAppDelegate.swift index 32ae69c..93f96aa 100644 --- a/sources/vphone-cli/VPhoneAppDelegate.swift +++ b/sources/vphone-cli/VPhoneAppDelegate.swift @@ -4,7 +4,7 @@ import Virtualization class VPhoneAppDelegate: NSObject, NSApplicationDelegate { private let cli: VPhoneCLI - private var vm: VPhoneVM? + private var vm: VPhoneVirtualMachine? private var control: VPhoneControl? private var windowController: VPhoneWindowController? private var menuController: VPhoneMenuController? @@ -31,7 +31,7 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { Task { @MainActor in do { - try await self.startVM() + try await self.startVirtualMachine() } catch { print("[vphone] Fatal: \(error)") NSApp.terminate(nil) @@ -40,7 +40,7 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { } @MainActor - private func startVM() async throws { + private func startVirtualMachine() async throws { let romURL = URL(fileURLWithPath: cli.rom) guard FileManager.default.fileExists(atPath: romURL.path) else { throw VPhoneError.romNotFound(cli.rom) @@ -67,7 +67,7 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { print(" rom : \(cli.sepRom)") print("") - let options = VPhoneVM.Options( + let options = VPhoneVirtualMachine.Options( romURL: romURL, nvramURL: nvramURL, machineIDURL: machineIDURL, @@ -82,7 +82,7 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { screenScale: cli.screenScale ) - let vm = try VPhoneVM(options: options) + let vm = try VPhoneVirtualMachine(options: options) self.vm = vm try await vm.start(forceDFU: cli.dfu) @@ -96,17 +96,7 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { } let provider = VPhoneLocationProvider(control: control) - self.locationProvider = provider - control.onConnect = { [weak provider] caps in - if caps.contains("location") { - provider?.startForwarding() - } else { - print("[location] guest does not support location simulation") - } - } - control.onDisconnect = { [weak provider] in - provider?.stopForwarding() - } + locationProvider = provider if let device = vm.virtualMachine.socketDevices.first as? VZVirtioSocketDevice { control.connect(device: device) @@ -134,7 +124,39 @@ class VPhoneAppDelegate: NSObject, NSApplicationDelegate { guard let fileWC, let control else { return } fileWC.showWindow(control: control) } + if let provider = locationProvider { + mc.locationProvider = provider + } menuController = mc + + // Wire location toggle through onConnect/onDisconnect + control.onConnect = { [weak mc, weak provider = locationProvider] caps in + if caps.contains("location") { + mc?.updateLocationCapability(available: true) + // Auto-resume if user had toggle on + if mc?.locationMenuItem?.state == .on { + provider?.startForwarding() + } + } else { + print("[location] guest does not support location simulation") + } + } + control.onDisconnect = { [weak mc, weak provider = locationProvider] in + provider?.stopForwarding() + mc?.updateLocationCapability(available: false) + } + } else if !cli.dfu { + // Headless mode: auto-start location as before (no menu exists) + control.onConnect = { [weak provider = locationProvider] caps in + if caps.contains("location") { + provider?.startForwarding() + } else { + print("[location] guest does not support location simulation") + } + } + control.onDisconnect = { [weak provider = locationProvider] in + provider?.stopForwarding() + } } } diff --git a/sources/vphone-cli/VPhoneControl.swift b/sources/vphone-cli/VPhoneControl.swift index 463c8d9..3902d31 100644 --- a/sources/vphone-cli/VPhoneControl.swift +++ b/sources/vphone-cli/VPhoneControl.swift @@ -250,42 +250,35 @@ class VPhoneControl { // 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") + struct DevModeStatus { + let enabled: Bool } - 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") + struct DevModeEnableResult { + let alreadyEnabled: Bool + let message: String } - func sendPing() { - nextRequestId += 1 - 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 } + func sendDevModeStatus() async throws -> DevModeStatus { + let (resp, _) = try await sendRequest(["t": "devmode", "action": "status"]) + let enabled = resp["enabled"] as? Bool ?? false + return DevModeStatus(enabled: enabled) + } + + func sendDevModeEnable() async throws -> DevModeEnableResult { + let (resp, _) = try await sendRequest(["t": "devmode", "action": "enable"]) + let alreadyEnabled = resp["already_enabled"] as? Bool ?? false + let message = resp["msg"] as? String ?? "" + return DevModeEnableResult(alreadyEnabled: alreadyEnabled, message: message) + } + + func sendPing() async throws { + _ = try await sendRequest(["t": "ping"]) + } + + func sendVersion() async throws -> String { + let (resp, _) = try await sendRequest(["t": "version"]) + return resp["hash"] as? String ?? "unknown" } // MARK: - Async Request-Response @@ -389,9 +382,11 @@ class VPhoneControl { // MARK: - Location - func sendLocation(latitude: Double, longitude: Double, altitude: Double, - horizontalAccuracy: Double, verticalAccuracy: Double, - speed: Double, course: Double) { + func sendLocation( + latitude: Double, longitude: Double, altitude: Double, + horizontalAccuracy: Double, verticalAccuracy: Double, + speed: Double, course: Double + ) { nextRequestId += 1 let msg: [String: Any] = [ "v": Self.protocolVersion, @@ -410,12 +405,16 @@ class VPhoneControl { print("[control] sendLocation failed (not connected)") return } - print("[control] sendLocation lat=\(latitude) lon=\(longitude)") + print("[control] location lat=\(latitude) lon=\(longitude)") } func sendLocationStop() { nextRequestId += 1 - let msg: [String: Any] = ["v": Self.protocolVersion, "t": "location_stop", "id": String(nextRequestId, radix: 16)] + let msg: [String: Any] = [ + "v": Self.protocolVersion, + "t": "location_stop", + "id": String(nextRequestId, radix: 16), + ] guard let fd = connection?.fileDescriptor, writeMessage(fd: fd, dict: msg) else { return } } @@ -492,6 +491,9 @@ class VPhoneControl { if !detail.isEmpty { print("[vphoned] ok: \(detail)") } case "pong": print("[vphoned] pong") + case "version": + let hash = msg["hash"] as? String ?? "unknown" + print("[vphoned] build: \(hash)") case "err": let detail = msg["msg"] as? String ?? "unknown" print("[vphoned] error: \(detail)") diff --git a/sources/vphone-cli/VPhoneFileWindowController.swift b/sources/vphone-cli/VPhoneFileWindowController.swift index 0c86b54..743c128 100644 --- a/sources/vphone-cli/VPhoneFileWindowController.swift +++ b/sources/vphone-cli/VPhoneFileWindowController.swift @@ -31,6 +31,7 @@ class VPhoneFileWindowController { window.contentMinSize = NSSize(width: 500, height: 300) window.center() window.toolbarStyle = .unified + window.isReleasedWhenClosed = false // Add toolbar so the unified title bar shows let toolbar = NSToolbar(identifier: "vphone-files-toolbar") diff --git a/sources/vphone-cli/VPhoneKeyHelper.swift b/sources/vphone-cli/VPhoneKeyHelper.swift index 0a3c826..9b67bf5 100644 --- a/sources/vphone-cli/VPhoneKeyHelper.swift +++ b/sources/vphone-cli/VPhoneKeyHelper.swift @@ -17,7 +17,7 @@ class VPhoneKeyHelper { return arr.object(at: 0) as AnyObject } - init(vm: VPhoneVM, control: VPhoneControl) { + init(vm: VPhoneVirtualMachine, control: VPhoneControl) { self.vm = vm.virtualMachine self.control = control } diff --git a/sources/vphone-cli/VPhoneLocationProvider.swift b/sources/vphone-cli/VPhoneLocationProvider.swift index ef52b9f..e64b038 100644 --- a/sources/vphone-cli/VPhoneLocationProvider.swift +++ b/sources/vphone-cli/VPhoneLocationProvider.swift @@ -25,11 +25,11 @@ class VPhoneLocationProvider: NSObject { self?.forward(location) } } - self.delegateProxy = proxy + delegateProxy = proxy let mgr = CLLocationManager() mgr.delegate = proxy mgr.desiredAccuracy = kCLLocationAccuracyBest - self.locationManager = mgr + locationManager = mgr print("[location] host location forwarding ready") } @@ -86,9 +86,10 @@ private class LocationDelegateProxy: NSObject, CLLocationManagerDelegate { func locationManager(_: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let location = locations.last else { return } - NSLog("[location] got location: %.6f,%.6f (±%.0fm)", - location.coordinate.latitude, location.coordinate.longitude, - location.horizontalAccuracy) + let c = location.coordinate + print( + "[location] got location: \(String(format: "%.6f,%.6f", c.latitude, c.longitude)) (±\(String(format: "%.0f", location.horizontalAccuracy))m)" + ) handler(location) } @@ -96,12 +97,12 @@ private class LocationDelegateProxy: NSObject, CLLocationManagerDelegate { let clErr = (error as NSError).code // kCLErrorLocationUnknown (0) = transient, just waiting for fix if clErr == 0 { return } - NSLog("[location] CLLocationManager error: %@ (code %ld)", error.localizedDescription, clErr) + print("[location] CLLocationManager error: \(error.localizedDescription) (code \(clErr))") } func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { let status = manager.authorizationStatus - NSLog("[location] authorization status: %d", status.rawValue) + print("[location] authorization status: \(status.rawValue)") if status == .authorized || status == .authorizedAlways { manager.startUpdatingLocation() } diff --git a/sources/vphone-cli/VPhoneMenuConnect.swift b/sources/vphone-cli/VPhoneMenuConnect.swift new file mode 100644 index 0000000..9cfa4ea --- /dev/null +++ b/sources/vphone-cli/VPhoneMenuConnect.swift @@ -0,0 +1,91 @@ +import AppKit + +// MARK: - Connect Menu + +extension VPhoneMenuController { + func buildConnectMenu() -> NSMenuItem { + let item = NSMenuItem() + let menu = NSMenu(title: "Connect") + menu.addItem(makeItem("File Browser", action: #selector(openFiles))) + menu.addItem(NSMenuItem.separator()) + menu.addItem(makeItem("Developer Mode Status", action: #selector(devModeStatus))) + menu.addItem(makeItem("Enable Developer Mode", action: #selector(devModeEnable))) + menu.addItem(NSMenuItem.separator()) + menu.addItem(makeItem("Ping", action: #selector(sendPing))) + menu.addItem(makeItem("Guest Version", action: #selector(queryGuestVersion))) + item.submenu = menu + return item + } + + @objc func openFiles() { + onFilesPressed?() + } + + @objc func devModeStatus() { + Task { + do { + let status = try await control.sendDevModeStatus() + showAlert( + title: "Developer Mode", + message: status.enabled ? "Developer Mode is enabled." : "Developer Mode is disabled.", + style: .informational + ) + } catch { + showAlert(title: "Developer Mode", message: "\(error)", style: .warning) + } + } + } + + @objc func devModeEnable() { + Task { + do { + let result = try await control.sendDevModeEnable() + showAlert( + title: "Developer Mode", + message: result.message.isEmpty + ? (result.alreadyEnabled ? "Developer Mode already enabled." : "Developer Mode enabled.") + : result.message, + style: .informational + ) + } catch { + showAlert(title: "Developer Mode", message: "\(error)", style: .warning) + } + } + } + + @objc func sendPing() { + Task { + do { + try await control.sendPing() + showAlert(title: "Ping", message: "pong", style: .informational) + } catch { + showAlert(title: "Ping", message: "\(error)", style: .warning) + } + } + } + + @objc func queryGuestVersion() { + Task { + do { + let hash = try await control.sendVersion() + showAlert(title: "Guest Version", message: "build: \(hash)", style: .informational) + } catch { + showAlert(title: "Guest Version", message: "\(error)", style: .warning) + } + } + } + + // MARK: - Alert + + private func showAlert(title: String, message: String, style: NSAlert.Style) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = style + if let window = NSApp.keyWindow { + alert.beginSheetModal(for: window) + } else { + alert.runModal() + } + } +} diff --git a/sources/vphone-cli/VPhoneMenuController.swift b/sources/vphone-cli/VPhoneMenuController.swift index 60dce72..09f14c2 100644 --- a/sources/vphone-cli/VPhoneMenuController.swift +++ b/sources/vphone-cli/VPhoneMenuController.swift @@ -5,10 +5,12 @@ import Foundation @MainActor class VPhoneMenuController { - private let keyHelper: VPhoneKeyHelper - private let control: VPhoneControl + let keyHelper: VPhoneKeyHelper + let control: VPhoneControl var onFilesPressed: (() -> Void)? + var locationProvider: VPhoneLocationProvider? + var locationMenuItem: NSMenuItem? init(keyHelper: VPhoneKeyHelper, control: VPhoneControl) { self.keyHelper = keyHelper @@ -24,93 +26,27 @@ class VPhoneMenuController { // App menu let appMenuItem = NSMenuItem() let appMenu = NSMenu(title: "vphone") + let buildItem = NSMenuItem(title: "Build: \(VPhoneBuildInfo.commitHash)", action: nil, keyEquivalent: "") + buildItem.isEnabled = false + appMenu.addItem(buildItem) + appMenu.addItem(NSMenuItem.separator()) appMenu.addItem( withTitle: "Quit vphone", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q" ) appMenuItem.submenu = appMenu mainMenu.addItem(appMenuItem) - // Keys menu — hardware buttons that need vphoned HID injection - let keysMenuItem = NSMenuItem() - let keysMenu = NSMenu(title: "Keys") - 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("Spotlight (Cmd+Space)", action: #selector(sendSpotlight))) - keysMenuItem.submenu = keysMenu - mainMenu.addItem(keysMenuItem) - - // Type menu - let typeMenuItem = NSMenuItem() - let typeMenu = NSMenu(title: "Type") - typeMenu.addItem(makeItem("Type ASCII from Clipboard", action: #selector(typeFromClipboard))) - 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) + mainMenu.addItem(buildKeysMenu()) + mainMenu.addItem(buildTypeMenu()) + mainMenu.addItem(buildConnectMenu()) + mainMenu.addItem(buildLocationMenu()) NSApp.mainMenu = mainMenu } - private func makeItem(_ title: String, action: Selector) -> NSMenuItem { + func makeItem(_ title: String, action: Selector) -> NSMenuItem { let item = NSMenuItem(title: title, action: action, keyEquivalent: "") item.target = self return item } - - // MARK: - Keys (hardware buttons via vphoned HID) - - @objc private func sendHome() { - keyHelper.sendHome() - } - - @objc private func sendPower() { - keyHelper.sendPower() - } - - @objc private func sendVolumeUp() { - keyHelper.sendVolumeUp() - } - - @objc private func sendVolumeDown() { - keyHelper.sendVolumeDown() - } - - @objc private func sendSpotlight() { - keyHelper.sendSpotlight() - } - - @objc private func typeFromClipboard() { - keyHelper.typeFromClipboard() - } - - // MARK: - vphoned Agent Commands - - @objc private func openFiles() { - onFilesPressed?() - } - - @objc private func devModeStatus() { - control.sendDevModeStatus() - } - - @objc private func devModeEnable() { - control.sendDevModeEnable() - } - - @objc private func sendPing() { - control.sendPing() - } } diff --git a/sources/vphone-cli/VPhoneMenuKeys.swift b/sources/vphone-cli/VPhoneMenuKeys.swift new file mode 100644 index 0000000..08a6f9a --- /dev/null +++ b/sources/vphone-cli/VPhoneMenuKeys.swift @@ -0,0 +1,38 @@ +import AppKit + +// MARK: - Keys Menu + +extension VPhoneMenuController { + func buildKeysMenu() -> NSMenuItem { + let item = NSMenuItem() + let menu = NSMenu(title: "Keys") + menu.addItem(makeItem("Home Screen", action: #selector(sendHome))) + menu.addItem(makeItem("Power", action: #selector(sendPower))) + menu.addItem(makeItem("Volume Up", action: #selector(sendVolumeUp))) + menu.addItem(makeItem("Volume Down", action: #selector(sendVolumeDown))) + menu.addItem(NSMenuItem.separator()) + menu.addItem(makeItem("Spotlight (Cmd+Space)", action: #selector(sendSpotlight))) + item.submenu = menu + return item + } + + @objc func sendHome() { + keyHelper.sendHome() + } + + @objc func sendPower() { + keyHelper.sendPower() + } + + @objc func sendVolumeUp() { + keyHelper.sendVolumeUp() + } + + @objc func sendVolumeDown() { + keyHelper.sendVolumeDown() + } + + @objc func sendSpotlight() { + keyHelper.sendSpotlight() + } +} diff --git a/sources/vphone-cli/VPhoneMenuLocation.swift b/sources/vphone-cli/VPhoneMenuLocation.swift new file mode 100644 index 0000000..3183602 --- /dev/null +++ b/sources/vphone-cli/VPhoneMenuLocation.swift @@ -0,0 +1,37 @@ +import AppKit + +// MARK: - Location Menu + +extension VPhoneMenuController { + func buildLocationMenu() -> NSMenuItem { + let item = NSMenuItem() + let menu = NSMenu(title: "Location") + let toggle = makeItem("Sync Host Location", action: #selector(toggleLocationSync)) + toggle.state = .off + toggle.isEnabled = false + locationMenuItem = toggle + menu.addItem(toggle) + item.submenu = menu + return item + } + + /// Enable or disable the location toggle based on guest capability. + /// Preserves the user's checkmark state across connect/disconnect cycles. + func updateLocationCapability(available: Bool) { + locationMenuItem?.isEnabled = available + } + + @objc func toggleLocationSync() { + guard let item = locationMenuItem else { return } + if item.state == .on { + locationProvider?.stopForwarding() + control.sendLocationStop() + item.state = .off + print("[location] sync toggled off by user") + } else { + locationProvider?.startForwarding() + item.state = .on + print("[location] sync toggled on by user") + } + } +} diff --git a/sources/vphone-cli/VPhoneMenuType.swift b/sources/vphone-cli/VPhoneMenuType.swift new file mode 100644 index 0000000..c553a80 --- /dev/null +++ b/sources/vphone-cli/VPhoneMenuType.swift @@ -0,0 +1,17 @@ +import AppKit + +// MARK: - Type Menu + +extension VPhoneMenuController { + func buildTypeMenu() -> NSMenuItem { + let item = NSMenuItem() + let menu = NSMenu(title: "Type") + menu.addItem(makeItem("Type ASCII from Clipboard", action: #selector(typeFromClipboard))) + item.submenu = menu + return item + } + + @objc func typeFromClipboard() { + keyHelper.typeFromClipboard() + } +} diff --git a/sources/vphone-cli/VPhoneVM.swift b/sources/vphone-cli/VPhoneVirtualMachine.swift similarity index 99% rename from sources/vphone-cli/VPhoneVM.swift rename to sources/vphone-cli/VPhoneVirtualMachine.swift index 1e66a35..fc34621 100644 --- a/sources/vphone-cli/VPhoneVM.swift +++ b/sources/vphone-cli/VPhoneVirtualMachine.swift @@ -4,7 +4,7 @@ import Virtualization /// Minimal VM for booting a vphone (virtual iPhone) in DFU mode. @MainActor -class VPhoneVM: NSObject, VZVirtualMachineDelegate { +class VPhoneVirtualMachine: NSObject, VZVirtualMachineDelegate { let virtualMachine: VZVirtualMachine /// Read handle for VM serial output. private var serialOutputReadHandle: FileHandle? diff --git a/sources/vphone-cli/VPhoneVMView.swift b/sources/vphone-cli/VPhoneVirtualMachineView.swift similarity index 98% rename from sources/vphone-cli/VPhoneVMView.swift rename to sources/vphone-cli/VPhoneVirtualMachineView.swift index 740ac02..899bf3e 100644 --- a/sources/vphone-cli/VPhoneVMView.swift +++ b/sources/vphone-cli/VPhoneVirtualMachineView.swift @@ -3,7 +3,7 @@ import Dynamic import Foundation import Virtualization -class VPhoneVMView: VZVirtualMachineView { +class VPhoneVirtualMachineView: VZVirtualMachineView { var keyHelper: VPhoneKeyHelper? private var currentTouchSwipeAim: Int = 0 diff --git a/sources/vphone-cli/VPhoneWindowController.swift b/sources/vphone-cli/VPhoneWindowController.swift index 9f1c731..3460325 100644 --- a/sources/vphone-cli/VPhoneWindowController.swift +++ b/sources/vphone-cli/VPhoneWindowController.swift @@ -16,7 +16,7 @@ class VPhoneWindowController: NSObject, NSToolbarDelegate { ) { self.control = control - let view = VPhoneVMView() + let view = VPhoneVirtualMachineView() view.virtualMachine = vm view.capturesSystemKeys = true view.keyHelper = keyHelper @@ -34,6 +34,7 @@ class VPhoneWindowController: NSObject, NSToolbarDelegate { defer: false ) + window.isReleasedWhenClosed = false window.contentAspectRatio = windowSize window.title = "vphone" window.subtitle = "daemon connecting..."