diff --git a/.gitignore b/.gitignore index d25c3f1..7fa8999 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ *.app *.dmg .env +.DS_Store +.build/ build/ build/generated-plists/ tools/ diff --git a/.version b/.version index 46b81d8..4a36342 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -2.11.0 +3.0.0 diff --git a/Makefile b/Makefile index dcff1c5..a6b5b8d 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ ifeq ($(BASE_VERSION),) $(error Missing $(VERSION_FILE). Create it with a version like 1.85.0) endif -.PHONY: all cli app clean help run launch generate test uitest framework dist tools debug-tools dmg ghosttools-icon ghostvm-icon debug website website-build sparkle-tools sparkle-sign capture composite screenshots bump check-version render-plists prepare-app-plists prepare-tools-plist +.PHONY: all cli app clean help run launch generate test uitest framework dist debug-dmg tools debug-tools dmg ghosttools-icon ghostvm-icon debug debug-export website website-build sparkle-tools sparkle-sign capture composite screenshots bump check-version render-plists prepare-app-plists prepare-tools-plist all: help @@ -184,6 +184,58 @@ debug: $(XCODE_PROJECT) dmg ghostvm-icon fi @echo "Debug app built at: $(BUILD_DIR)/Build/Products/Debug/$(APP_NAME).app" +# Build debug for export to a machine without Xcode/signing. +# Drops com.apple.vm.networking (restricted entitlement) so ad-hoc signing works. +# NAT networking won't work but vsock + bridged networking are fine. +# Output: build/debug-export/GhostVM.app (ad-hoc signed, ready to copy) +DEBUG_EXPORT_DIR = build/debug-export +debug-export: $(XCODE_PROJECT) dmg ghostvm-icon + @$(MAKE) --no-print-directory prepare-app-plists INJECT_TIMESTAMP=$(INJECT_TIMESTAMP) + xcodebuild -project $(XCODE_PROJECT) \ + -scheme $(APP_NAME) \ + -configuration Debug \ + -derivedDataPath $(BUILD_DIR) \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY=- \ + DEVELOPMENT_TEAM= \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + build + @rm -rf "$(DEBUG_EXPORT_DIR)" + @mkdir -p "$(DEBUG_EXPORT_DIR)" + @cp -R "$(BUILD_DIR)/Build/Products/Debug/$(APP_NAME).app" "$(DEBUG_EXPORT_DIR)/" + @# Copy icons + @mkdir -p "$(DEBUG_EXPORT_DIR)/$(APP_NAME).app/Contents/Resources" + @cp macOS/GhostVM/Resources/ghostvm.png "$(DEBUG_EXPORT_DIR)/$(APP_NAME).app/Contents/Resources/" + @cp macOS/GhostVM/Resources/ghostvm-dark.png "$(DEBUG_EXPORT_DIR)/$(APP_NAME).app/Contents/Resources/" + @cp build/GhostVMIcon.icns "$(DEBUG_EXPORT_DIR)/$(APP_NAME).app/Contents/Resources/GhostVMIcon.icns" + @# Copy GhostTools.dmg + @cp "$(GHOSTTOOLS_DMG)" "$(DEBUG_EXPORT_DIR)/$(APP_NAME).app/Contents/Resources/" + @# Ad-hoc sign inside-out with debug entitlements (no restricted entitlements) + @echo "Ad-hoc signing for export (no provisioning profile required)..." + @find "$(DEBUG_EXPORT_DIR)/$(APP_NAME).app/Contents/PlugIns/Helpers/GhostVMHelper.app/Contents/Frameworks" \ + -maxdepth 1 \( -name '*.framework' -o -name '*.dylib' \) 2>/dev/null | while read f; do \ + codesign --force --deep -s "-" "$$f"; \ + done + codesign --force --deep --entitlements macOS/GhostVMHelper/entitlements-debug.plist -s "-" \ + "$(DEBUG_EXPORT_DIR)/$(APP_NAME).app/Contents/PlugIns/Helpers/GhostVMHelper.app" + @find "$(DEBUG_EXPORT_DIR)/$(APP_NAME).app/Contents/PlugIns/Helpers/vmctl.app/Contents/Frameworks" \ + -maxdepth 1 \( -name '*.framework' -o -name '*.dylib' \) 2>/dev/null | while read f; do \ + codesign --force --deep -s "-" "$$f"; \ + done + codesign --force --deep --entitlements macOS/GhostVM/vmctl/entitlements-debug.plist -s "-" \ + "$(DEBUG_EXPORT_DIR)/$(APP_NAME).app/Contents/PlugIns/Helpers/vmctl.app" + @find "$(DEBUG_EXPORT_DIR)/$(APP_NAME).app/Contents/Frameworks" \ + -maxdepth 1 \( -name '*.framework' -o -name '*.dylib' \) 2>/dev/null | while read f; do \ + codesign --force --deep -s "-" "$$f"; \ + done + codesign --force --entitlements macOS/GhostVM/entitlements-debug.plist -s "-" \ + "$(DEBUG_EXPORT_DIR)/$(APP_NAME).app" + @echo "Verifying signature..." + codesign --verify --deep --strict "$(DEBUG_EXPORT_DIR)/$(APP_NAME).app" + @echo "Debug export built at: $(DEBUG_EXPORT_DIR)/$(APP_NAME).app" + @echo "Note: com.apple.vm.networking removed — use bridged networking (NAT disabled)." + # Build debug and run attached to terminal (stdout/stderr visible) run: debug "$(BUILD_DIR)/Build/Products/Debug/$(APP_NAME).app/Contents/MacOS/$(APP_NAME)" @@ -269,7 +321,7 @@ tools: ghosttools-icon @mkdir -p "$(GHOSTTOOLS_APP)/Contents/MacOS" @mkdir -p "$(GHOSTTOOLS_APP)/Contents/Resources" @cp "$(GHOSTTOOLS_BUILD_DIR)/release/GhostTools" "$(GHOSTTOOLS_APP)/Contents/MacOS/" - vtool -set-build-version macos 14.0 15.0 -replace -output "$(GHOSTTOOLS_APP)/Contents/MacOS/GhostTools" "$(GHOSTTOOLS_APP)/Contents/MacOS/GhostTools" + vtool -set-build-version macos 26.0 26.0 -replace -output "$(GHOSTTOOLS_APP)/Contents/MacOS/GhostTools" "$(GHOSTTOOLS_APP)/Contents/MacOS/GhostTools" @cp "$(PLIST_TOOLS)" "$(GHOSTTOOLS_APP)/Contents/Info.plist" @# Generate release notes from git log since last tag @LAST_TAG=$$(git describe --tags --abbrev=0 2>/dev/null); \ @@ -287,7 +339,7 @@ debug-tools: @echo "Building GhostTools (debug)..." swift build --package-path $(GHOSTTOOLS_DIR) --scratch-path $(GHOSTTOOLS_BUILD_DIR) @echo "Patching SDK version for guest compatibility..." - vtool -set-build-version macos 14.0 15.0 -replace \ + vtool -set-build-version macos 26.0 26.0 -replace \ -output $(GHOSTTOOLS_BUILD_DIR)/debug/GhostTools-debug \ $(GHOSTTOOLS_BUILD_DIR)/debug/GhostTools codesign --force -s "-" $(GHOSTTOOLS_BUILD_DIR)/debug/GhostTools-debug @@ -353,8 +405,7 @@ dmg: tools @cp "$(GHOSTTOOLS_DIR)/README.txt" "$(GHOSTTOOLS_BUILD_DIR)/dmg-stage/" @rm -f "$(GHOSTTOOLS_DMG)" hdiutil makehybrid -o "$(GHOSTTOOLS_DMG)" \ - -hfs \ - -hfs-volume-name "GhostTools" \ + -hfs -hfs-volume-name "GhostTools" \ "$(GHOSTTOOLS_BUILD_DIR)/dmg-stage" @echo "GhostTools.dmg created at: $(GHOSTTOOLS_DMG)" @@ -530,7 +581,7 @@ dist: @# Add Applications symlink for drag-to-install ln -s /Applications "$(DIST_DIR)/dmg-stage/Applications" @# Create compressed disk image (APFS via hdiutil create preserves framework symlinks - @# cleanly; makehybrid -hfs injects FinderInfo xattrs on symlinks which breaks AMFI + @# cleanly; the old makehybrid -hfs injected FinderInfo xattrs on symlinks which broke AMFI @# strict validation for restricted entitlements like com.apple.vm.networking) hdiutil create -volname "$(DMG_NAME)" -srcfolder "$(DIST_DIR)/dmg-stage" \ -ov -format UDZO "$(DIST_DIR)/$(DMG_NAME)-$(VERSION).dmg" @@ -548,6 +599,188 @@ dist: @echo "Distribution created: $(DIST_DIR)/$(DMG_NAME)-$(VERSION).dmg" @echo "vmctl is at: $(APP_NAME).app/Contents/PlugIns/Helpers/vmctl.app/Contents/MacOS/vmctl" +# Create a signed, notarized debug DMG with timestamp versioning. +# Builds everything in Debug configuration with timestamp versions +# so Sparkle sees each build as a new version. +debug-dmg: $(XCODE_PROJECT) ghostvm-icon ghosttools-icon + @# Build GhostTools (debug) + @$(MAKE) --no-print-directory prepare-tools-plist INJECT_TIMESTAMP=1 BUILD_TIMESTAMP=$(BUILD_TIMESTAMP) + @rm -f "$(GHOSTTOOLS_BUILD_DIR)/debug/GhostTools" + swift build --package-path $(GHOSTTOOLS_DIR) --scratch-path $(GHOSTTOOLS_BUILD_DIR) -c debug + @rm -rf "$(GHOSTTOOLS_APP)" + @mkdir -p "$(GHOSTTOOLS_APP)/Contents/MacOS" "$(GHOSTTOOLS_APP)/Contents/Resources" + @cp "$(GHOSTTOOLS_BUILD_DIR)/debug/GhostTools" "$(GHOSTTOOLS_APP)/Contents/MacOS/" + vtool -set-build-version macos 26.0 26.0 -replace -output "$(GHOSTTOOLS_APP)/Contents/MacOS/GhostTools" "$(GHOSTTOOLS_APP)/Contents/MacOS/GhostTools" + @cp "$(PLIST_TOOLS)" "$(GHOSTTOOLS_APP)/Contents/Info.plist" + @cp "$(GHOSTTOOLS_ICON_ICNS)" "$(GHOSTTOOLS_APP)/Contents/Resources/" + codesign --force --deep --entitlements "$(GHOSTTOOLS_DIR)/Sources/GhostTools/Resources/entitlements.plist" -s "$(GHOSTTOOLS_SIGN_ID)" "$(GHOSTTOOLS_APP)" + @# Package GhostTools.dmg + @rm -rf "$(GHOSTTOOLS_BUILD_DIR)/dmg-stage" + @mkdir -p "$(GHOSTTOOLS_BUILD_DIR)/dmg-stage" + @cp -R "$(GHOSTTOOLS_APP)" "$(GHOSTTOOLS_BUILD_DIR)/dmg-stage/" + @cp "$(GHOSTTOOLS_DIR)/README.txt" "$(GHOSTTOOLS_BUILD_DIR)/dmg-stage/" + @rm -f "$(GHOSTTOOLS_DMG)" + hdiutil makehybrid -o "$(GHOSTTOOLS_DMG)" \ + -hfs -hfs-volume-name "GhostTools" \ + "$(GHOSTTOOLS_BUILD_DIR)/dmg-stage" + @# Build app + cli (debug) + @$(MAKE) --no-print-directory prepare-app-plists INJECT_TIMESTAMP=1 BUILD_TIMESTAMP=$(BUILD_TIMESTAMP) + xcodebuild -project $(XCODE_PROJECT) -scheme $(APP_NAME) -configuration Debug -derivedDataPath $(BUILD_DIR) \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY=- \ + DEVELOPMENT_TEAM= \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + build + xcodebuild -project $(XCODE_PROJECT) -scheme vmctl -configuration Debug -derivedDataPath $(BUILD_DIR) \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY=- \ + DEVELOPMENT_TEAM= \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + build + @# Copy resources into debug app + @mkdir -p "$(BUILD_DIR)/Build/Products/Debug/$(APP_NAME).app/Contents/Resources" + @cp macOS/GhostVM/Resources/ghostvm.png "$(BUILD_DIR)/Build/Products/Debug/$(APP_NAME).app/Contents/Resources/" + @cp macOS/GhostVM/Resources/ghostvm-dark.png "$(BUILD_DIR)/Build/Products/Debug/$(APP_NAME).app/Contents/Resources/" + @cp build/GhostVMIcon.icns "$(BUILD_DIR)/Build/Products/Debug/$(APP_NAME).app/Contents/Resources/GhostVMIcon.icns" + @cp "$(GHOSTTOOLS_DMG)" "$(BUILD_DIR)/Build/Products/Debug/$(APP_NAME).app/Contents/Resources/" + @# Verify we have a real signing identity for distribution + @if [ -z "$(DIST_CODESIGN_ID)" ]; then \ + echo "Error: No 'Developer ID Application' identity found in keychain."; \ + exit 1; \ + fi + @# Verify notarization credentials are set + @if [ -z "$(NOTARY_APPLE_ID)" ] || [ -z "$(NOTARY_TEAM_ID)" ] || [ -z "$(NOTARY_PASSWORD)" ]; then \ + echo "ERROR: Notarization requires NOTARY_APPLE_ID, NOTARY_TEAM_ID, and NOTARY_PASSWORD"; \ + exit 1; \ + fi + @scripts/verify-profile-cert.sh "$(DIST_CODESIGN_ID)" $(APP_PROVISIONING_PROFILE) $(HELPER_PROVISIONING_PROFILE) $(VMCTL_PROVISIONING_PROFILE) + $(eval DEBUG_VERSION := $(strip $(shell echo $(BASE_VERSION) | sed 's/\.[^.]*$$//')).$(BUILD_TIMESTAMP)) + @echo "Creating debug DMG (version $(DEBUG_VERSION))..." + @echo "Signing with: $(DIST_CODESIGN_ID)" + @rm -rf "$(DIST_DIR)" + @mkdir -p "$(DIST_DIR)/dmg-stage" + cp -R "$(BUILD_DIR)/Build/Products/Debug/$(APP_NAME).app" "$(DIST_DIR)/dmg-stage/" + cp -R "$(BUILD_DIR)/Build/Products/Debug/vmctl.app" "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/PlugIns/Helpers/" + @if [ -n "$(HELPER_PROVISIONING_PROFILE)" ]; then \ + test -f "$(HELPER_PROVISIONING_PROFILE)" || (echo "ERROR: HELPER_PROVISIONING_PROFILE not found: $(HELPER_PROVISIONING_PROFILE)" && exit 1); \ + cp "$(HELPER_PROVISIONING_PROFILE)" "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/PlugIns/Helpers/GhostVMHelper.app/Contents/embedded.provisionprofile"; \ + fi + @if [ -n "$(APP_PROVISIONING_PROFILE)" ]; then \ + test -f "$(APP_PROVISIONING_PROFILE)" || (echo "ERROR: APP_PROVISIONING_PROFILE not found: $(APP_PROVISIONING_PROFILE)" && exit 1); \ + cp "$(APP_PROVISIONING_PROFILE)" "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/embedded.provisionprofile"; \ + fi + @if [ -n "$(VMCTL_PROVISIONING_PROFILE)" ]; then \ + test -f "$(VMCTL_PROVISIONING_PROFILE)" || (echo "ERROR: VMCTL_PROVISIONING_PROFILE not found: $(VMCTL_PROVISIONING_PROFILE)" && exit 1); \ + cp "$(VMCTL_PROVISIONING_PROFILE)" "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/PlugIns/Helpers/vmctl.app/Contents/embedded.provisionprofile"; \ + fi + @# Sign GhostVMHelper (frameworks, MacOS dylibs, then bundle) + @for fw in "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/PlugIns/Helpers/GhostVMHelper.app/Contents/Frameworks/"*.framework; do \ + if [ -e "$$fw" ]; then \ + codesign --force --options runtime --timestamp -s "$(DIST_CODESIGN_ID)" "$$fw"; \ + fi; \ + done + @for dylib in "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/PlugIns/Helpers/GhostVMHelper.app/Contents/Frameworks/"*.dylib; do \ + if [ -e "$$dylib" ]; then \ + codesign --force --options runtime --timestamp -s "$(DIST_CODESIGN_ID)" "$$dylib"; \ + fi; \ + done + @for dylib in "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/PlugIns/Helpers/GhostVMHelper.app/Contents/MacOS/"*.dylib; do \ + if [ -e "$$dylib" ]; then \ + echo " Signing $$(basename $$dylib) (in GhostVMHelper)"; \ + codesign --force --options runtime --timestamp -s "$(DIST_CODESIGN_ID)" "$$dylib"; \ + fi; \ + done + codesign --force --options runtime --timestamp \ + --entitlements macOS/GhostVMHelper/entitlements.plist \ + -s "$(DIST_CODESIGN_ID)" \ + "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/PlugIns/Helpers/GhostVMHelper.app" + @# Sign XPC services in Sparkle + @for xpc in "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/"*.xpc; do \ + if [ -e "$$xpc" ]; then \ + codesign --force --options runtime --timestamp -s "$(DIST_CODESIGN_ID)" "$$xpc"; \ + fi; \ + done + @# Sign Sparkle helpers + codesign --force --options runtime --timestamp -s "$(DIST_CODESIGN_ID)" \ + "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate" || true + codesign --force --options runtime --timestamp -s "$(DIST_CODESIGN_ID)" \ + "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app" || true + @# Sign vmctl (frameworks, MacOS dylibs, then bundle) + @for fw in "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/PlugIns/Helpers/vmctl.app/Contents/Frameworks/"*.framework; do \ + if [ -e "$$fw" ]; then \ + codesign --force --options runtime --timestamp -s "$(DIST_CODESIGN_ID)" "$$fw"; \ + fi; \ + done + @for dylib in "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/PlugIns/Helpers/vmctl.app/Contents/Frameworks/"*.dylib; do \ + if [ -e "$$dylib" ]; then \ + codesign --force --options runtime --timestamp -s "$(DIST_CODESIGN_ID)" "$$dylib"; \ + fi; \ + done + @for dylib in "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/PlugIns/Helpers/vmctl.app/Contents/MacOS/"*.dylib; do \ + if [ -e "$$dylib" ]; then \ + echo " Signing $$(basename $$dylib) (in vmctl)"; \ + codesign --force --options runtime --timestamp -s "$(DIST_CODESIGN_ID)" "$$dylib"; \ + fi; \ + done + codesign --force --options runtime --timestamp \ + --entitlements macOS/GhostVM/vmctl/entitlements.plist \ + -s "$(DIST_CODESIGN_ID)" \ + "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/PlugIns/Helpers/vmctl.app" + @# Sign main app frameworks and MacOS dylibs + @for fw in "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/Frameworks/"*.framework; do \ + if [ -e "$$fw" ]; then \ + codesign --force --options runtime --timestamp -s "$(DIST_CODESIGN_ID)" "$$fw"; \ + fi; \ + done + @for dylib in "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/Frameworks/"*.dylib; do \ + if [ -e "$$dylib" ]; then \ + codesign --force --options runtime --timestamp -s "$(DIST_CODESIGN_ID)" "$$dylib"; \ + fi; \ + done + @# Re-sign GhostTools.dmg inside the app bundle + @mkdir -p "$(DIST_DIR)/ghosttools-stage" + @hdiutil attach "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/Resources/GhostTools.dmg" -mountpoint "$(DIST_DIR)/ghosttools-mount" -nobrowse + @ditto "$(DIST_DIR)/ghosttools-mount/GhostTools.app" "$(DIST_DIR)/ghosttools-stage/GhostTools.app" + @hdiutil detach "$(DIST_DIR)/ghosttools-mount" + @xattr -cr "$(DIST_DIR)/ghosttools-stage/GhostTools.app" + codesign --force --options runtime --timestamp --deep --entitlements "$(GHOSTTOOLS_DIR)/Sources/GhostTools/Resources/entitlements.plist" -s "$(DIST_CODESIGN_ID)" "$(DIST_DIR)/ghosttools-stage/GhostTools.app" + @rm -f "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/Resources/GhostTools.dmg" + hdiutil makehybrid -o "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/Resources/GhostTools.dmg" \ + -hfs -hfs-volume-name "GhostTools" \ + "$(DIST_DIR)/ghosttools-stage" + @rm -rf "$(DIST_DIR)/ghosttools-stage" + @# Strip xattrs and sign main app MacOS dylibs then bundle + @xattr -cr "$(DIST_DIR)/dmg-stage/$(APP_NAME).app" + @for dylib in "$(DIST_DIR)/dmg-stage/$(APP_NAME).app/Contents/MacOS/"*.dylib; do \ + if [ -e "$$dylib" ]; then \ + echo " Signing $$(basename $$dylib) (in $(APP_NAME))"; \ + codesign --force --options runtime --timestamp -s "$(DIST_CODESIGN_ID)" "$$dylib"; \ + fi; \ + done + codesign --force --options runtime --timestamp \ + --entitlements macOS/GhostVM/entitlements.plist \ + -s "$(DIST_CODESIGN_ID)" \ + "$(DIST_DIR)/dmg-stage/$(APP_NAME).app" + @# Verify + codesign --verify --deep --strict "$(DIST_DIR)/dmg-stage/$(APP_NAME).app" + @# Create DMG + ln -s /Applications "$(DIST_DIR)/dmg-stage/Applications" + hdiutil create -volname "$(DMG_NAME)" -srcfolder "$(DIST_DIR)/dmg-stage" \ + -ov -format UDZO "$(DIST_DIR)/$(DMG_NAME)-$(DEBUG_VERSION).dmg" + @rm -rf "$(DIST_DIR)/dmg-stage" + codesign --force --timestamp -s "$(DIST_CODESIGN_ID)" "$(DIST_DIR)/$(DMG_NAME)-$(DEBUG_VERSION).dmg" + @# Notarize + xcrun notarytool submit "$(DIST_DIR)/$(DMG_NAME)-$(DEBUG_VERSION).dmg" \ + --apple-id "$(NOTARY_APPLE_ID)" \ + --team-id "$(NOTARY_TEAM_ID)" \ + --password "$(NOTARY_PASSWORD)" \ + --wait + xcrun stapler staple "$(DIST_DIR)/$(DMG_NAME)-$(DEBUG_VERSION).dmg" + @echo "Debug distribution created: $(DIST_DIR)/$(DMG_NAME)-$(DEBUG_VERSION).dmg" + @echo "vmctl is at: $(APP_NAME).app/Contents/PlugIns/Helpers/vmctl.app/Contents/MacOS/vmctl" + # Sign DMG for Sparkle auto-updates (run after make dist) sparkle-sign: sparkle-tools @echo "Signing DMG for Sparkle..." @@ -598,6 +831,7 @@ help: @echo " make generate - Generate Xcode project from macOS/project.yml" @echo " make app - Build SwiftUI app via xcodebuild" @echo " make debug - Build SwiftUI app in Debug configuration" + @echo " make debug-export - Debug build ad-hoc signed (no Xcode needed on target)" @echo " make run - Build and run attached to terminal" @echo " make launch - Build and launch detached" @echo " make test - Run unit tests" @@ -609,6 +843,7 @@ help: @echo " make debug-tools - Build GhostTools debug binary (lldb-compatible with macOS 15)" @echo " make dmg - Create GhostTools.dmg" @echo " make dist - Create distribution DMG with app + vmctl" + @echo " make debug-dmg - Like dist but with timestamp version for Sparkle updates" @echo " make sparkle-tools - Download Sparkle signing tools" @echo " make sparkle-sign - Sign DMG for Sparkle auto-updates" @echo " make website - Run Next.js dev server (Website/)" diff --git a/Packages/GhostHTTP/Package.swift b/Packages/GhostHTTP/Package.swift new file mode 100644 index 0000000..1de8cc0 --- /dev/null +++ b/Packages/GhostHTTP/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "GhostHTTP", + platforms: [ + .macOS("15.0") + ], + products: [ + .library( + name: "GhostHTTP", + targets: ["GhostHTTP"] + ) + ], + targets: [ + .target( + name: "GhostHTTP" + ), + .testTarget( + name: "GhostHTTPTests", + dependencies: ["GhostHTTP"] + ) + ] +) diff --git a/Packages/GhostHTTP/Sources/GhostHTTP/GhostHTTP.swift b/Packages/GhostHTTP/Sources/GhostHTTP/GhostHTTP.swift new file mode 100644 index 0000000..19b8581 --- /dev/null +++ b/Packages/GhostHTTP/Sources/GhostHTTP/GhostHTTP.swift @@ -0,0 +1,841 @@ +import Foundation +import Darwin + +public enum HTTPMethod: String, Sendable { + case GET, POST, PUT, DELETE, HEAD, OPTIONS, PATCH +} + +public enum HTTPQueryParser { + public static func parseQuery(_ path: String, key: String) -> String? { + guard let queryStart = path.firstIndex(of: "?") else { return nil } + let query = String(path[path.index(after: queryStart)...]) + for pair in query.components(separatedBy: "&") { + let parts = pair.split(separator: "=", maxSplits: 1).map(String.init) + if parts.count == 2 && parts[0] == key { + return parts[1].removingPercentEncoding ?? parts[1] + } + } + return nil + } +} + +public enum HTTPStatus: Int, Sendable { + case switchingProtocols = 101 + case ok = 200 + case created = 201 + case noContent = 204 + case badRequest = 400 + case unauthorized = 401 + case forbidden = 403 + case notFound = 404 + case methodNotAllowed = 405 + case requestTimeout = 408 + case payloadTooLarge = 413 + case headerTooLarge = 431 + case badGateway = 502 + case serviceUnavailable = 503 + case gatewayTimeout = 504 + case internalServerError = 500 + + public var reasonPhrase: String { + switch self { + case .switchingProtocols: return "Switching Protocols" + case .ok: return "OK" + case .created: return "Created" + case .noContent: return "No Content" + case .badRequest: return "Bad Request" + case .unauthorized: return "Unauthorized" + case .forbidden: return "Forbidden" + case .notFound: return "Not Found" + case .methodNotAllowed: return "Method Not Allowed" + case .requestTimeout: return "Request Timeout" + case .payloadTooLarge: return "Payload Too Large" + case .headerTooLarge: return "Request Header Fields Too Large" + case .badGateway: return "Bad Gateway" + case .serviceUnavailable: return "Service Unavailable" + case .gatewayTimeout: return "Gateway Timeout" + case .internalServerError: return "Internal Server Error" + } + } + + public static func from(code: Int) -> HTTPStatus { + HTTPStatus(rawValue: code) ?? .internalServerError + } +} + +public struct HTTPHeaders: Sendable, ExpressibleByDictionaryLiteral { + public struct Entry: Sendable, Equatable { + public let name: String + public let value: String + + public init(name: String, value: String) { + self.name = name + self.value = value + } + } + + private var entries: [Entry] + private var lowercaseIndex: [String: Int] + + public init(_ entries: [Entry] = []) { + self.entries = entries + self.lowercaseIndex = [:] + rebuildIndex() + } + + public init(_ dictionary: [String: String]) { + self.init(dictionary.map { Entry(name: $0.key, value: $0.value) }) + } + + public init(dictionaryLiteral elements: (String, String)...) { + self.init(Dictionary(uniqueKeysWithValues: elements)) + } + + public subscript(name: String) -> String? { + get { + guard let index = lowercaseIndex[name.lowercased()] else { return nil } + return entries[index].value + } + set { + let key = name.lowercased() + if let index = lowercaseIndex[key] { + if let newValue { + entries[index] = Entry(name: entries[index].name, value: newValue) + } else { + entries.remove(at: index) + rebuildIndex() + } + } else if let newValue { + entries.append(Entry(name: name, value: newValue)) + lowercaseIndex[key] = entries.count - 1 + } + } + } + + public var all: [Entry] { entries } + + public var dictionary: [String: String] { + var result: [String: String] = [:] + for entry in entries { + result[entry.name] = entry.value + } + return result + } + + private mutating func rebuildIndex() { + lowercaseIndex.removeAll(keepingCapacity: true) + for (index, entry) in entries.enumerated() { + lowercaseIndex[entry.name.lowercased()] = index + } + } +} + +public struct HTTPRequestHead: Sendable { + public let method: HTTPMethod + public let path: String + public let headers: HTTPHeaders + + public init(method: HTTPMethod, path: String, headers: HTTPHeaders = HTTPHeaders()) { + self.method = method + self.path = path + self.headers = headers + } + + public func header(_ name: String) -> String? { + headers[name] + } + + public var contentLength: Int? { + header("content-length").flatMap(Int.init) + } +} + +public struct HTTPResponseHead: Sendable { + public let status: HTTPStatus + public let headers: HTTPHeaders + + public init(status: HTTPStatus, headers: HTTPHeaders = HTTPHeaders()) { + self.status = status + self.headers = headers + } + + public func header(_ name: String) -> String? { + headers[name] + } + + public var contentLength: Int? { + header("content-length").flatMap(Int.init) + } +} + +public enum HTTPBodyFraming: Sendable, Equatable { + case knownLength(Int) + case chunked + case eof +} + +public protocol HTTPBodyWriter { + func write(_ data: Data) throws + func write(_ buffer: UnsafeRawBufferPointer) throws +} + +public enum HTTPResponseBody: Sendable { + case empty + case bytes(Data) + case stream(contentLength: Int, producer: @Sendable (HTTPBodyWriter) throws -> Void) +} + +public struct HTTPResponse: Sendable { + public var status: HTTPStatus + public var headers: HTTPHeaders + public var body: HTTPResponseBody + + public init(status: HTTPStatus, headers: HTTPHeaders = HTTPHeaders(), body: HTTPResponseBody = .empty) { + self.status = status + self.headers = headers + self.body = body + } + + public init(status: HTTPStatus, headers: [String: String], body: HTTPResponseBody = .empty) { + self.init(status: status, headers: HTTPHeaders(headers), body: body) + } + + public static func json(_ data: Data, status: HTTPStatus = .ok) -> HTTPResponse { + HTTPResponse( + status: status, + headers: HTTPHeaders(["Content-Type": "application/json"]), + body: .bytes(data) + ) + } + + public static func text(_ string: String, status: HTTPStatus = .ok) -> HTTPResponse { + HTTPResponse( + status: status, + headers: HTTPHeaders(["Content-Type": "text/plain; charset=utf-8"]), + body: .bytes(Data(string.utf8)) + ) + } + + public static func error(_ status: HTTPStatus, message: String) -> HTTPResponse { + let payload = (try? JSONSerialization.data(withJSONObject: ["error": message])) + ?? Data(#"{"error":"unknown"}"#.utf8) + return .json(payload, status: status) + } +} + +public struct HTTPBufferedResponse: Sendable { + public let head: HTTPResponseHead + public let body: Data + + public init(head: HTTPResponseHead, body: Data) { + self.head = head + self.body = body + } +} + +public struct HTTPUpgradedConnection: Sendable { + public let responseHead: HTTPResponseHead + public let prelude: Data + + public init(responseHead: HTTPResponseHead, prelude: Data) { + self.responseHead = responseHead + self.prelude = prelude + } +} + +public enum HTTPError: Error, CustomStringConvertible, Sendable { + case malformedRequestLine(String) + case malformedStatusLine(String) + case malformedHeader(String) + case headerTooLarge(maxBytes: Int) + case unsupportedMethod(String) + case writeFailed(errno: Int32) + case readFailed(errno: Int32) + case unexpectedEOF(read: Int, expected: Int) + case bodyTooLarge(contentLength: Int, max: Int) + + public var description: String { + switch self { + case .malformedRequestLine(let line): + return "Malformed request line: \(line)" + case .malformedStatusLine(let line): + return "Malformed status line: \(line)" + case .malformedHeader(let line): + return "Malformed header: \(line)" + case .headerTooLarge(let maxBytes): + return "Header section exceeded the \(maxBytes) byte cap" + case .unsupportedMethod(let method): + return "Unsupported HTTP method: \(method)" + case .writeFailed(let err): + return "write() failed: errno \(err)" + case .readFailed(let err): + return "read() failed: errno \(err)" + case .unexpectedEOF(let read, let expected): + return "Unexpected EOF after \(read) of \(expected) bytes" + case .bodyTooLarge(let contentLength, let max): + return "Body of \(contentLength) bytes exceeds limit of \(max) bytes" + } + } +} + +public final class HTTPBodyReader { + public let framing: HTTPBodyFraming + + private let fd: Int32 + private var prelude: Data + private var bytesDelivered = 0 + private var eofReached = false + private var chunkRemaining = 0 + private var chunkedFinished = false + + public init(fd: Int32, framing: HTTPBodyFraming, prelude: Data) { + self.fd = fd + self.framing = framing + self.prelude = prelude + } + + public var contentLength: Int? { + if case .knownLength(let count) = framing { return count } + return nil + } + + public func read(into buffer: UnsafeMutableRawBufferPointer) throws -> Int { + switch framing { + case .knownLength(let total): + let remaining = total - bytesDelivered + if remaining == 0 { return 0 } + let count = try readSocket(into: buffer, maxBytes: min(buffer.count, remaining), errorOnEOF: true) + bytesDelivered += count + return count + + case .eof: + if eofReached { return 0 } + let count = try readSocket(into: buffer, maxBytes: buffer.count, errorOnEOF: false) + bytesDelivered += count + return count + + case .chunked: + return try readChunked(into: buffer) + } + } + + public func readAll(maxSize: Int = 16 * 1024 * 1024) throws -> Data { + switch framing { + case .knownLength(let total): + if total == 0 { return Data() } + guard total <= maxSize else { + throw HTTPError.bodyTooLarge(contentLength: total, max: maxSize) + } + var out = Data(count: total) + var written = 0 + try out.withUnsafeMutableBytes { rawPtr in + while written < total { + let slice = UnsafeMutableRawBufferPointer(rebasing: rawPtr[written.. maxSize { + throw HTTPError.bodyTooLarge(contentLength: out.count, max: maxSize) + } + let count = try read(into: rawPtr) + if count == 0 { break } + out.append(rawPtr.baseAddress!.assumingMemoryBound(to: UInt8.self), count: count) + } + } + return out + } + } + + public func discard() { + var buf = [UInt8](repeating: 0, count: 64 * 1024) + while true { + let count = buf.withUnsafeMutableBytes { ptr in + (try? read(into: ptr)) ?? 0 + } + if count == 0 { break } + } + } + + private func readChunked(into buffer: UnsafeMutableRawBufferPointer) throws -> Int { + while !chunkedFinished { + if chunkRemaining > 0 { + let count = try readSocket(into: buffer, maxBytes: min(buffer.count, chunkRemaining), errorOnEOF: true) + chunkRemaining -= count + bytesDelivered += count + if chunkRemaining == 0 { + try expectCRLF() + } + return count + } + + let sizeLine = try readLine(max: 1024) + let core = sizeLine.split(separator: ";", maxSplits: 1).first ?? Substring(sizeLine) + let hex = core.trimmingCharacters(in: .whitespaces) + guard let size = Int(hex, radix: 16), size >= 0 else { + throw HTTPError.malformedHeader("Invalid chunk size: \(hex)") + } + + if size == 0 { + while true { + let trailer = try readLine(max: 8192) + if trailer.isEmpty { break } + } + chunkedFinished = true + return 0 + } + + chunkRemaining = size + } + + return 0 + } + + private func readSocket( + into buffer: UnsafeMutableRawBufferPointer, + maxBytes: Int, + errorOnEOF: Bool + ) throws -> Int { + if !prelude.isEmpty { + let fromPrelude = min(maxBytes, prelude.count) + prelude.withUnsafeBytes { src in + buffer.copyMemory(from: UnsafeRawBufferPointer(rebasing: src[.. 0 { + return count + } + if count == 0 { + if errorOnEOF { + throw HTTPError.unexpectedEOF(read: bytesDelivered, expected: contentLength ?? (bytesDelivered + 1)) + } + eofReached = true + return 0 + } + if errno == EINTR { + continue + } + throw HTTPError.readFailed(errno: errno) + } + } + + private func readByte() throws -> UInt8 { + if !prelude.isEmpty { + let byte = prelude.removeFirst() + return byte + } + + while true { + var byte: UInt8 = 0 + let count = Darwin.read(fd, &byte, 1) + if count == 1 { return byte } + if count == 0 { + throw HTTPError.unexpectedEOF(read: bytesDelivered, expected: bytesDelivered + 1) + } + if errno == EINTR { continue } + throw HTTPError.readFailed(errno: errno) + } + } + + private func readLine(max: Int) throws -> String { + var bytes: [UInt8] = [] + bytes.reserveCapacity(64) + var previous: UInt8 = 0 + while bytes.count < max { + let byte = try readByte() + if previous == 0x0D && byte == 0x0A { + bytes.removeLast() + return String(bytes: bytes, encoding: .utf8) ?? "" + } + bytes.append(byte) + previous = byte + } + throw HTTPError.malformedHeader("Line exceeded \(max) bytes") + } + + private func expectCRLF() throws { + let cr = try readByte() + let lf = try readByte() + if cr != 0x0D || lf != 0x0A { + throw HTTPError.malformedHeader("Expected CRLF") + } + } +} + +public enum HTTPCodec { + public static let defaultMaxHeaderBytes = 16 * 1024 + + public static func requestFraming(for request: HTTPRequestHead) -> HTTPBodyFraming { + if let transferEncoding = request.header("transfer-encoding")?.lowercased() { + let values = transferEncoding + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + if values.contains("chunked") { + return .chunked + } + } + if let contentLength = request.contentLength { + return .knownLength(contentLength) + } + return .eof + } + + public static func responseFraming(for response: HTTPResponseHead) -> HTTPBodyFraming { + if let transferEncoding = response.header("transfer-encoding")?.lowercased() { + let values = transferEncoding + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + if values.contains("chunked") { + return .chunked + } + } + if let contentLength = response.contentLength { + return .knownLength(contentLength) + } + return .eof + } + + public static func readRequest(fd: Int32, maxHeaderBytes: Int = defaultMaxHeaderBytes) throws -> (HTTPRequestHead, prelude: Data) { + let (headerText, prelude) = try readHeaderBlock(fd: fd, maxHeaderBytes: maxHeaderBytes) + let lines = headerText.components(separatedBy: "\r\n") + guard let requestLine = lines.first else { + throw HTTPError.malformedRequestLine("empty") + } + + let parts = requestLine.split(separator: " ", maxSplits: 2).map(String.init) + guard parts.count >= 3 else { + throw HTTPError.malformedRequestLine(requestLine) + } + guard let method = HTTPMethod(rawValue: parts[0]) else { + throw HTTPError.unsupportedMethod(parts[0]) + } + + let request = HTTPRequestHead( + method: method, + path: parts[1], + headers: try parseHeaders(lines.dropFirst()) + ) + return (request, prelude) + } + + public static func readResponseHead(fd: Int32, maxHeaderBytes: Int = defaultMaxHeaderBytes) throws -> (HTTPResponseHead, prelude: Data) { + let (headerText, prelude) = try readHeaderBlock(fd: fd, maxHeaderBytes: maxHeaderBytes) + let lines = headerText.components(separatedBy: "\r\n") + guard let statusLine = lines.first else { + throw HTTPError.malformedStatusLine("empty") + } + + let parts = statusLine.split(separator: " ", maxSplits: 2).map(String.init) + guard parts.count >= 2, let statusCode = Int(parts[1]) else { + throw HTTPError.malformedStatusLine(statusLine) + } + + let head = HTTPResponseHead( + status: HTTPStatus.from(code: statusCode), + headers: try parseHeaders(lines.dropFirst()) + ) + return (head, prelude) + } + + public static func writeRequest( + fd: Int32, + method: String, + path: String, + headers: HTTPHeaders = HTTPHeaders(), + body: Data? = nil + ) throws { + try writeAll(fd: fd, data: requestData(method: method, path: path, headers: headers, body: body)) + } + + public static func writeRaw(fd: Int32, _ string: String) throws { + try writeAll(fd: fd, data: Data(string.utf8)) + } + + public static func writeResponse(_ response: HTTPResponse, fd: Int32) throws { + try writeAll(fd: fd, data: responseHeaderData(status: response.status, headers: response.headers, body: response.body)) + + switch response.body { + case .empty: + break + case .bytes(let data): + try writeAll(fd: fd, data: data) + case .stream(let contentLength, let producer): + let writer = CountingBodyWriter(fd: fd, expectedCount: contentLength) + try producer(writer) + try writer.finish() + } + } + + public static func writeResponseHead( + fd: Int32, + status: HTTPStatus, + headers: HTTPHeaders = HTTPHeaders() + ) throws { + try writeAll(fd: fd, data: responseHeadData(status: status, headers: headers)) + } + + public static func requestData( + method: String, + path: String, + headers: HTTPHeaders = HTTPHeaders(), + body: Data? = nil + ) -> Data { + var workingHeaders = headers + let connectionHeader = workingHeaders["Connection"]?.lowercased() ?? "" + let isUpgradeRequest = connectionHeader.split(separator: ",").contains { $0.trimmingCharacters(in: .whitespaces) == "upgrade" } + if let body { + workingHeaders["Content-Length"] = "\(body.count)" + } else if !isUpgradeRequest && workingHeaders["Content-Length"] == nil && workingHeaders["Transfer-Encoding"] == nil { + workingHeaders["Content-Length"] = "0" + } + if workingHeaders["Host"] == nil { + workingHeaders["Host"] = "localhost" + } + if workingHeaders["Connection"] == nil { + workingHeaders["Connection"] = "close" + } + + var block = "\(method) \(path) HTTP/1.1\r\n" + for entry in workingHeaders.all { + block += "\(entry.name): \(entry.value)\r\n" + } + block += "\r\n" + + var data = Data(block.utf8) + if let body { + data.append(body) + } + return data + } + + public static func responseData(status: HTTPStatus, headers: HTTPHeaders = HTTPHeaders(), body: Data = Data()) -> Data { + var data = responseHeaderData(status: status, headers: headers, bodyLength: body.count) + data.append(body) + return data + } + + public static func responseHeadData(status: HTTPStatus, headers: HTTPHeaders = HTTPHeaders()) -> Data { + var block = "HTTP/1.1 \(status.rawValue) \(status.reasonPhrase)\r\n" + for entry in headers.all { + block += "\(entry.name): \(entry.value)\r\n" + } + block += "\r\n" + return Data(block.utf8) + } + + @discardableResult + public static func writeAll(fd: Int32, data: Data) throws -> Int { + guard !data.isEmpty else { return 0 } + try data.withUnsafeBytes { ptr in + try writeAll(fd: fd, ptr: ptr.baseAddress!, count: data.count) + } + return data.count + } + + public static func writeAll(fd: Int32, ptr: UnsafeRawPointer, count: Int) throws { + var offset = 0 + while offset < count { + let written = Darwin.write(fd, ptr + offset, count - offset) + if written > 0 { + offset += written + } else if written < 0 { + let writeErrno = errno + if writeErrno == EINTR { + continue + } + throw HTTPError.writeFailed(errno: writeErrno) + } else { + throw HTTPError.writeFailed(errno: EPIPE) + } + } + } + + private static func readHeaderBlock(fd: Int32, maxHeaderBytes: Int) throws -> (String, Data) { + var buffer = Data() + var chunk = [UInt8](repeating: 0, count: 4096) + let separator = Data([0x0D, 0x0A, 0x0D, 0x0A]) + + while true { + if let range = buffer.range(of: separator) { + let headerData = buffer[.. maxHeaderBytes { + throw HTTPError.headerTooLarge(maxBytes: maxHeaderBytes) + } + + let count = Darwin.read(fd, &chunk, chunk.count) + if count > 0 { + buffer.append(contentsOf: chunk[0..(_ lines: S) throws -> HTTPHeaders where S.Element == String { + var entries: [HTTPHeaders.Entry] = [] + for line in lines where !line.isEmpty { + guard let colon = line.firstIndex(of: ":") else { + throw HTTPError.malformedHeader(line) + } + let name = String(line[.. Data { + let bodyLength: Int + switch body { + case .empty: + bodyLength = 0 + case .bytes(let data): + bodyLength = data.count + case .stream(let length, _): + bodyLength = length + } + return responseHeaderData(status: status, headers: headers, bodyLength: bodyLength) + } + + private static func responseHeaderData(status: HTTPStatus, headers: HTTPHeaders, bodyLength: Int) -> Data { + var workingHeaders = headers + if status != .switchingProtocols && status != .noContent { + workingHeaders["Content-Length"] = "\(bodyLength)" + } + if workingHeaders["Connection"] == nil { + workingHeaders["Connection"] = "close" + } + + var block = "HTTP/1.1 \(status.rawValue) \(status.reasonPhrase)\r\n" + for entry in workingHeaders.all { + block += "\(entry.name): \(entry.value)\r\n" + } + block += "\r\n" + return Data(block.utf8) + } +} + +public enum HTTPClient { + public static func performRequest( + fd: Int32, + method: String, + path: String, + headers: HTTPHeaders = HTTPHeaders(), + body: Data? = nil, + shutdownWrite: Bool = true + ) throws -> HTTPBufferedResponse { + try HTTPCodec.writeRequest(fd: fd, method: method, path: path, headers: headers, body: body) + if shutdownWrite { + _ = Darwin.shutdown(fd, SHUT_WR) + } + return try readBufferedResponse(fd: fd) + } + + public static func sendRequestHead( + fd: Int32, + method: String, + path: String, + headers: HTTPHeaders = HTTPHeaders() + ) throws { + try HTTPCodec.writeRequest(fd: fd, method: method, path: path, headers: headers, body: nil) + } + + public static func readBufferedResponse(fd: Int32, maxBodySize: Int = 64 * 1024 * 1024) throws -> HTTPBufferedResponse { + let (head, prelude) = try HTTPCodec.readResponseHead(fd: fd) + let reader = HTTPBodyReader(fd: fd, framing: HTTPCodec.responseFraming(for: head), prelude: prelude) + let body = try reader.readAll(maxSize: maxBodySize) + return HTTPBufferedResponse(head: head, body: body) + } + + public static func readResponseHead(fd: Int32) throws -> (HTTPResponseHead, HTTPBodyReader) { + let (head, prelude) = try HTTPCodec.readResponseHead(fd: fd) + return (head, HTTPBodyReader(fd: fd, framing: HTTPCodec.responseFraming(for: head), prelude: prelude)) + } + + public static func performUpgradeRequest( + fd: Int32, + method: String = "GET", + path: String, + headers: HTTPHeaders, + expectedStatus: HTTPStatus = .switchingProtocols + ) throws -> HTTPUpgradedConnection { + try HTTPCodec.writeRequest(fd: fd, method: method, path: path, headers: headers, body: nil) + let (responseHead, prelude) = try HTTPCodec.readResponseHead(fd: fd) + guard responseHead.status == expectedStatus else { + throw HTTPError.malformedStatusLine( + "Expected \(expectedStatus.rawValue), got \(responseHead.status.rawValue)" + ) + } + return HTTPUpgradedConnection(responseHead: responseHead, prelude: prelude) + } +} + +private struct SocketBodyWriter: HTTPBodyWriter { + let fd: Int32 + + func write(_ data: Data) throws { + try HTTPCodec.writeAll(fd: fd, data: data) + } + + func write(_ buffer: UnsafeRawBufferPointer) throws { + guard let base = buffer.baseAddress else { return } + try HTTPCodec.writeAll(fd: fd, ptr: base, count: buffer.count) + } +} + +private final class CountingBodyWriter: HTTPBodyWriter, @unchecked Sendable { + private let socketWriter: SocketBodyWriter + private let expectedCount: Int + private var bytesWritten = 0 + + init(fd: Int32, expectedCount: Int) { + socketWriter = SocketBodyWriter(fd: fd) + self.expectedCount = expectedCount + } + + func write(_ data: Data) throws { + bytesWritten += data.count + guard bytesWritten <= expectedCount else { + throw HTTPError.unexpectedEOF(read: bytesWritten, expected: expectedCount) + } + try socketWriter.write(data) + } + + func write(_ buffer: UnsafeRawBufferPointer) throws { + bytesWritten += buffer.count + guard bytesWritten <= expectedCount else { + throw HTTPError.unexpectedEOF(read: bytesWritten, expected: expectedCount) + } + try socketWriter.write(buffer) + } + + func finish() throws { + guard bytesWritten == expectedCount else { + throw HTTPError.unexpectedEOF(read: bytesWritten, expected: expectedCount) + } + } +} diff --git a/Packages/GhostHTTP/Tests/GhostHTTPTests/GhostHTTPTests.swift b/Packages/GhostHTTP/Tests/GhostHTTPTests/GhostHTTPTests.swift new file mode 100644 index 0000000..2a1ed64 --- /dev/null +++ b/Packages/GhostHTTP/Tests/GhostHTTPTests/GhostHTTPTests.swift @@ -0,0 +1,199 @@ +import XCTest +import Darwin +@testable import GhostHTTP + +final class GhostHTTPTests: XCTestCase { + func testReadRequestWithContentLengthBody() throws { + let pair = try makeSocketPair() + defer { + Darwin.close(pair.0) + Darwin.close(pair.1) + } + + let body = Data("hello world".utf8) + try HTTPCodec.writeRequest(fd: pair.0, method: "POST", path: "/clipboard", headers: HTTPHeaders(["Content-Type": "text/plain"]), body: body) + _ = Darwin.shutdown(pair.0, SHUT_WR) + + let (request, prelude) = try HTTPCodec.readRequest(fd: pair.1) + XCTAssertEqual(request.method, .POST) + XCTAssertEqual(request.path, "/clipboard") + XCTAssertEqual(request.header("Content-Type"), "text/plain") + + let reader = HTTPBodyReader(fd: pair.1, framing: HTTPCodec.requestFraming(for: request), prelude: prelude) + XCTAssertEqual(try reader.readAll(), body) + } + + func testReadChunkedRequestBody() throws { + let pair = try makeSocketPair() + defer { + Darwin.close(pair.0) + Darwin.close(pair.1) + } + + let request = "POST /upload HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "5\r\n" + + "hello\r\n" + + "6\r\n" + + " world\r\n" + + "0\r\n" + + "\r\n" + try HTTPCodec.writeAll(fd: pair.0, data: Data(request.utf8)) + _ = Darwin.shutdown(pair.0, SHUT_WR) + + let (head, prelude) = try HTTPCodec.readRequest(fd: pair.1) + XCTAssertEqual(head.path, "/upload") + XCTAssertEqual(HTTPCodec.requestFraming(for: head), .chunked) + + let reader = HTTPBodyReader(fd: pair.1, framing: .chunked, prelude: prelude) + XCTAssertEqual(try reader.readAll(), Data("hello world".utf8)) + } + + func testWriteStreamingResponse() throws { + let pair = try makeSocketPair() + defer { + Darwin.close(pair.0) + Darwin.close(pair.1) + } + + let response = HTTPResponse( + status: .ok, + headers: HTTPHeaders(["Content-Type": "application/octet-stream"]), + body: .stream(contentLength: 11) { writer in + try writer.write(Data("hello ".utf8)) + try writer.write(Data("world".utf8)) + } + ) + + try HTTPCodec.writeResponse(response, fd: pair.0) + _ = Darwin.shutdown(pair.0, SHUT_WR) + + let buffered = try HTTPClient.readBufferedResponse(fd: pair.1) + XCTAssertEqual(buffered.head.status, .ok) + XCTAssertEqual(buffered.head.header("Content-Type"), "application/octet-stream") + XCTAssertEqual(buffered.body, Data("hello world".utf8)) + } + + func testBufferedClientRequestResponseRoundTrip() throws { + let pair = try makeSocketPair() + defer { + Darwin.close(pair.0) + Darwin.close(pair.1) + } + + let responseData = Data(#"{"ok":true}"#.utf8) + DispatchQueue.global().async { + do { + let (request, prelude) = try HTTPCodec.readRequest(fd: pair.1) + let reader = HTTPBodyReader(fd: pair.1, framing: HTTPCodec.requestFraming(for: request), prelude: prelude) + let body = try reader.readAll() + XCTAssertEqual(request.method, .POST) + XCTAssertEqual(request.path, "/api/v1/test") + XCTAssertEqual(body, Data("payload".utf8)) + try HTTPCodec.writeResponse(.json(responseData), fd: pair.1) + _ = Darwin.shutdown(pair.1, SHUT_WR) + } catch { + XCTFail("server failed: \(error)") + } + } + + let response = try HTTPClient.performRequest( + fd: pair.0, + method: "POST", + path: "/api/v1/test", + headers: HTTPHeaders(["Content-Type": "text/plain"]), + body: Data("payload".utf8) + ) + + XCTAssertEqual(response.head.status, .ok) + XCTAssertEqual(response.head.header("Content-Type"), "application/json") + XCTAssertEqual(response.body, responseData) + } + + func testResponseHeadWithPreludeSupportsStreamingRead() throws { + let pair = try makeSocketPair() + defer { + Darwin.close(pair.0) + Darwin.close(pair.1) + } + + let response = "HTTP/1.1 200 OK\r\nContent-Length: 12\r\nX-Test: yes\r\n\r\nhello world!" + try HTTPCodec.writeAll(fd: pair.0, data: Data(response.utf8)) + _ = Darwin.shutdown(pair.0, SHUT_WR) + + let (head, reader) = try HTTPClient.readResponseHead(fd: pair.1) + XCTAssertEqual(head.status, .ok) + XCTAssertEqual(head.header("X-Test"), "yes") + XCTAssertEqual(try reader.readAll(), Data("hello world!".utf8)) + } + + func testUpgradeRequestReturnsResponseHeadAndPrelude() throws { + let pair = try makeSocketPair() + defer { + Darwin.close(pair.0) + Darwin.close(pair.1) + } + + DispatchQueue.global().async { + do { + let (request, _) = try HTTPCodec.readRequest(fd: pair.1) + XCTAssertEqual(request.path, "/shell") + XCTAssertEqual(request.header("Upgrade"), "websocket") + + let response = "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "\r\n" + + "post-upgrade-bytes" + try HTTPCodec.writeAll(fd: pair.1, data: Data(response.utf8)) + _ = Darwin.shutdown(pair.1, SHUT_WR) + } catch { + XCTFail("server failed: \(error)") + } + } + + let upgraded = try HTTPClient.performUpgradeRequest( + fd: pair.0, + path: "/shell", + headers: HTTPHeaders([ + "Upgrade": "websocket", + "Connection": "Upgrade", + "Sec-WebSocket-Key": "test-key" + ]) + ) + + XCTAssertEqual(upgraded.responseHead.status, .switchingProtocols) + XCTAssertEqual(upgraded.responseHead.header("Upgrade"), "websocket") + XCTAssertEqual(upgraded.prelude, Data("post-upgrade-bytes".utf8)) + } + + func testNilBodyRequestsEmitContentLengthZero() throws { + let request = HTTPCodec.requestData(method: "GET", path: "/health") + let text = String(decoding: request, as: UTF8.self) + XCTAssertTrue(text.contains("Content-Length: 0\r\n")) + XCTAssertTrue(text.hasSuffix("\r\n\r\n")) + } + + func testUpgradeRequestsDoNotInjectContentLengthZero() throws { + let request = HTTPCodec.requestData( + method: "GET", + path: "/shell", + headers: HTTPHeaders([ + "Connection": "Upgrade", + "Upgrade": "websocket", + ]) + ) + let text = String(decoding: request, as: UTF8.self) + XCTAssertFalse(text.contains("Content-Length: 0\r\n")) + } + + private func makeSocketPair() throws -> (Int32, Int32) { + var fds = [Int32](repeating: 0, count: 2) + guard socketpair(AF_UNIX, SOCK_STREAM, 0, &fds) == 0 else { + throw HTTPError.readFailed(errno: errno) + } + return (fds[0], fds[1]) + } +} diff --git a/Website/package-lock.json b/Website/package-lock.json index c1cc79e..15690c8 100644 --- a/Website/package-lock.json +++ b/Website/package-lock.json @@ -244,7 +244,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -267,7 +266,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -288,7 +286,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -305,7 +302,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -322,7 +318,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -339,7 +334,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -356,7 +350,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -373,7 +366,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -390,7 +382,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -407,7 +398,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -424,7 +414,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -441,7 +430,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -458,7 +446,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -481,7 +468,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -504,7 +490,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -527,7 +512,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -550,7 +534,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -573,7 +556,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -596,7 +578,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -619,7 +600,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -642,7 +622,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -662,7 +641,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -682,7 +660,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -702,7 +679,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ diff --git a/Website/public/ipsw.xml b/Website/public/ipsw.xml index 67cc288..408c9d9 100644 --- a/Website/public/ipsw.xml +++ b/Website/public/ipsw.xml @@ -16,13 +16,13 @@ ProductVersion - 26.3.2 + 26.4.1 BuildVersion - 25D2140 + 25E253 FirmwareURL - https://updates.cdn-apple.com/2026WinterFCS/fullrestores/047-94879/40A2B65E-4E49-4EAA-8BEC-62A305007488/UniversalMac_26.3.2_25D2140_Restore.ipsw + https://updates.cdn-apple.com/2026WinterFCS/fullrestores/122-28781/DCB2FF13-06CB-44C2-BCA2-DFCAF3521D46/UniversalMac_26.4.1_25E253_Restore.ipsw FirmwareSHA1 - 338037f50ea1d6b39e7e9f82a5276e8b1f693ee8 + 03078f4af82bff5473398ca49f99288c76253fe8 diff --git a/Website/src/app/blog/ghostvm-v3-beta/page.tsx b/Website/src/app/blog/ghostvm-v3-beta/page.tsx new file mode 100644 index 0000000..9077e20 --- /dev/null +++ b/Website/src/app/blog/ghostvm-v3-beta/page.tsx @@ -0,0 +1,249 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import CodeBlock from "@/components/docs/CodeBlock"; + +export const metadata: Metadata = { + title: + "GhostVM v3 Beta: Interactive Terminal, ASIF Disks, SwiftNIO - GhostVM", + description: + "GhostVM v3 beta: interactive terminal over vsock, ASIF disk images, SwiftNIO guest agent, macOS 26, Swift 6.", + openGraph: { + title: "GhostVM v3 Beta", + description: + "Interactive terminal, ASIF disks, SwiftNIO, macOS 26.", + url: "https://ghostvm.org/blog/ghostvm-v3-beta", + type: "article", + }, +}; + +function PlaceholderImage({ + alt, + caption, +}: { + alt: string; + caption: string; +}) { + return ( +
+
+

{alt}

+
+
+ {caption} +
+
+ ); +} + +export default function GhostVMV3BetaPost() { + return ( + <> +
+ + ← Back to blog + +
+ +
+ + · + 4 min read +
+ +

GhostVM v3 Beta

+ +

+ v3 is a rewrite on macOS 26 and Swift 6. Shell into VMs without SSH, + near-native disk performance, non-blocking guest communication. +

+ + {/* ── Interactive Terminal ─────────────────────────────── */} + +

vmctl shell

+ +

+ Full PTY session into any running VM. Connects over virtio-vsock — + no SSH daemon, no port forwarding, no network config. +

+ + + {`$ vmctl shell ~/VMs/dev.GhostVM +Connecting to 'dev' via vsock... + +dev ~ % whoami +admin +dev ~ % exit +Connection closed.`} + + +

+ Terminal resize, Ctrl-C, signal forwarding all work. Feels like SSH + without the setup. +

+ +

+ The GUI gets an Open Terminal toolbar button that + launches Terminal.app with a shell session already connected. +

+ + + + {/* ── ASIF Disk Images ────────────────────────────────── */} + +

ASIF Disk Images

+ +

+ v3 switches from raw sparse images to Apple Sparse Image Format. + Near-native SSD performance — most noticeable on Xcode builds,{" "} + npm install, large git checkouts. +

+ + + +

+ Legacy VMs auto-migrate on first launch. Non-destructive — your + original disk is preserved until the new image is verified. +

+ + {/* ── SwiftNIO ────────────────────────────────────────── */} + +

SwiftNIO Guest Agent

+ +

+ All blocking server code replaced with a SwiftNIO event loop. + Auto-detects HTTP/1.1 and HTTP/2. File uploads stream directly to disk + instead of buffering in memory. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Server + + Port + + Role +
NIOVsockServer5000HTTP/1.1 + HTTP/2 requests, streaming file uploads
TunnelServer5001CONNECT-based TCP tunneling
HealthServer5002JSON health status
EventPushServer5003NDJSON event streaming
+
+ + {/* ── Platform ────────────────────────────────────────── */} + +

macOS 26 + Swift 6

+ +

+ macOS 26 gives us native AF_VSOCK in kqueue — the polling-based + vsock probe from v2 is gone. Swift 6 strict concurrency enforces{" "} + Sendable at compile time across every actor boundary. +

+ +

+ v2.x stays on macOS 15 and continues to get bug fixes. +

+ + {/* ── Network ─────────────────────────────────────────── */} + +

Network Bridge Monitoring

+ +

+ Bridged VMs now survive host network changes. Switch Wi-Fi, wake from + sleep on a different network — GhostVM detects the change via{" "} + NWPathMonitor, cycles the bridge attachment, and triggers + guest DHCP renewal automatically. +

+ + {/* ── Migration ───────────────────────────────────────── */} + +

Upgrading

+ +
    +
  • + macOS 26 required. Stay on v2.x for macOS 15. +
  • +
  • + Disk migration is automatic. Prompted on first launch, + non-destructive, cancellable. +
  • +
  • + Update GhostTools. The guest agent needs the v3 + version for terminal and SwiftNIO features. +
  • +
  • + VM bundles carry over. Configs, clones, and snapshots + are unchanged. +
  • +
+ +

Try It

+ +

+ Download the beta or build from source: +

+ + + {`git clone https://github.com/groundwater/GhostVM +cd GhostVM && git checkout experiment/nio-http2 +make app`} + + +

+ Bugs and feedback:{" "} + + GitHub Issues + +

+ +
+ +
+ + ← Back to blog + +
+ + ); +} diff --git a/Website/src/app/blog/page.tsx b/Website/src/app/blog/page.tsx index 93ca75d..4835c4f 100644 --- a/Website/src/app/blog/page.tsx +++ b/Website/src/app/blog/page.tsx @@ -8,6 +8,14 @@ export const metadata: Metadata = { }; const posts = [ + { + slug: "ghostvm-v3-beta", + title: "GhostVM v3 Beta: Interactive Terminal, ASIF Disks, SwiftNIO", + date: "2026-04-28", + readingTime: "4 min read", + summary: + "Shell into VMs without SSH, near-native disk performance, non-blocking guest communication. A rewrite on macOS 26 and Swift 6.", + }, { slug: "why-you-cant-clone-your-mac", title: "Why You Can't Clone Your Mac Into a VM", diff --git a/experiments/kqueue-vsock-test/main.swift b/experiments/kqueue-vsock-test/main.swift new file mode 100644 index 0000000..5f4d90b --- /dev/null +++ b/experiments/kqueue-vsock-test/main.swift @@ -0,0 +1,288 @@ +#!/usr/bin/env swift +// +// kqueue-vsock-test: Does kqueue fire for AF_VSOCK on macOS? +// +// Run inside a macOS guest VM. Listens on vsock port 9999, +// then tests whether kqueue/poll/DispatchSource detect readability +// on the accepted connection. +// +// Usage: +// 1. Build & run in the guest: swift main.swift +// 2. From the host, connect: vmctl remote --name exec /usr/bin/true +// (or any other vsock connection to port 9999) +// + +import Foundation +import Darwin + +// MARK: - vsock constants & structs + +let AF_VSOCK: Int32 = 40 +let VMADDR_CID_ANY: UInt32 = 0xFFFFFFFF + +struct sockaddr_vm { + var svm_len: UInt8 + var svm_family: UInt8 + var svm_reserved1: UInt16 + var svm_port: UInt32 + var svm_cid: UInt32 + var svm_zero: (UInt8, UInt8, UInt8, UInt8) = (0, 0, 0, 0) + + init(port: UInt32, cid: UInt32 = VMADDR_CID_ANY) { + self.svm_len = UInt8(MemoryLayout.size) + self.svm_family = UInt8(AF_VSOCK) + self.svm_reserved1 = 0 + self.svm_port = port + self.svm_cid = cid + } +} + +// MARK: - Create & bind server socket + +let testPort: UInt32 = 9999 + +let serverFD = socket(AF_VSOCK, SOCK_STREAM, 0) +guard serverFD >= 0 else { + print("FAIL: socket() failed, errno=\(errno) (\(String(cString: strerror(errno))))") + print(" Are you running inside a macOS VM?") + exit(1) +} + +var optval: Int32 = 1 +setsockopt(serverFD, SOL_SOCKET, SO_REUSEADDR, &optval, socklen_t(MemoryLayout.size)) + +var addr = sockaddr_vm(port: testPort) +let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + Darwin.bind(serverFD, sockPtr, socklen_t(MemoryLayout.size)) + } +} +guard bindResult == 0 else { + print("FAIL: bind() failed, errno=\(errno) (\(String(cString: strerror(errno))))") + close(serverFD) + exit(1) +} + +guard listen(serverFD, 1) == 0 else { + print("FAIL: listen() failed, errno=\(errno)") + close(serverFD) + exit(1) +} + +print("Listening on vsock port \(testPort)...") +print("Now connect from the host to trigger the test.") +print("") + +// MARK: - Test 1: kqueue on the LISTEN socket (accept readiness) + +print("=== Test 1: kqueue on listen socket (waiting for connection) ===") + +let kq = kqueue() +guard kq >= 0 else { + print("FAIL: kqueue() failed, errno=\(errno)") + close(serverFD) + exit(1) +} + +// Register EVFILT_READ on the server socket +var kev = kevent( + ident: UInt(serverFD), + filter: Int16(EVFILT_READ), + flags: UInt16(EV_ADD | EV_ENABLE), + fflags: 0, + data: 0, + udata: nil +) + +let registerResult = kevent(kq, &kev, 1, nil, 0, nil) +if registerResult < 0 { + print("FAIL: kevent register failed, errno=\(errno) (\(String(cString: strerror(errno))))") + print(" kqueue does NOT support AF_VSOCK on this macOS version.") + close(kq) + close(serverFD) + exit(1) +} +print(" kevent register: OK (no error)") + +// Wait for readability with a 30-second timeout +print(" Waiting up to 30s for kqueue to fire on listen socket...") +var timeout = timespec(tv_sec: 30, tv_nsec: 0) +var outEvent = kevent() +let nEvents = kevent(kq, nil, 0, &outEvent, 1, &timeout) + +if nEvents < 0 { + print(" FAIL: kevent wait failed, errno=\(errno) (\(String(cString: strerror(errno))))") + close(kq) + close(serverFD) + exit(1) +} else if nEvents == 0 { + print(" TIMEOUT: kqueue did NOT fire within 30s.") + print(" Trying blocking accept() to see if a connection is actually pending...") + + // Set non-blocking to test + let flags = fcntl(serverFD, F_GETFL, 0) + _ = fcntl(serverFD, F_SETFL, flags | O_NONBLOCK) + var clientAddr = sockaddr_vm(port: 0) + var addrLen = socklen_t(MemoryLayout.size) + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + Darwin.accept(serverFD, sockPtr, &addrLen) + } + } + if clientFD >= 0 { + print(" RESULT: Connection WAS pending but kqueue didn't fire! kqueue BROKEN for vsock.") + close(clientFD) + } else { + print(" RESULT: No connection pending. Timed out waiting for a connection.") + print(" Connect from host and re-run to test.") + } + close(kq) + close(serverFD) + exit(1) +} else { + print(" kqueue FIRED! nEvents=\(nEvents)") + print(" filter=\(outEvent.filter) flags=\(outEvent.flags) data=\(outEvent.data)") + print(" RESULT: kqueue WORKS for AF_VSOCK listen sockets!") +} + +close(kq) + +// MARK: - Accept the connection + +var clientAddr = sockaddr_vm(port: 0) +var addrLen = socklen_t(MemoryLayout.size) +let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + Darwin.accept(serverFD, sockPtr, &addrLen) + } +} +guard clientFD >= 0 else { + print("FAIL: accept() failed, errno=\(errno)") + close(serverFD) + exit(1) +} +print("\nAccepted connection (fd=\(clientFD))") + +// MARK: - Test 2: kqueue on connected socket (data readiness) + +print("\n=== Test 2: kqueue on connected socket (waiting for data) ===") + +let kq2 = kqueue() +guard kq2 >= 0 else { + print("FAIL: kqueue() failed") + close(clientFD) + close(serverFD) + exit(1) +} + +var kev2 = kevent( + ident: UInt(clientFD), + filter: Int16(EVFILT_READ), + flags: UInt16(EV_ADD | EV_ENABLE), + fflags: 0, + data: 0, + udata: nil +) + +let reg2 = kevent(kq2, &kev2, 1, nil, 0, nil) +if reg2 < 0 { + print(" FAIL: kevent register on connected socket, errno=\(errno) (\(String(cString: strerror(errno))))") +} else { + print(" kevent register: OK") +} + +// The host should be sending HTTP data, so wait for it +var timeout2 = timespec(tv_sec: 10, tv_nsec: 0) +var outEvent2 = kevent() +let nEvents2 = kevent(kq2, nil, 0, &outEvent2, 1, &timeout2) + +if nEvents2 > 0 { + print(" kqueue FIRED on connected socket! data=\(outEvent2.data)") + + // Try reading + var buf = [UInt8](repeating: 0, count: 4096) + let n = read(clientFD, &buf, buf.count) + if n > 0 { + let str = String(bytes: buf[0.." + print(" Read \(n) bytes: \(str.prefix(200))") + } + print(" RESULT: kqueue WORKS for AF_VSOCK connected sockets!") +} else if nEvents2 == 0 { + print(" TIMEOUT: kqueue did NOT fire on connected socket within 10s") + + // Check if data is actually available via blocking read + let flags = fcntl(clientFD, F_GETFL, 0) + _ = fcntl(clientFD, F_SETFL, flags | O_NONBLOCK) + var buf = [UInt8](repeating: 0, count: 4096) + let n = read(clientFD, &buf, buf.count) + if n > 0 { + print(" Data WAS available (\(n) bytes) but kqueue didn't fire! BROKEN.") + } else if n == 0 { + print(" EOF — connection closed by host before sending data") + } else { + print(" EAGAIN — no data pending. Host may not have sent anything yet.") + } + print(" RESULT: kqueue does NOT work for AF_VSOCK connected sockets.") +} else { + print(" FAIL: kevent wait error, errno=\(errno)") +} + +// MARK: - Test 3: poll() on connected socket + +print("\n=== Test 3: poll() on connected socket ===") + +var pollFD = pollfd(fd: clientFD, events: Int16(POLLIN), revents: 0) +let pollResult = poll(&pollFD, 1, 5000) // 5s timeout + +if pollResult > 0 { + print(" poll() returned \(pollResult), revents=\(pollFD.revents)") + print(" RESULT: poll() WORKS for AF_VSOCK!") +} else if pollResult == 0 { + print(" poll() timed out") + print(" RESULT: poll() does NOT work for AF_VSOCK.") +} else { + print(" poll() error, errno=\(errno)") +} + +// MARK: - Test 4: DispatchSource read source + +print("\n=== Test 4: DispatchSource.makeReadSource on connected socket ===") + +// Write some data back to the client so the host side gets a response, +// then the host might close — we just want to see if DispatchSource fires + +let semaphore = DispatchSemaphore(value: 0) +var dispatchSourceFired = false + +let readSource = DispatchSource.makeReadSource(fileDescriptor: clientFD, queue: .global()) +readSource.setEventHandler { + dispatchSourceFired = true + print(" DispatchSource FIRED! estimatedBytes=\(readSource.data)") + semaphore.signal() +} +readSource.setCancelHandler { + if !dispatchSourceFired { + print(" DispatchSource was cancelled without firing") + } +} +readSource.resume() + +let waitResult = semaphore.wait(timeout: .now() + 5) +readSource.cancel() + +if waitResult == .timedOut && !dispatchSourceFired { + print(" DispatchSource did NOT fire within 5s") + print(" RESULT: DispatchSource does NOT work for AF_VSOCK.") +} else if dispatchSourceFired { + print(" RESULT: DispatchSource WORKS for AF_VSOCK!") +} + +// MARK: - Summary + +print("\n=== Summary ===") +print("macOS version: \(ProcessInfo.processInfo.operatingSystemVersionString)") +print("Tests complete. See results above.") + +close(kq2) +close(clientFD) +close(serverFD) diff --git a/macOS/GhostTools/Package.swift b/macOS/GhostTools/Package.swift index f78dc6d..e563951 100644 --- a/macOS/GhostTools/Package.swift +++ b/macOS/GhostTools/Package.swift @@ -1,19 +1,29 @@ -// swift-tools-version:5.9 +// swift-tools-version:6.2 import PackageDescription let package = Package( name: "GhostTools", platforms: [ - .macOS(.v14) + .macOS(.v15) ], products: [ .executable(name: "GhostTools", targets: ["GhostTools"]) ], - dependencies: [], + dependencies: [ + .package(path: "../../Packages/GhostHTTP") + ], targets: [ + .target( + name: "CPty", + path: "Sources/CPty", + publicHeadersPath: "include" + ), .executableTarget( name: "GhostTools", - dependencies: [], + dependencies: [ + "CPty", + .product(name: "GhostHTTP", package: "GhostHTTP"), + ], exclude: ["Resources/Info.plist", "Resources/Info.template.plist", "Resources/entitlements.plist"], linkerSettings: [ .unsafeFlags(["-Xlinker", "-sectcreate", "-Xlinker", "__TEXT", "-Xlinker", "__info_plist", "-Xlinker", "../../build/generated-plists/GhostTools-Info.plist"], .when(configuration: .release)) @@ -21,7 +31,9 @@ let package = Package( ), .testTarget( name: "GhostToolsTests", - dependencies: ["GhostTools"] + dependencies: [ + "GhostTools", + ] ) ] ) diff --git a/macOS/GhostTools/Sources/CPty/cpty.c b/macOS/GhostTools/Sources/CPty/cpty.c new file mode 100644 index 0000000..07c488b --- /dev/null +++ b/macOS/GhostTools/Sources/CPty/cpty.c @@ -0,0 +1 @@ +// Empty — we only need the header to expose forkpty/openpty to Swift diff --git a/macOS/GhostTools/Sources/CPty/include/cpty.h b/macOS/GhostTools/Sources/CPty/include/cpty.h new file mode 100644 index 0000000..7e4ea5d --- /dev/null +++ b/macOS/GhostTools/Sources/CPty/include/cpty.h @@ -0,0 +1,8 @@ +#ifndef CPTY_H +#define CPTY_H + +#include +#include +#include + +#endif diff --git a/macOS/GhostTools/Sources/GhostTools/App.swift b/macOS/GhostTools/Sources/GhostTools/App.swift index 49c8217..84125d3 100644 --- a/macOS/GhostTools/Sources/GhostTools/App.swift +++ b/macOS/GhostTools/Sources/GhostTools/App.swift @@ -59,9 +59,7 @@ struct GhostToolsApp: App { final class AppDelegate: NSObject, NSApplicationDelegate { private static let logger = Logger(subsystem: "org.ghostvm.ghosttools", category: "App") private var statusItem: NSStatusItem? - private var server: VsockServer? - private var tunnelServer: TunnelServer? - private var healthServer: HealthServer? + private var server: VsockListener? private var isServerRunning = false private var lastTunnelError: String? private var isFilePickerOpen = false @@ -165,9 +163,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } startServer() - startTunnelServer() - startHealthServer() - startEventPushServer() + configureTunnelService() + startEventPushService() startPortScanner() startForegroundAppService() AutoUpdateService.shared.start(appDelegate: self) @@ -684,9 +681,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ForegroundAppService.shared.stop() PortScannerService.shared.stop() server?.stop() - tunnelServer?.stop() - healthServer?.stop() - EventPushServer.shared.stop() + TunnelService.shared.stop() + EventPushService.shared.stop() } // MARK: - URL Handling @@ -907,89 +903,66 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func startServer() { print("[GhostTools] startServer() called") - Task { - do { - print("[GhostTools] Creating router...") - let router = Router() - print("[GhostTools] Creating VsockServer on port 5000...") - server = VsockServer(port: 5000, router: router) - - server?.onStatusChange = { [weak self] running in - Task { @MainActor in - print("[GhostTools] Server status changed: \(running)") - self?.isServerRunning = running - self?.updateStatusIcon(connected: running) - self?.updateMenu() - } + do { + let router = Router() + + print("[GhostTools] Creating VsockListener on port 5000...") + let srv = VsockListener(port: 5000, router: router) + srv.onStatusChange = { [weak self] running in + Task { @MainActor in + print("[GhostTools] Server status changed: \(running)") + self?.isServerRunning = running + self?.updateStatusIcon(connected: running) + self?.updateMenu() } - - print("[GhostTools] Starting vsock server...") - try await server?.start() - print("[GhostTools] Server started successfully") - } catch { - print("[GhostTools] Failed to start server: \(error)") - print("[GhostTools] Error details: \(String(describing: error))") - isServerRunning = false - updateStatusIcon(connected: false) - updateMenu() } + server = srv + try srv.start() + + print("[GhostTools] Server started successfully") + } catch { + print("[GhostTools] Failed to start server: \(error)") + print("[GhostTools] Error details: \(String(describing: error))") + isServerRunning = false + updateStatusIcon(connected: false) + updateMenu() } } - private func startTunnelServer() { - Self.logger.info("startTunnelServer() called") - Task { - do { - tunnelServer = TunnelServer() - tunnelServer?.onOperationalError = { [weak self] runtimeError in - Task { @MainActor in - self?.lastTunnelError = runtimeError.message - self?.updateMenu() - } - Self.logger.error("Tunnel operational error phase=\(runtimeError.phase.rawValue, privacy: .public) targetPort=\(runtimeError.targetPort ?? 0): \(runtimeError.message, privacy: .public)") - } - tunnelServer?.onConnectionSuccess = { [weak self] in - Task { @MainActor in - self?.lastTunnelError = nil - self?.updateMenu() - } - } - Self.logger.info("Starting tunnel server on vsock port 5001...") - try await tunnelServer?.start() - Self.logger.info("Tunnel server started successfully") - } catch { - let message = "Failed to start tunnel server: \(error.localizedDescription)" - Self.logger.error("\(message, privacy: .public)") - lastTunnelError = message - updateMenu() + private func configureTunnelService() { + Self.logger.info("configureTunnelService() called") + let srv = TunnelService.shared + srv.onOperationalError = { [weak self] runtimeError in + Task { @MainActor in + self?.lastTunnelError = runtimeError.message + self?.updateMenu() } + Self.logger.error("Tunnel operational error phase=\(runtimeError.phase.rawValue, privacy: .public) targetPort=\(runtimeError.targetPort ?? 0): \(runtimeError.message, privacy: .public)") } - } - - private func startHealthServer() { - print("[GhostTools] startHealthServer() called") - Task { - do { - healthServer = HealthServer() - print("[GhostTools] Starting health server on vsock port 5002...") - try await healthServer?.start() - print("[GhostTools] Health server started successfully") - } catch { - print("[GhostTools] Failed to start health server: \(error)") + srv.onConnectionSuccess = { [weak self] in + Task { @MainActor in + self?.lastTunnelError = nil + self?.updateMenu() } } + do { + try srv.start() + Self.logger.info("Tunnel service registered on unified HTTP server") + } catch { + let message = "Failed to configure tunnel service: \(error.localizedDescription)" + Self.logger.error("\(message, privacy: .public)") + lastTunnelError = message + updateMenu() + } } - private func startEventPushServer() { - print("[GhostTools] startEventPushServer() called") - Task { - do { - print("[GhostTools] Starting event push server on vsock port 5003...") - try await EventPushServer.shared.start() - print("[GhostTools] Event push server started successfully") - } catch { - print("[GhostTools] Failed to start event push server: \(error)") - } + private func startEventPushService() { + print("[GhostTools] startEventPushService() called") + do { + try EventPushService.shared.start() + print("[GhostTools] Event push service attached to unified HTTP server") + } catch { + print("[GhostTools] Failed to start event push server: \(error)") } } diff --git a/macOS/GhostTools/Sources/GhostTools/Server/BlockingServer/ConnectionWorker.swift b/macOS/GhostTools/Sources/GhostTools/Server/BlockingServer/ConnectionWorker.swift new file mode 100644 index 0000000..0c6a019 --- /dev/null +++ b/macOS/GhostTools/Sources/GhostTools/Server/BlockingServer/ConnectionWorker.swift @@ -0,0 +1,130 @@ +import Foundation +import Darwin +import os + +/// Owns one accepted client fd from `accept()` to `close()`. Reads one HTTP +/// request, dispatches to the router (or to the WebSocket upgrade path), +/// writes the response, and closes. No keep-alive in v1 — one request per +/// connection, then close. +final class ConnectionWorker { + private static let logger = Logger(subsystem: "org.ghostvm.ghosttools", category: "ConnectionWorker") + + private let fd: Int32 + private let router: Router + + init(fd: Int32, router: Router) { + self.fd = fd + self.router = router + } + + func run() { + defer { Darwin.close(fd) } + + // Set a receive timeout so that a buggy/silent peer can't pin this + // thread forever. Generous default (60 s) — long-running endpoints + // (file uploads) reset their progress every chunk, which keeps the + // timer fresh because each successful read() resets it. + var timeout = timeval(tv_sec: 60, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + + let request: HTTPRequest + let prelude: Data + do { + (request, prelude) = try HTTPCodec.readRequest(fd: fd) + } catch { + Self.logger.error("readRequest failed: \(String(describing: error), privacy: .public)") + // Best-effort error reply (may itself fail if the peer is gone). + try? HTTPCodec.writeResponse(.error(.badRequest, message: "\(error)"), fd: fd) + return + } + + Self.logger.debug("request \(request.method.rawValue, privacy: .public) \(request.path, privacy: .public)") + + // WebSocket upgrade — /api/v1/shell — is handled specially. The + // worker hands the fd to the WS shell after the handshake succeeds. + let pathOnly = request.path.components(separatedBy: "?").first ?? request.path + if pathOnly == "/api/v1/shell" { + let cols = parseUInt16Query(request.path, key: "cols") ?? 80 + let rows = parseUInt16Query(request.path, key: "rows") ?? 24 + let term = parseStringQuery(request.path, key: "term") ?? "xterm-256color" + do { + try WebSocketShell.handleUpgradeAndRun( + fd: fd, + request: request, + cols: cols, + rows: rows, + term: term, + prelude: prelude + ) + } catch { + Self.logger.error("ws shell failed: \(String(describing: error), privacy: .public)") + } + return + } + if pathOnly == "/api/v1/tunnel-connect" { + do { + try TunnelService.shared.handleUpgrade(fd: fd, request: request, prelude: prelude) + } catch { + Self.logger.error("tunnel upgrade failed: \(String(describing: error), privacy: .public)") + } + return + } + if pathOnly == "/api/v1/event-stream" { + do { + try EventPushService.shared.serveUpgradedConnection(fd: fd, prelude: prelude) + } catch { + Self.logger.error("event-stream upgrade failed: \(String(describing: error), privacy: .public)") + } + return + } + + // Pick the body framing the way swift-nio's HTTPRequestDecoder did. + // RFC 7230 §3.3.3: Transfer-Encoding wins over Content-Length. + let framing: BodyFraming + let te = request.header("transfer-encoding")?.lowercased() + if let te, te.split(separator: ",").map({ $0.trimmingCharacters(in: .whitespaces) }).contains("chunked") { + framing = .chunked + } else if let cl = request.contentLength { + framing = .knownLength(cl) + } else { + framing = .eof + } + let body = BodyReader(fd: fd, framing: framing, prelude: prelude) + + let response: HTTPResponse + do { + response = try router.route(request: request, body: body) + } catch { + Self.logger.error("router threw: \(String(describing: error), privacy: .public)") + response = .error(.internalServerError, message: String(describing: error)) + } + + do { + try HTTPCodec.writeResponse(response, fd: fd) + } catch { + Self.logger.error("writeResponse failed: \(String(describing: error), privacy: .public)") + } + + // Make a best effort to drain unread body bytes after the response + // has already been sent. The connection is closed either way. + body.discard() + } + + private func parseUInt16Query(_ path: String, key: String) -> UInt16? { + guard let raw = parseStringQuery(path, key: key) else { return nil } + return UInt16(raw) + } + + private func parseStringQuery(_ path: String, key: String) -> String? { + guard let queryStart = path.firstIndex(of: "?") else { return nil } + let query = String(path[path.index(after: queryStart)...]) + for pair in query.split(separator: "&") { + let parts = pair.split(separator: "=", maxSplits: 1) + if parts.count == 2 && parts[0] == key { + let value = String(parts[1]) + return value.removingPercentEncoding ?? value + } + } + return nil + } +} diff --git a/macOS/GhostTools/Sources/GhostTools/Server/BlockingServer/EventPushService.swift b/macOS/GhostTools/Sources/GhostTools/Server/BlockingServer/EventPushService.swift new file mode 100644 index 0000000..ce7936d --- /dev/null +++ b/macOS/GhostTools/Sources/GhostTools/Server/BlockingServer/EventPushService.swift @@ -0,0 +1,231 @@ +import Foundation +import Darwin +import os + +/// Event types pushed from guest to host over NDJSON. Pure value type — no +/// NIO/Network bindings. +enum PushEvent { + case files([String]) + case urls([String]) + case log(String) + case ports([PortInfo]) + case app(name: String, bundleId: String, iconBase64: String?) + + var jsonLine: String { + switch self { + case .files(let paths): + let escaped = paths.map { escapeJSON($0) } + return "{\"type\":\"files\",\"files\":[\(escaped.joined(separator: ","))]}" + case .urls(let urls): + let escaped = urls.map { escapeJSON($0) } + return "{\"type\":\"urls\",\"urls\":[\(escaped.joined(separator: ","))]}" + case .log(let message): + return "{\"type\":\"log\",\"message\":\(escapeJSON(message))}" + case .ports(let portInfos): + let entries = portInfos.map { "{\"port\":\($0.port),\"process\":\(escapeJSON($0.process))}" } + return "{\"type\":\"ports\",\"ports\":[\(entries.joined(separator: ","))]}" + case .app(let name, let bundleId, let iconBase64): + var json = "{\"type\":\"app\",\"name\":\(escapeJSON(name)),\"bundleId\":\(escapeJSON(bundleId))" + if let icon = iconBase64 { + json += ",\"icon\":\(escapeJSON(icon))" + } + json += "}" + return json + } + } + + private func escapeJSON(_ s: String) -> String { + let escaped = s + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\t", with: "\\t") + return "\"\(escaped)\"" + } +} + +/// Manages a single upgraded event-stream client attached via the unified +/// HTTP server on vsock port 5000. Guest services call `pushEvent(_:)` to +/// send NDJSON lines downstream. +/// +/// Concurrency model: +/// - One accept thread reaps incoming connections; if a new client arrives +/// while one is connected, the old one is closed. +/// - The current client fd is guarded by a lock; `pushEvent` writes under +/// the lock with blocking I/O. Failed writes (EPIPE/ECONNRESET) drop the fd. +/// - A per-client reader thread blocks on `read()` and clears the fd on +/// EOF so we don't keep handing events to a dead socket. +final class EventPushService: @unchecked Sendable { + static let shared = EventPushService() + + private static let logger = Logger(subsystem: "org.ghostvm.ghosttools", category: "EventPushService") + + private let clientLock = NSLock() + private let writeLock = NSLock() + private var clientFD: Int32 = -1 + /// Monotonically increasing id, bumped each time we install a new client + /// fd. The reader thread carries the generation it was started with, so + /// it only clears `clientFD` if it still owns it (avoids a benign race + /// where a new connection arrives just as the old one tears down). + private var clientGeneration: UInt64 = 0 + + private var stopping = false + + private init() {} + + deinit { stop() } + + func start() throws { + stopping = false + Self.logger.info("Event push service attached to unified HTTP server on port 5000") + } + + func stop() { + stopping = true + clearClient() + } + + func serveUpgradedConnection(fd: Int32, prelude: Data = Data()) throws { + let response = HTTPResponse( + status: .switchingProtocols, + headers: [ + "Upgrade": "event-stream", + "Connection": "Upgrade", + ] + ) + try HTTPCodec.writeResponseHead(fd: fd, status: response.status, headers: response.headers) + clearReceiveTimeout(fd: fd) + configureSendTimeout(fd: fd) + + clientLock.lock() + let oldFD = clientFD + clientFD = fd + clientGeneration &+= 1 + let generation = clientGeneration + clientLock.unlock() + + if oldFD >= 0 { + Darwin.shutdown(oldFD, SHUT_RDWR) + } + + pushCurrentState() + + Self.logger.info("event-stream client attached fd=\(fd)") + readerLoop(fd: fd, generation: generation, initialData: prelude) + } + + // MARK: - Push API + + /// Sends a single NDJSON line to the connected client. No-op if there + /// is no client. Safe to call from any thread. + func pushEvent(_ event: PushEvent) { + let line = event.jsonLine + "\n" + let bytes = Array(line.utf8) + + clientLock.lock() + let fd = clientFD + let generation = clientGeneration + guard fd >= 0 else { + clientLock.unlock() + return + } + let dupFD = Darwin.dup(fd) + clientLock.unlock() + guard dupFD >= 0 else { return } + + writeLock.lock() + let ok = bytes.withUnsafeBufferPointer { ptr -> Bool in + guard let base = ptr.baseAddress else { return false } + return Self.writeAll(fd: dupFD, ptr: base, count: ptr.count) + } + writeLock.unlock() + Darwin.close(dupFD) + if !ok { + // Write failed — peer is gone. Tear down so the next event + // doesn't waste time hitting the same dead fd. + Self.logger.info("pushEvent write failed; closing client fd") + clientLock.lock() + if clientFD == fd { + clientFD = -1 + clientGeneration &+= 1 + clientLock.unlock() + Darwin.shutdown(fd, SHUT_RDWR) + } else { + clientLock.unlock() + } + return + } + + clientLock.lock() + if clientFD == fd && clientGeneration != generation { + Self.logger.debug("event push write completed after client generation changed") + } + clientLock.unlock() + } + + private func readerLoop(fd: Int32, generation: UInt64, initialData: Data) { + if !initialData.isEmpty { + Self.logger.debug("event-stream client sent \(initialData.count) bytes of upgrade prelude; ignoring") + } + var buf = [UInt8](repeating: 0, count: 256) + while true { + let n = Darwin.read(fd, &buf, buf.count) + if n < 0 && errno == EINTR { continue } + if n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK) { continue } + if n <= 0 { break } + // Host shouldn't be sending anything; ignore inbound bytes. + } + // Clear the slot if this fd is still current. + clientLock.lock() + if clientFD == fd && clientGeneration == generation { + clientFD = -1 + clientGeneration &+= 1 + clientLock.unlock() + Self.logger.info("client disconnected fd=\(fd)") + } else { + clientLock.unlock() + } + } + + private func clearClient() { + clientLock.lock() + let fd = clientFD + clientFD = -1 + clientGeneration &+= 1 + clientLock.unlock() + if fd >= 0 { Darwin.shutdown(fd, SHUT_RDWR) } + } + + // MARK: - Helpers + + private static func writeAll(fd: Int32, ptr: UnsafeRawPointer, count: Int) -> Bool { + var offset = 0 + while offset < count { + let n = Darwin.write(fd, ptr + offset, count - offset) + if n > 0 { offset += n } + else if n < 0 && errno == EINTR { continue } + else { return false } + } + return true + } + + private func clearReceiveTimeout(fd: Int32) { + var noTimeout = timeval(tv_sec: 0, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &noTimeout, socklen_t(MemoryLayout.size)) + } + + private func configureSendTimeout(fd: Int32) { + var timeout = timeval(tv_sec: 10, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) + } + + private func pushCurrentState() { + pushEvent(.files(FileService.shared.listOutgoingFiles())) + pushEvent(.urls(URLService.shared.listPendingURLs())) + pushEvent(.ports(PortScanner.shared.getListeningPortsWithProcess())) + DispatchQueue.main.async { + ForegroundAppService.shared.pushCurrentAppToConnectedClient() + } + } +} diff --git a/macOS/GhostTools/Sources/GhostTools/Server/BlockingServer/HTTPCodec.swift b/macOS/GhostTools/Sources/GhostTools/Server/BlockingServer/HTTPCodec.swift new file mode 100644 index 0000000..3ac1064 --- /dev/null +++ b/macOS/GhostTools/Sources/GhostTools/Server/BlockingServer/HTTPCodec.swift @@ -0,0 +1,11 @@ +import GhostHTTP + +typealias HTTPMethod = GhostHTTP.HTTPMethod +typealias HTTPStatus = GhostHTTP.HTTPStatus +typealias HTTPCodec = GhostHTTP.HTTPCodec +typealias HTTPRequest = HTTPRequestHead +typealias HTTPResponse = GhostHTTP.HTTPResponse +typealias BodyReader = HTTPBodyReader +typealias BodyFraming = HTTPBodyFraming +typealias ResponseBody = HTTPResponseBody +typealias HTTPCodecError = HTTPError diff --git a/macOS/GhostTools/Sources/GhostTools/Server/BlockingServer/TunnelService.swift b/macOS/GhostTools/Sources/GhostTools/Server/BlockingServer/TunnelService.swift new file mode 100644 index 0000000..fe5dab8 --- /dev/null +++ b/macOS/GhostTools/Sources/GhostTools/Server/BlockingServer/TunnelService.swift @@ -0,0 +1,193 @@ +import Foundation +import Darwin +import os + +/// Operational/runtime error reported by the tunnel for telemetry surfaces. +struct TunnelRuntimeError: Sendable { + enum Phase: String, Sendable { + case handshakeRead + case handshakeProtocol + case connectLocal + case bridge + } + + let phase: Phase + let message: String + let targetPort: UInt16? + let timestamp: Date + + init(phase: Phase, message: String, targetPort: UInt16? = nil, timestamp: Date = Date()) { + self.phase = phase + self.message = message + self.targetPort = targetPort + self.timestamp = timestamp + } +} + +/// Upgrade-only tunnel service attached to the unified HTTP server on vsock +/// port 5000. After a successful `101 Switching Protocols` response, the +/// connection becomes a raw byte bridge to `127.0.0.1:`. +final class TunnelService: @unchecked Sendable { + static let shared = TunnelService() + + private static let logger = Logger(subsystem: "org.ghostvm.ghosttools", category: "TunnelService") + + var onStatusChange: ((Bool) -> Void)? + var onOperationalError: ((TunnelRuntimeError) -> Void)? + var onConnectionSuccess: (() -> Void)? + + private init() {} + + func start() throws { + Self.logger.info("Tunnel service attached to unified HTTP server on port 5000") + onStatusChange?(true) + } + + func stop() { + onStatusChange?(false) + } + + func handleUpgrade(fd: Int32, request: HTTPRequest, prelude: Data = Data()) throws { + guard let portText = request.header("Tunnel-Port") else { + try HTTPCodec.writeResponse(.error(.badRequest, message: "Missing Tunnel-Port header"), fd: fd) + report(.init(phase: .handshakeRead, message: "Missing Tunnel-Port header"), id: UUID().uuidString) + return + } + guard let targetPort = UInt16(portText) else { + try HTTPCodec.writeResponse(.error(.badRequest, message: "Invalid Tunnel-Port header"), fd: fd) + report(.init(phase: .handshakeProtocol, message: "Invalid Tunnel-Port header '\(portText)'"), id: UUID().uuidString) + return + } + + let connectionID = UUID().uuidString + let tcpFD = socket(AF_INET, SOCK_STREAM, 0) + if tcpFD < 0 { + try? HTTPCodec.writeResponse(.error(.badGateway, message: "socket() failed"), fd: fd) + report(.init(phase: .connectLocal, message: "socket() failed errno=\(errno)", targetPort: targetPort), id: connectionID) + return + } + var bridgeStarted = false + defer { + if !bridgeStarted { + Darwin.close(tcpFD) + } + } + + var sa = sockaddr_in() + sa.sin_family = sa_family_t(AF_INET) + sa.sin_port = in_port_t(targetPort.bigEndian) + sa.sin_addr.s_addr = inet_addr("127.0.0.1") + + let connectResult = withUnsafePointer(to: &sa) { ptr -> Int32 in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(tcpFD, $0, socklen_t(MemoryLayout.size)) + } + } + if connectResult != 0 { + let e = errno + try? HTTPCodec.writeResponse(.error(.badGateway, message: "Connection refused to port \(targetPort)"), fd: fd) + report(.init(phase: .connectLocal, message: "connect() failed errno=\(e)", targetPort: targetPort), id: connectionID) + Darwin.close(tcpFD) + return + } + + try HTTPCodec.writeResponseHead( + fd: fd, + status: .switchingProtocols, + headers: [ + "Upgrade": "tunnel", + "Connection": "Upgrade", + ] + ) + clearReceiveTimeout(fd: fd) + configureSendTimeout(fd: fd) + configureSendTimeout(fd: tcpFD) + if !prelude.isEmpty { + try HTTPCodec.writeAll(fd: tcpFD, data: prelude) + } + onConnectionSuccess?() + Self.logger.info("unified tunnel bridge established id=\(connectionID, privacy: .public) targetPort=\(targetPort) tcpFD=\(tcpFD)") + bridgeStarted = true + bridgeBytes(srcFD: fd, dstFD: tcpFD, connectionID: connectionID, targetPort: targetPort, closeSourceFD: false) + } + + private func bridgeBytes(srcFD: Int32, dstFD: Int32, connectionID: String, targetPort: UInt16, closeSourceFD: Bool) { + let group = DispatchGroup() + + group.enter() + let aToB = Thread { [weak self] in + self?.pumpOneWay(from: srcFD, to: dstFD, label: "tunnel", connectionID: connectionID, targetPort: targetPort) + group.leave() + } + aToB.name = "TunnelService-pump-\(srcFD)→\(dstFD)" + aToB.start() + + group.enter() + pumpOneWay(from: dstFD, to: srcFD, label: "tunnel-reverse", connectionID: connectionID, targetPort: targetPort) + group.leave() + + group.wait() + if closeSourceFD { + Darwin.close(srcFD) + } + Darwin.close(dstFD) + Self.logger.info("bridge closed id=\(connectionID, privacy: .public) targetPort=\(targetPort)") + } + + private func pumpOneWay(from src: Int32, to dst: Int32, label: String, connectionID: String, targetPort: UInt16) { + var buf = [UInt8](repeating: 0, count: 65536) + while true { + let n = Darwin.read(src, &buf, buf.count) + if n < 0 && errno == EINTR { continue } + if n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK) { continue } + if n <= 0 { + _ = Darwin.shutdown(dst, SHUT_WR) + return + } + let ok = buf.withUnsafeBufferPointer { ptr -> Bool in + guard let base = ptr.baseAddress else { return false } + return Self.writeAll(fd: dst, ptr: base, count: n) + } + if !ok { + report(.init(phase: .bridge, message: "write failed in \(label)", targetPort: targetPort), id: connectionID) + _ = Darwin.shutdown(dst, SHUT_WR) + return + } + } + } + + private func report(_ error: TunnelRuntimeError, id: String) { + let portText = error.targetPort.map(String.init) ?? "none" + if error.phase == .bridge { + Self.logger.warning("Operational tunnel error id=\(id, privacy: .public) phase=\(error.phase.rawValue, privacy: .public) targetPort=\(portText, privacy: .public): \(error.message, privacy: .public)") + } else { + Self.logger.error("Operational tunnel error id=\(id, privacy: .public) phase=\(error.phase.rawValue, privacy: .public) targetPort=\(portText, privacy: .public): \(error.message, privacy: .public)") + } + onOperationalError?(error) + } + + private static func writeAll(fd: Int32, ptr: UnsafeRawPointer, count: Int) -> Bool { + var offset = 0 + while offset < count { + let n = Darwin.write(fd, ptr + offset, count - offset) + if n > 0 { + offset += n + } else if n < 0 && errno == EINTR { + continue + } else { + return false + } + } + return true + } + + private func clearReceiveTimeout(fd: Int32) { + var noTimeout = timeval(tv_sec: 0, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &noTimeout, socklen_t(MemoryLayout.size)) + } + + private func configureSendTimeout(fd: Int32) { + var timeout = timeval(tv_sec: 30, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) + } +} diff --git a/macOS/GhostTools/Sources/GhostTools/Server/BlockingServer/VsockListener.swift b/macOS/GhostTools/Sources/GhostTools/Server/BlockingServer/VsockListener.swift new file mode 100644 index 0000000..c95b343 --- /dev/null +++ b/macOS/GhostTools/Sources/GhostTools/Server/BlockingServer/VsockListener.swift @@ -0,0 +1,124 @@ +import Foundation +import Darwin +import os + +/// Blocking AF_VSOCK listener. Thread-per-connection, bounded. +/// +/// Sits in place of the deleted NIO-based server. Built specifically to +/// sidestep macOS's AF_VSOCK non-blocking-write bug (see +/// `bug-repros/macos-vsock-write-loss/`): blocking `write()` is the only +/// kernel path that handles back-pressure honestly. +final class VsockListener: @unchecked Sendable { + private static let logger = Logger(subsystem: "org.ghostvm.ghosttools", category: "VsockListener") + + private let port: UInt32 + private let router: Router + private let maxConnections: Int + private let connectionSlots: DispatchSemaphore + private var listenFD: Int32 = -1 + private var acceptThread: Thread? + private var stopping = false + + var onStatusChange: ((Bool) -> Void)? + + init(port: UInt32 = 5000, router: Router, maxConnections: Int = 64) { + self.port = port + self.router = router + self.maxConnections = maxConnections + self.connectionSlots = DispatchSemaphore(value: maxConnections) + } + + deinit { stop() } + + /// Synchronous start. Binds, listens, kicks off the accept thread, returns. + /// Throws if bind/listen fails. + func start() throws { + let fd = socket(AF_VSOCK, SOCK_STREAM, 0) + guard fd >= 0 else { throw VsockServerError.socketCreationFailed(errno) } + + var one: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, socklen_t(MemoryLayout.size)) + + var addr = sockaddr_vm(port: port) + let bindResult = withUnsafePointer(to: &addr) { ptr -> Int32 in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + Darwin.close(fd) + throw VsockServerError.bindFailed(errno) + } + guard listen(fd, 128) == 0 else { + Darwin.close(fd) + throw VsockServerError.listenFailed(errno) + } + + listenFD = fd + Self.logger.info("Listening on vsock port \(self.port, privacy: .public) (fd=\(fd))") + onStatusChange?(true) + + let thread = Thread { [weak self] in self?.acceptLoop() } + thread.name = "VsockListener-accept-\(port)" + acceptThread = thread + thread.start() + } + + func stop() { + stopping = true + if listenFD >= 0 { + Darwin.close(listenFD) + listenFD = -1 + } + onStatusChange?(false) + } + + // MARK: - Accept loop + + private func acceptLoop() { + let fd = listenFD + while !stopping { + // Block until a connection slot is free. This is the bound on + // concurrent connections — extra inbound connects sit in the + // kernel listen backlog until a worker finishes. + connectionSlots.wait() + if stopping { + connectionSlots.signal() + return + } + + var clientAddr = sockaddr_vm(port: 0) + var addrLen = socklen_t(MemoryLayout.size) + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr -> Int32 in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { + accept(fd, $0, &addrLen) + } + } + + if clientFD < 0 { + let e = errno + connectionSlots.signal() + if stopping { return } + if e == EINTR { continue } + Self.logger.error("accept failed: errno \(e)") + // Brief pause to avoid a tight error loop on persistent failures. + Thread.sleep(forTimeInterval: 0.1) + continue + } + + Self.logger.debug("accepted client fd=\(clientFD)") + spawnWorker(fd: clientFD) + } + } + + private func spawnWorker(fd: Int32) { + let router = self.router + let slots = self.connectionSlots + let workerThread = Thread { + defer { slots.signal() } + ConnectionWorker(fd: fd, router: router).run() + } + workerThread.name = "VsockListener-worker-\(fd)" + workerThread.start() + } +} diff --git a/macOS/GhostTools/Sources/GhostTools/Server/BlockingServer/WebSocketShell.swift b/macOS/GhostTools/Sources/GhostTools/Server/BlockingServer/WebSocketShell.swift new file mode 100644 index 0000000..13308f1 --- /dev/null +++ b/macOS/GhostTools/Sources/GhostTools/Server/BlockingServer/WebSocketShell.swift @@ -0,0 +1,457 @@ +import Foundation +import Darwin +import CPty +import os + +/// Server-side WebSocket "shell" endpoint. Performs the WS handshake on a +/// blocking fd, spawns a login PTY, and bridges WS frames ↔ PTY bytes using +/// thread-per-direction blocking I/O. Mirrors the host-side vmctl shell +/// code exactly — including the WS frame format we already know works. +enum WebSocketShell { + + private static let logger = Logger(subsystem: "org.ghostvm.ghosttools", category: "WebSocketShell") + + struct LaunchConfiguration { + let executablePath: String + let arguments: [String] + let environment: [String: String] + + static func login(term: String) -> LaunchConfiguration { + let user = ProcessInfo.processInfo.environment["USER"] ?? "root" + return LaunchConfiguration( + executablePath: "/usr/bin/login", + arguments: ["login", "-fp", user], + environment: ["TERM": term] + ) + } + } + + static func handleUpgradeAndRun( + fd: Int32, + request: HTTPRequest, + cols: UInt16, + rows: UInt16, + term: String, + prelude: Data = Data() + ) throws { + try handleUpgradeAndRun( + fd: fd, + request: request, + cols: cols, + rows: rows, + term: term, + prelude: prelude, + launchConfiguration: .login(term: term) + ) + } + + static func handleUpgradeAndRun( + fd: Int32, + request: HTTPRequest, + cols: UInt16, + rows: UInt16, + term: String, + prelude: Data = Data(), + launchConfiguration: LaunchConfiguration + ) throws { + // 1. WS handshake. + guard let key = request.header("Sec-WebSocket-Key") else { + try HTTPCodec.writeResponse(.error(.badRequest, message: "missing Sec-WebSocket-Key"), fd: fd) + return + } + let accept = wsAcceptKey(for: key) + try HTTPCodec.writeResponseHead( + fd: fd, + status: .switchingProtocols, + headers: [ + "Upgrade": "websocket", + "Connection": "Upgrade", + "Sec-WebSocket-Accept": accept, + ] + ) + + // ConnectionWorker sets SO_RCVTIMEO=60s for HTTP requests. A WS shell + // sits idle for arbitrarily long, so clear the timeout — otherwise + // socket reads start returning EAGAIN once a minute and the input + // bridge silently exits. + var noTimeout = timeval(tv_sec: 0, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &noTimeout, socklen_t(MemoryLayout.size)) + + Self.logger.info("WS shell upgraded fd=\(fd) cols=\(cols) rows=\(rows) term=\(term, privacy: .public)") + + let (pid, masterFD) = try Self.spawnPTY( + rows: rows, + cols: cols, + launchConfiguration: launchConfiguration + ) + + defer { + // When the bridge returns, reap the shell. + kill(pid, SIGTERM) + var status: Int32 = 0 + _ = waitpid(pid, &status, 0) + Darwin.close(masterFD) + } + + // 3. Bidirectional bridge. + // - socket → PTY: parse WS frames, write payload to masterFD + // - PTY → socket: read masterFD, frame it as WS binary, write to socket + let group = DispatchGroup() + let socketWriter = LockedFDWriter(fd: fd) + + // socket → PTY + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + var parser = WSFrameParser() + var buf = [UInt8](repeating: 0, count: 16384) + if !prelude.isEmpty { + parser.feed(Array(prelude)) + } + outer: while true { + let n = Darwin.read(fd, &buf, buf.count) + if n < 0 && (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) { continue } + if n <= 0 { break } + parser.feed(Array(buf[0.. Bool in + guard let base = ptr.baseAddress else { return false } + return Self.writeAll(fd: masterFD, ptr: base, count: ptr.count) + } + } + case .text: + // Control message (JSON). Currently only {"type":"resize"}. + Self.handleControlMessage(payload: frame.payload, masterFD: masterFD) + case .close: + break outer + case .ping: + // Reply pong with same payload. + let pong = WSFrameEncoder.encode(opcode: .pong, payload: frame.payload, mask: false) + _ = socketWriter.write(pong) + default: + break + } + } + } + kill(pid, SIGHUP) + group.leave() + } + + // PTY → socket + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + var buf = [UInt8](repeating: 0, count: 16384) + while true { + let n = Darwin.read(masterFD, &buf, buf.count) + if n < 0 && (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) { continue } + if n <= 0 { break } + let frame = WSFrameEncoder.encode(opcode: .binary, payload: Array(buf[0.. 0, rows > 0, + cols <= Int(UInt16.max), rows <= Int(UInt16.max) else { + return + } + var ws = winsize(ws_row: UInt16(rows), ws_col: UInt16(cols), ws_xpixel: 0, ws_ypixel: 0) + _ = ioctl(masterFD, TIOCSWINSZ, &ws) + Self.logger.debug("resize → cols=\(cols) rows=\(rows)") + default: + Self.logger.debug("ignoring unknown control message: \(type, privacy: .public)") + } + } + + // MARK: - Helpers + + /// RFC 6455 server-side accept-key computation: SHA1(key + GUID), base64. + private static func wsAcceptKey(for clientKey: String) -> String { + let combined = clientKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + let digest = sha1(combined) + return digest.base64EncodedString() + } + + private static func sha1(_ input: String) -> Data { + var hash = [UInt8](repeating: 0, count: 20) + input.withCString { ptr in + _ = CC_SHA1_Local(ptr, UInt32(strlen(ptr)), &hash) + } + return Data(hash) + } + + private static func spawnPTY( + rows: UInt16, + cols: UInt16, + launchConfiguration: LaunchConfiguration + ) throws -> (pid: pid_t, masterFD: Int32) { + var masterFDVar: Int32 = -1 + var ws = winsize(ws_row: rows, ws_col: cols, ws_xpixel: 0, ws_ypixel: 0) + let pid = forkpty(&masterFDVar, nil, nil, &ws) + if pid < 0 { + Self.logger.error("forkpty failed: errno \(errno)") + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + if pid == 0 { + for (key, value) in launchConfiguration.environment { + setenv(key, value, 1) + } + let cStrings = launchConfiguration.arguments.map { argument in + argument.withCString { strdup($0) } + } + let argv = cStrings + [nil] + defer { + for pointer in cStrings { + free(pointer) + } + } + _ = execv(launchConfiguration.executablePath, argv) + Darwin._exit(1) + } + return (pid, masterFDVar) + } + + fileprivate static func writeAll(fd: Int32, ptr: UnsafeRawPointer, count: Int) -> Bool { + var offset = 0 + while offset < count { + let n = Darwin.write(fd, ptr + offset, count - offset) + if n > 0 { offset += n } + else if n < 0 && errno == EINTR { continue } + else { return false } + } + return true + } +} + +private final class LockedFDWriter: @unchecked Sendable { + private let fd: Int32 + private let lock = NSLock() + + init(fd: Int32) { + self.fd = fd + } + + func write(_ bytes: [UInt8]) -> Bool { + bytes.withUnsafeBufferPointer { ptr in + guard let base = ptr.baseAddress else { return false } + lock.lock() + defer { lock.unlock() } + return WebSocketShell.writeAll(fd: fd, ptr: base, count: ptr.count) + } + } +} + +// SHA1 via libsystem's CommonCrypto. We can't import CommonCrypto directly +// from a Swift package without a bridging header, so declare the symbol. +@_silgen_name("CC_SHA1") private func CC_SHA1_Local( + _ data: UnsafeRawPointer, + _ len: UInt32, + _ md: UnsafeMutablePointer +) -> UnsafeMutablePointer + +// MARK: - WS frame parser / encoder +// +// Minimal RFC-6455 implementation, masking optional. Server reads masked +// frames from the client and sends unmasked frames back. + +enum WSOpcode: UInt8 { + case continuation = 0x0 + case text = 0x1 + case binary = 0x2 + case close = 0x8 + case ping = 0x9 + case pong = 0xA +} + +struct WSFrame { + let opcode: WSOpcode + let payload: [UInt8] +} + +struct WSFrameParser { + private static let maxPayloadBytes = 16 * 1024 * 1024 + private var buffer: [UInt8] = [] + private var fragmentOpcode: WSOpcode? + private var fragmentPayload: [UInt8] = [] + + mutating func feed(_ data: [UInt8]) { + buffer.append(contentsOf: data) + } + + mutating func nextFrame() -> WSFrame? { + while true { + switch parseRawFrame() { + case .needMoreData: + return nil + case .protocolError: + return protocolFailure() + case .frame(let raw): + if raw.opcode.isControl { + // RFC 6455 §5.5: control frames MUST NOT be fragmented. + if !raw.fin { return protocolFailure() } + return WSFrame(opcode: raw.opcode, payload: raw.payload) + } + + if raw.opcode == .continuation { + guard let baseOpcode = fragmentOpcode else { return protocolFailure() } + fragmentPayload.append(contentsOf: raw.payload) + if fragmentPayload.count > Self.maxPayloadBytes { return protocolFailure() } + if raw.fin { + let payload = fragmentPayload + fragmentOpcode = nil + fragmentPayload = [] + return WSFrame(opcode: baseOpcode, payload: payload) + } + continue + } + + // Data frame (text/binary). + if fragmentOpcode != nil { return protocolFailure() } + if raw.fin { + return WSFrame(opcode: raw.opcode, payload: raw.payload) + } + fragmentOpcode = raw.opcode + fragmentPayload = raw.payload + } + } + } + + private mutating func protocolFailure() -> WSFrame { + buffer.removeAll() + fragmentOpcode = nil + fragmentPayload = [] + return WSFrame(opcode: .close, payload: []) + } + + private enum RawFrameResult { + case frame(RawFrame) + case needMoreData + case protocolError + } + + private mutating func parseRawFrame() -> RawFrameResult { + guard buffer.count >= 2 else { return .needMoreData } + + let fin = (buffer[0] & 0x80) != 0 + let opcodeByte = buffer[0] & 0x0F + guard let opcode = WSOpcode(rawValue: opcodeByte) else { return .protocolError } + let masked = (buffer[1] & 0x80) != 0 + var payloadLen = Int(buffer[1] & 0x7F) + var offset = 2 + + if payloadLen == 126 { + guard buffer.count >= offset + 2 else { return .needMoreData } + payloadLen = (Int(buffer[offset]) << 8) | Int(buffer[offset + 1]) + offset += 2 + } else if payloadLen == 127 { + guard buffer.count >= offset + 8 else { return .needMoreData } + payloadLen = 0 + for i in 0..<8 { + payloadLen = (payloadLen << 8) | Int(buffer[offset + i]) + } + offset += 8 + } + + guard payloadLen <= Self.maxPayloadBytes else { return .protocolError } + + var maskKey = [UInt8]() + if masked { + guard buffer.count >= offset + 4 else { return .needMoreData } + maskKey = Array(buffer[offset..= offset + payloadLen else { return .needMoreData } + var payload = Array(buffer[offset.. [UInt8] { + var frame: [UInt8] = [] + frame.append((fin ? 0x80 : 0x00) | opcode.rawValue) + let maskBit: UInt8 = mask ? 0x80 : 0x00 + if payload.count <= 125 { + frame.append(maskBit | UInt8(payload.count)) + } else if payload.count <= 0xFFFF { + frame.append(maskBit | 126) + frame.append(UInt8((payload.count >> 8) & 0xFF)) + frame.append(UInt8(payload.count & 0xFF)) + } else { + frame.append(maskBit | 127) + let len64 = UInt64(payload.count) + for i in (0..<8).reversed() { + frame.append(UInt8((len64 >> (i * 8)) & 0xFF)) + } + } + if mask { + var key = [UInt8](repeating: 0, count: 4) + _ = SecRandomCopyBytesShim(4, &key) + frame.append(contentsOf: key) + for (i, b) in payload.enumerated() { + frame.append(b ^ key[i % 4]) + } + } else { + frame.append(contentsOf: payload) + } + return frame + } +} + +private func SecRandomCopyBytesShim(_ count: Int, _ bytes: inout [UInt8]) -> Int { + for i in 0.. String { - let escaped = s - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - .replacingOccurrences(of: "\t", with: "\\t") - return "\"\(escaped)\"" - } -} - -/// EventPushServer listens on vsock port 5003 for persistent connections from the host. -/// Guest services push NDJSON events when data is available (files queued, URLs opened, logs). -/// -/// Protocol: -/// 1. Host connects to port 5003 -/// 2. Guest pushes NDJSON lines: {"type":"files","files":[...]}\n -/// 3. Host reads lines and dispatches events -/// 4. Connection drops if either side disconnects -final class EventPushServer: @unchecked Sendable { - static let shared = EventPushServer() - - private let port: UInt32 = 5003 - private var serverSocket: Int32 = -1 - private var isRunning = false - - /// Current connected client fd (-1 if none) - private var clientFd: Int32 = -1 - private let clientLock = NSLock() - - /// Serial queue for writes (preserves NDJSON line ordering) - private let writeQueue = DispatchQueue(label: "\(Bundle.main.bundleIdentifier ?? "org.ghostvm.com.ghostvm.guest-tools").eventpush.write") - - /// Called on main thread when a new host client connects. - var onClientConnected: (() -> Void)? - - private init() {} - - deinit { - stop() - } - - func start() async throws { - print("[EventPushServer] Creating socket on port \(port)") - - serverSocket = socket(AF_VSOCK, SOCK_STREAM, 0) - guard serverSocket >= 0 else { - throw VsockServerError.socketCreationFailed(errno) - } - - var optval: Int32 = 1 - setsockopt(serverSocket, SOL_SOCKET, SO_REUSEADDR, &optval, socklen_t(MemoryLayout.size)) - - var addr = sockaddr_vm(port: port) - let bindResult = withUnsafePointer(to: &addr) { addrPtr in - addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.bind(serverSocket, sockaddrPtr, socklen_t(MemoryLayout.size)) - } - } - - guard bindResult == 0 else { - close(serverSocket) - throw VsockServerError.bindFailed(errno) - } - - guard listen(serverSocket, 1) == 0 else { - close(serverSocket) - throw VsockServerError.listenFailed(errno) - } - - // Keep socket BLOCKING — kqueue/poll don't fire for AF_VSOCK on macOS guests - isRunning = true - print("[EventPushServer] Listening on vsock port \(port)") - - // Blocking accept loop on dedicated thread - DispatchQueue.global(qos: .utility).async { [weak self] in - while self?.isRunning == true { - var clientAddr = sockaddr_vm(port: 0) - var addrLen = socklen_t(MemoryLayout.size) - - let newFd = withUnsafeMutablePointer(to: &clientAddr) { addrPtr in - addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.accept(self?.serverSocket ?? -1, sockaddrPtr, &addrLen) - } - } - - if newFd < 0 { - if errno == EINTR { continue } - break // socket closed by stop() - } - - // Close any existing client - self?.clientLock.lock() - let oldFd = self?.clientFd ?? -1 - self?.clientFd = newFd - self?.clientLock.unlock() - if oldFd >= 0 { - close(oldFd) - } - - print("[EventPushServer] Client connected, fd=\(newFd)") - - // Notify listeners on main thread - if let callback = self?.onClientConnected { - DispatchQueue.main.async { callback() } - } - - // Block on read until host disconnects (on background thread) - DispatchQueue.global(qos: .utility).async { [weak self] in - var buf = [UInt8](repeating: 0, count: 1) - while true { - let n = Darwin.read(newFd, &buf, 1) - if n <= 0 { break } - } - - // Client disconnected — clear if still current - self?.clientLock.lock() - if self?.clientFd == newFd { - self?.clientFd = -1 - } - self?.clientLock.unlock() - close(newFd) - print("[EventPushServer] Client disconnected") - } - } - } - } - - /// Push an event to the connected host. No-op if no client connected. - /// Writes are dispatched asynchronously on a serial queue to avoid blocking the caller. - func pushEvent(_ event: PushEvent) { - clientLock.lock() - let fd = clientFd - clientLock.unlock() - - guard fd >= 0 else { return } - - let line = event.jsonLine + "\n" - writeQueue.async { [weak self] in - line.withCString { ptr in - let len = strlen(ptr) - let result = Darwin.write(fd, ptr, len) - if result < 0 { - print("[EventPushServer] Write failed (fd=\(fd)): errno \(errno)") - self?.clientLock.lock() - if self?.clientFd == fd { self?.clientFd = -1 } - self?.clientLock.unlock() - close(fd) - } else { - print("[EventPushServer] Wrote \(result)/\(len) bytes to fd=\(fd)") - } - } - } - } - - func stop() { - isRunning = false - - clientLock.lock() - let fd = clientFd - clientFd = -1 - clientLock.unlock() - if fd >= 0 { close(fd) } - - if serverSocket >= 0 { - close(serverSocket) - serverSocket = -1 - } - } -} diff --git a/macOS/GhostTools/Sources/GhostTools/Server/HTTPParser.swift b/macOS/GhostTools/Sources/GhostTools/Server/HTTPParser.swift deleted file mode 100644 index f882ec4..0000000 --- a/macOS/GhostTools/Sources/GhostTools/Server/HTTPParser.swift +++ /dev/null @@ -1,170 +0,0 @@ -import Foundation - -/// HTTP method enum -enum HTTPMethod: String { - case GET - case POST - case PUT - case DELETE - case HEAD - case OPTIONS - case PATCH -} - -/// HTTP status codes -enum HTTPStatus: Int { - case ok = 200 - case created = 201 - case noContent = 204 - case badRequest = 400 - case unauthorized = 401 - case forbidden = 403 - case notFound = 404 - case methodNotAllowed = 405 - case requestTimeout = 408 - case internalServerError = 500 - - var reasonPhrase: String { - switch self { - case .ok: return "OK" - case .created: return "Created" - case .noContent: return "No Content" - case .badRequest: return "Bad Request" - case .unauthorized: return "Unauthorized" - case .forbidden: return "Forbidden" - case .notFound: return "Not Found" - case .methodNotAllowed: return "Method Not Allowed" - case .requestTimeout: return "Request Timeout" - case .internalServerError: return "Internal Server Error" - } - } -} - -/// Parsed HTTP request -struct HTTPRequest { - let method: HTTPMethod - let path: String - let headers: [String: String] - let body: Data? - - /// Gets a header value (case-insensitive) - func header(_ name: String) -> String? { - let lowercased = name.lowercased() - for (key, value) in headers { - if key.lowercased() == lowercased { - return value - } - } - return nil - } -} - -/// HTTP response -struct HTTPResponse { - var status: HTTPStatus - var headers: [String: String] - var body: Data? - - init(status: HTTPStatus, headers: [String: String] = [:], body: Data? = nil) { - self.status = status - self.headers = headers - self.body = body - } - - /// Creates a JSON response - static func json(_ data: Data, status: HTTPStatus = .ok) -> HTTPResponse { - var headers = ["Content-Type": "application/json"] - headers["Content-Length"] = "\(data.count)" - return HTTPResponse(status: status, headers: headers, body: data) - } - - /// Creates an error response - static func error(_ status: HTTPStatus, message: String) -> HTTPResponse { - let payload: [String: String] = ["error": message] - let body = (try? JSONSerialization.data(withJSONObject: payload)) ?? Data(#"{"error":"unknown"}"#.utf8) - return json(body, status: status) - } -} - -/// Simple HTTP/1.1 parser -enum HTTPParser { - /// Parses an HTTP request from raw data - static func parseRequest(_ data: Data) -> HTTPRequest? { - guard let string = String(data: data, encoding: .utf8) else { - return nil - } - - // Split into lines - let lines = string.components(separatedBy: "\r\n") - guard !lines.isEmpty else { - return nil - } - - // Parse request line: METHOD PATH HTTP/VERSION - let requestLineParts = lines[0].split(separator: " ", maxSplits: 2) - guard requestLineParts.count >= 2 else { - return nil - } - - guard let method = HTTPMethod(rawValue: String(requestLineParts[0])) else { - return nil - } - - let path = String(requestLineParts[1]) - - // Parse headers - var headers: [String: String] = [:] - var bodyStartIndex = 1 - for i in 1.. Data { - var result = "HTTP/1.1 \(response.status.rawValue) \(response.status.reasonPhrase)\r\n" - - // Add headers - var headers = response.headers - if let body = response.body { - headers["Content-Length"] = "\(body.count)" - } else { - headers["Content-Length"] = "0" - } - headers["Connection"] = "close" - - for (key, value) in headers { - result += "\(key): \(value)\r\n" - } - - result += "\r\n" - - var data = Data(result.utf8) - if let body = response.body { - data.append(body) - } - - return data - } -} diff --git a/macOS/GhostTools/Sources/GhostTools/Server/HealthServer.swift b/macOS/GhostTools/Sources/GhostTools/Server/HealthServer.swift deleted file mode 100644 index 71ed54d..0000000 --- a/macOS/GhostTools/Sources/GhostTools/Server/HealthServer.swift +++ /dev/null @@ -1,106 +0,0 @@ -import Foundation - -/// HealthServer listens on vsock port 5002 for persistent health check connections. -/// -/// Protocol: -/// 1. Host connects to port 5002 -/// 2. Server writes: {"status":"ok","version":""}\n -/// 3. Server blocks on read() until host disconnects -/// 4. Connection close = host detects unhealthy -/// -/// Accepts one connection at a time. New connections replace the old one. -final class HealthServer: @unchecked Sendable { - private let port: UInt32 = 5002 - private var serverSocket: Int32 = -1 - private var isRunning = false - - init() {} - - deinit { - stop() - } - - func start() async throws { - print("[HealthServer] Creating socket on port \(port)") - - serverSocket = socket(AF_VSOCK, SOCK_STREAM, 0) - guard serverSocket >= 0 else { - throw VsockServerError.socketCreationFailed(errno) - } - - var optval: Int32 = 1 - setsockopt(serverSocket, SOL_SOCKET, SO_REUSEADDR, &optval, socklen_t(MemoryLayout.size)) - - var addr = sockaddr_vm(port: port) - let bindResult = withUnsafePointer(to: &addr) { addrPtr in - addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.bind(serverSocket, sockaddrPtr, socklen_t(MemoryLayout.size)) - } - } - - guard bindResult == 0 else { - close(serverSocket) - throw VsockServerError.bindFailed(errno) - } - - guard listen(serverSocket, 1) == 0 else { - close(serverSocket) - throw VsockServerError.listenFailed(errno) - } - - // Keep socket BLOCKING — kqueue/poll don't fire for AF_VSOCK on macOS guests - isRunning = true - print("[HealthServer] Listening on vsock port \(port)") - - // Blocking accept loop on dedicated thread - DispatchQueue.global(qos: .utility).async { [weak self] in - while self?.isRunning == true { - var clientAddr = sockaddr_vm(port: 0) - var addrLen = socklen_t(MemoryLayout.size) - - let clientSocket = withUnsafeMutablePointer(to: &clientAddr) { addrPtr in - addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.accept(self?.serverSocket ?? -1, sockaddrPtr, &addrLen) - } - } - - if clientSocket < 0 { - if errno == EINTR { continue } - break // socket closed by stop() - } - - // Handle on background thread (blocks until disconnect) - DispatchQueue.global(qos: .utility).async { - self?.handleConnection(clientSocket) - } - } - } - } - - private func handleConnection(_ fd: Int32) { - defer { close(fd) } - - // Write version line - let json = "{\"status\":\"ok\",\"version\":\"\(kGhostToolsVersion)\"}\n" - _ = json.withCString { ptr in - Darwin.write(fd, ptr, strlen(ptr)) - } - - // Block on read until host disconnects - var buffer = [UInt8](repeating: 0, count: 1) - while true { - let n = Darwin.read(fd, &buffer, 1) - if n <= 0 { break } - } - - print("[HealthServer] Client disconnected") - } - - func stop() { - isRunning = false - if serverSocket >= 0 { - close(serverSocket) - serverSocket = -1 - } - } -} diff --git a/macOS/GhostTools/Sources/GhostTools/Server/Router.swift b/macOS/GhostTools/Sources/GhostTools/Server/Router.swift index 532cc89..78ebe15 100644 --- a/macOS/GhostTools/Sources/GhostTools/Server/Router.swift +++ b/macOS/GhostTools/Sources/GhostTools/Server/Router.swift @@ -1,33 +1,53 @@ import AppKit import Foundation +import Darwin +import GhostHTTP -/// Router that dispatches HTTP requests to handlers -/// Note: No authentication required - vsock provides host-only access +private let execOutputQueue = DispatchQueue(label: "GhostTools.Router.execOutput", qos: .userInitiated) +private final class ExecOutputCapture: @unchecked Sendable { + var stdout = Data() + var stderr = Data() +} + +/// Router that dispatches HTTP requests to handlers. +/// +/// Sync end-to-end. Worker threads call `route(request:, body:)` directly — +/// no Task, no continuation, no event loop. Handlers may block (e.g. on +/// `Process.waitUntilExit()` or on socket writes for streaming responses); +/// each connection has its own thread so head-of-line blocking is per- +/// connection only. +/// +/// No authentication — vsock is host-only by construction. final class Router: @unchecked Sendable { init() {} - /// Handles an HTTP request and returns a response - func handle(_ request: HTTPRequest) async -> HTTPResponse { + private func onMain(_ body: @MainActor () -> T) -> T { + DispatchQueue.main.sync { + MainActor.assumeIsolated { + body() + } + } + } + + /// Handles one HTTP request. The body reader can be drained synchronously + /// (small request) or in chunks (large upload). The returned response can + /// be buffered bytes or a streaming producer. + func route(request: HTTPRequest, body: BodyReader) throws -> HTTPResponse { let fullPath = request.path let path = fullPath.components(separatedBy: "?").first ?? fullPath - // Health check if path == "/health" { return handleHealth(request) } - // Note: Authentication disabled - vsock provides host-only access - // The host VM is the only entity that can connect via vsock - - // Route to appropriate handler if path == "/api/v1/clipboard" { - return await handleClipboard(request) + return handleClipboard(request: request, body: body) } else if path == "/api/v1/files" { return handleFileList(request) } else if path == "/api/v1/files/receive" { - return handleFileReceive(request) + return handleFileReceive(request: request, body: body) } else if path.hasPrefix("/api/v1/files/") { - return handleFileGet(request) + return handleFileSend(request: request) } else if path == "/api/v1/urls" { return handleURLs(request) } else if path == "/api/v1/ports" { @@ -35,15 +55,15 @@ final class Router: @unchecked Sendable { } else if path == "/api/v1/logs" { return handleLogs(request) } else if path == "/api/v1/open" { - return handleOpen(request) + return handleOpen(request: request, body: body) } else if path == "/api/v1/apps/frontmost" { return handleFrontmostApp(request) } else if path == "/api/v1/apps" || path.hasPrefix("/api/v1/apps/") { - return handleApps(request) + return handleApps(request: request, body: body) } else if path == "/api/v1/fs" || path == "/api/v1/fs/mkdir" || path == "/api/v1/fs/delete" || path == "/api/v1/fs/move" { - return handleFS(request) + return handleFS(request: request, body: body) } else if path == "/api/v1/exec" { - return await handleExec(request) + return handleExec(request: request, body: body) } return HTTPResponse.error(.notFound, message: "Not Found") @@ -61,12 +81,12 @@ final class Router: @unchecked Sendable { // MARK: - Clipboard - private func handleClipboard(_ request: HTTPRequest) async -> HTTPResponse { + private func handleClipboard(request: HTTPRequest, body: BodyReader) -> HTTPResponse { switch request.method { case .GET: return getClipboard() case .POST: - return setClipboard(request) + return setClipboard(request: request, body: body) default: return HTTPResponse.error(.methodNotAllowed, message: "Method not allowed") } @@ -74,7 +94,14 @@ final class Router: @unchecked Sendable { private func getClipboard() -> HTTPResponse { log("[Router] GET /clipboard") - guard let (data, type) = ClipboardService.shared.getClipboardData() else { + // ClipboardService is @MainActor. Hop to the main thread and assume + // isolation so the type system is satisfied in a sync world. + let result: (data: Data, type: String)? = DispatchQueue.main.sync { + MainActor.assumeIsolated { + ClipboardService.shared.getClipboardData() + } + } + guard let (data, type) = result else { log("[Router] No clipboard content") return HTTPResponse(status: .noContent) } @@ -82,16 +109,29 @@ final class Router: @unchecked Sendable { log("[Router] Returning clipboard: type=\(type), \(data.count) bytes") let headers: [String: String] = [ "Content-Type": "application/octet-stream", - "Content-Length": "\(data.count)", "X-Clipboard-Type": type, ] - return HTTPResponse(status: .ok, headers: headers, body: data) + return HTTPResponse(status: .ok, headers: headers, body: .bytes(data)) } - private func setClipboard(_ request: HTTPRequest) -> HTTPResponse { - log("[Router] POST /clipboard") - guard let body = request.body, !body.isEmpty else { - log("[Router] No request body") + private func setClipboard(request: HTTPRequest, body: BodyReader) -> HTTPResponse { + let framing: String + switch body.framing { + case .knownLength(let n): framing = "cl=\(n)" + case .chunked: framing = "chunked" + case .eof: framing = "eof" + } + log("[Router] POST /clipboard \(framing)") + + let raw: Data + do { + raw = try body.readAll(maxSize: 100 * 1024 * 1024) + } catch { + log("[Router] POST /clipboard: body read failed: \(error)") + return HTTPResponse.error(.badRequest, message: "Failed to read body: \(error)") + } + guard !raw.isEmpty else { + log("[Router] POST /clipboard: empty body — rejecting") return HTTPResponse.error(.badRequest, message: "Request body required") } @@ -99,46 +139,63 @@ final class Router: @unchecked Sendable { let explicitType = request.header("X-Clipboard-Type") let contentType = request.header("Content-Type")?.lowercased() - // Backward compatibility: older clients POSTed JSON like - // {"content":"...","type":"public.utf8-plain-text"}. + // Backward compat: older clients posted JSON {"content":..., "type":...}. if explicitType == nil, contentType?.contains("application/json") == true, - let object = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let object = try? JSONSerialization.jsonObject(with: raw) as? [String: Any], let content = object["content"] as? String { let parsedType = (object["type"] as? String) ?? "public.utf8-plain-text" return (Data(content.utf8), parsedType) } - - return (body, explicitType ?? "public.utf8-plain-text") + return (raw, clipboardType(explicitType: explicitType, contentType: contentType)) }() log("[Router] Setting clipboard: type=\(type), \(clipboardBody.count) bytes") - guard ClipboardService.shared.setClipboardData(clipboardBody, type: type) else { - log("[Router] Failed to set clipboard") + let didSet: Bool = DispatchQueue.main.sync { + MainActor.assumeIsolated { + ClipboardService.shared.setClipboardData(clipboardBody, type: type) + } + } + guard didSet else { + log("[Router] POST /clipboard: setClipboardData returned false (type=\(type), \(clipboardBody.count) bytes)") return HTTPResponse.error(.internalServerError, message: "Failed to set clipboard") } - - log("[Router] Clipboard set successfully") + log("[Router] POST /clipboard: ok") return HTTPResponse(status: .ok) } - // MARK: - Files + private func clipboardType(explicitType: String?, contentType: String?) -> String { + if let explicitType, !explicitType.isEmpty { + return explicitType + } + guard let contentType else { return "public.utf8-plain-text" } + let mimeType = contentType.split(separator: ";", maxSplits: 1).first? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + switch mimeType { + case "image/png": return "public.png" + case "image/tiff": return "public.tiff" + case "image/jpeg": return "public.jpeg" + case "text/rtf", "application/rtf": return "public.rtf" + case "text/plain": return "public.utf8-plain-text" + default: return "public.utf8-plain-text" + } + } + + // MARK: - Files (list + clear) private func handleFileList(_ request: HTTPRequest) -> HTTPResponse { switch request.method { case .GET: - // Return outgoing files (queued for host to fetch) let files = FileService.shared.listOutgoingFiles() - log("[Router] GET /files - returning \(files.count) outgoing file(s)") + log("[Router] GET /files - \(files.count) outgoing file(s)") let response = FileListResponse(files: files) - guard let data = try? JSONEncoder().encode(response) else { return HTTPResponse.error(.internalServerError, message: "Failed to encode response") } return HTTPResponse.json(data) case .DELETE: - // Clear the outgoing file queue FileService.shared.clearOutgoingFiles() log("[Router] DELETE /files - queue cleared") return HTTPResponse(status: .ok) @@ -148,89 +205,199 @@ final class Router: @unchecked Sendable { } } - private func handleFileReceive(_ request: HTTPRequest) -> HTTPResponse { - guard request.method == .POST else { + // MARK: - Files: streaming send (GET /api/v1/files/{path}) + + /// Streams the requested file as the response body. Content-Length set + /// from `stat`; bytes pulled from disk in 64 KiB chunks and written via + /// the BlockingServer's StreamingWriter. Scales to arbitrarily-large + /// files — never buffers more than one chunk in memory. + private func handleFileSend(request: HTTPRequest) -> HTTPResponse { + guard request.method == .GET else { return HTTPResponse.error(.methodNotAllowed, message: "Method not allowed") } - guard let body = request.body, !body.isEmpty else { - return HTTPResponse.error(.badRequest, message: "Request body required") + let prefix = "/api/v1/files/" + let pathOnly = request.path.components(separatedBy: "?").first ?? request.path + let encoded = pathOnly.hasPrefix(prefix) ? String(pathOnly.dropFirst(prefix.count)) : pathOnly + let filePath = encoded.removingPercentEncoding ?? encoded + guard !filePath.isEmpty else { + return HTTPResponse.error(.badRequest, message: "Path required") + } + + let info: (url: URL, size: Int, filename: String, permissions: Int?) + do { + info = try FileService.shared.statFile(at: filePath) + } catch FileServiceError.accessDenied { + return HTTPResponse.error(.forbidden, message: "Access denied") + } catch { + return HTTPResponse.error(.notFound, message: "File not found") + } + + log("[Router] GET /files/\(info.filename) (\(info.size) bytes) — streaming") + + var headers: [String: String] = [ + "Content-Type": "application/octet-stream", + "Content-Disposition": "attachment; filename=\"\(escapeContentDispositionFilename(info.filename))\"", + ] + if let perms = info.permissions { + headers["X-Permissions"] = String(perms, radix: 8) + } + + let url = info.url + return HTTPResponse( + status: .ok, + headers: headers, + body: .stream(contentLength: info.size) { writer in + let fh = try FileHandle(forReadingFrom: url) + defer { try? fh.close() } + let chunkSize = 64 * 1024 + while true { + let chunk = try fh.read(upToCount: chunkSize) ?? Data() + if chunk.isEmpty { break } + try writer.write(chunk) + } + } + ) + } + + // MARK: - Files: streaming receive (POST /api/v1/files/receive) + + /// Streams the request body straight to disk in 64 KiB chunks. No + /// in-memory buffering of the payload. Filename + permissions come from + /// X-Filename / X-Permissions headers. X-Batch-ID / X-Batch-Last drive + /// Finder reveal once the final file in a batch arrives. + private func handleFileReceive(request: HTTPRequest, body: BodyReader) -> HTTPResponse { + guard request.method == .POST else { + return HTTPResponse.error(.methodNotAllowed, message: "Method not allowed") } - // Get filename from header or generate one - let filename = request.header("X-Filename") ?? "received_file_\(Int(Date().timeIntervalSince1970))" + let rawFilename = request.header("X-Filename") ?? "received_file_\(Int(Date().timeIntervalSince1970))" + let filename = Router.sanitizeRelativePath(rawFilename) + // Content-Length: nil when the client streamed without advertising + // length (EOF-delimited). Either is supported below. + let expected = body.contentLength + + let baseURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Downloads") + .appendingPathComponent("GhostVM") + let destURL = baseURL.appendingPathComponent(filename) - log("[Router] Receiving file: \(filename) (\(body.count) bytes)") + log("[Router] POST /files/receive: \(filename) (\(expected.map(String.init) ?? "") bytes)") do { - let savedURL = try FileService.shared.receiveFile(data: body, filename: filename) + try FileManager.default.createDirectory( + at: destURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + } catch { + return HTTPResponse.error(.internalServerError, message: "Failed to create directory: \(error.localizedDescription)") + } - // Apply permissions if provided - if let permStr = request.header("X-Permissions"), - let mode = Int(permStr, radix: 8) { - try? FileManager.default.setAttributes([.posixPermissions: mode], ofItemAtPath: savedURL.path) - } + FileManager.default.createFile(atPath: destURL.path, contents: nil) + guard let fh = FileHandle(forWritingAtPath: destURL.path) else { + return HTTPResponse.error(.internalServerError, message: "Could not open destination for writing") + } - let response = FileReceiveResponse(path: savedURL.path) + do { + defer { try? fh.close() } + + var buf = [UInt8](repeating: 0, count: 64 * 1024) + var written = 0 + try buf.withUnsafeMutableBytes { (rawPtr: UnsafeMutableRawBufferPointer) in + while true { + if let expected, written >= expected { break } + let cap = expected.map { min(rawPtr.count, $0 - written) } ?? rawPtr.count + let slice = UnsafeMutableRawBufferPointer(rebasing: rawPtr[.. HTTPResponse { - guard request.method == .GET else { - return HTTPResponse.error(.methodNotAllowed, message: "Method not allowed") + // Apply permissions if provided. + if let permStr = request.header("X-Permissions"), + let mode = Int(permStr, radix: 8) { + let sanitizedMode = mode & 0o777 + try? FileManager.default.setAttributes([.posixPermissions: sanitizedMode], ofItemAtPath: destURL.path) + } + + // Reveal-in-Finder bookkeeping for batch transfers. + let batchID = request.header("X-Batch-ID") + let isLastInBatch = request.header("X-Batch-Last") == "true" + if let batchID { + RouterBatchTracker.shared.add(url: destURL, batchID: batchID) + if isLastInBatch { + let allFiles = RouterBatchTracker.shared.finish(batchID: batchID) + let topLevel = Router.computeTopLevelItems(allFiles, baseURL: baseURL) + DispatchQueue.main.async { + NSWorkspace.shared.activateFileViewerSelecting(topLevel) + } + } + } else { + DispatchQueue.main.async { + NSWorkspace.shared.activateFileViewerSelecting([destURL]) + } } - // Extract path after /api/v1/files/ - let prefix = "/api/v1/files/" - guard request.path.hasPrefix(prefix) else { - return HTTPResponse.error(.badRequest, message: "Invalid path") + let response = FileReceiveResponse(path: destURL.path) + guard let data = try? JSONEncoder().encode(response) else { + return HTTPResponse.error(.internalServerError, message: "Failed to encode response") } + log("[Router] File saved to: \(destURL.path)") + return HTTPResponse.json(data) + } - let filePath = String(request.path.dropFirst(prefix.count)) - guard !filePath.isEmpty else { - return HTTPResponse.error(.badRequest, message: "Path required") - } + // MARK: - Filename / batch helpers + + private static func sanitizeRelativePath(_ path: String) -> String { + let cleaned = path.replacingOccurrences(of: "\0", with: "_") + let components = cleaned.components(separatedBy: "/") + .filter { !$0.isEmpty && $0 != "." && $0 != ".." } + .map { + $0 + .replacingOccurrences(of: "..", with: "_") + .replacingOccurrences(of: "\\", with: "_") + .replacingOccurrences(of: "\"", with: "_") + } + return components.isEmpty ? "unnamed" : components.joined(separator: "/") + } - // URL decode the path - let decodedPath = filePath.removingPercentEncoding ?? filePath + private func escapeContentDispositionFilename(_ filename: String) -> String { + filename + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } - do { - let (data, filename, permissions) = try FileService.shared.readFile(at: decodedPath) - var headers: [String: String] = [ - "Content-Type": "application/octet-stream", - "Content-Disposition": "attachment; filename=\"\(filename)\"", - "Content-Length": "\(data.count)" - ] - if let permissions = permissions { - headers["X-Permissions"] = String(permissions, radix: 8) + private static func computeTopLevelItems(_ urls: [URL], baseURL: URL) -> [URL] { + let basePath = baseURL.path + var topLevelNames = Set() + for url in urls { + let relativePath = String(url.path.dropFirst(basePath.count)) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let firstComponent = relativePath.components(separatedBy: "/").first ?? relativePath + if !firstComponent.isEmpty { + topLevelNames.insert(firstComponent) } - return HTTPResponse(status: .ok, headers: headers, body: data) - } catch FileServiceError.accessDenied { - return HTTPResponse.error(.forbidden, message: "Access denied") - } catch { - return HTTPResponse.error(.notFound, message: "File not found") } + return topLevelNames.map { baseURL.appendingPathComponent($0) } } - // MARK: - Ports + // MARK: - Ports / Logs / URLs private func handlePorts(_ request: HTTPRequest) -> HTTPResponse { guard request.method == .GET else { return HTTPResponse.error(.methodNotAllowed, message: "Method not allowed") } - let ports = PortScanner.shared.getListeningPorts() - log("[Router] GET /ports - returning \(ports.count) listening port(s)") - let response = PortListResponse(ports: ports) guard let data = try? JSONEncoder().encode(response) else { return HTTPResponse.error(.internalServerError, message: "Failed to encode response") @@ -238,16 +405,11 @@ final class Router: @unchecked Sendable { return HTTPResponse.json(data) } - // MARK: - Logs - private func handleLogs(_ request: HTTPRequest) -> HTTPResponse { guard request.method == .GET else { return HTTPResponse.error(.methodNotAllowed, message: "Method not allowed") } - - // Pop and return buffered logs let logs = LogService.shared.popAll() - let response = LogListResponse(logs: logs) guard let data = try? JSONEncoder().encode(response) else { return HTTPResponse.error(.internalServerError, message: "Failed to encode response") @@ -255,27 +417,19 @@ final class Router: @unchecked Sendable { return HTTPResponse.json(data) } - // MARK: - URLs - private func handleURLs(_ request: HTTPRequest) -> HTTPResponse { switch request.method { case .GET: - // Get and clear pending URLs atomically let urls = URLService.shared.popAllURLs() - if !urls.isEmpty { - log("[Router] GET /urls - returning \(urls.count) URL(s)") - } + if !urls.isEmpty { log("[Router] GET /urls - \(urls.count) URL(s)") } let response = URLListResponse(urls: urls) - guard let data = try? JSONEncoder().encode(response) else { return HTTPResponse.error(.internalServerError, message: "Failed to encode response") } return HTTPResponse.json(data) case .DELETE: - // Clear the URL queue (without returning them) URLService.shared.clearPendingURLs() - log("[Router] DELETE /urls - queue cleared") return HTTPResponse(status: .ok) default: @@ -285,41 +439,33 @@ final class Router: @unchecked Sendable { // MARK: - Open - private func handleOpen(_ request: HTTPRequest) -> HTTPResponse { + private func handleOpen(request: HTTPRequest, body: BodyReader) -> HTTPResponse { guard request.method == .POST else { return HTTPResponse.error(.methodNotAllowed, message: "Method not allowed") } - - guard let body = request.body else { - return HTTPResponse.error(.badRequest, message: "Request body required") + let raw: Data + do { raw = try body.readAll() } catch { + return HTTPResponse.error(.badRequest, message: "Failed to read body: \(error)") } - - guard let openRequest = try? JSONDecoder().decode(OpenRequest.self, from: body) else { + guard let openRequest = try? JSONDecoder().decode(OpenRequest.self, from: raw) else { return HTTPResponse.error(.badRequest, message: "Invalid JSON") } - - log("[Router] POST /open: \(openRequest.path)") - let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/open") var args = [openRequest.path] - if let app = openRequest.app { - args = ["-b", app, openRequest.path] - } + if let app = openRequest.app { args = ["-b", app, openRequest.path] } process.arguments = args - do { try process.run() return HTTPResponse(status: .ok) } catch { - log("[Router] Failed to open: \(error)") return HTTPResponse.error(.internalServerError, message: "Failed to open: \(error.localizedDescription)") } } // MARK: - App Management - private func handleApps(_ request: HTTPRequest) -> HTTPResponse { + private func handleApps(request: HTTPRequest, body: BodyReader) -> HTTPResponse { let path = request.path.components(separatedBy: "?").first ?? request.path if path == "/api/v1/apps" && request.method == .GET { @@ -330,125 +476,125 @@ final class Router: @unchecked Sendable { } return HTTPResponse.json(data) } - guard request.method == .POST else { return HTTPResponse.error(.methodNotAllowed, message: "Method not allowed") } - guard let body = request.body, - let payload = try? JSONDecoder().decode(AppActionRequest.self, from: body) else { + let raw: Data + do { raw = try body.readAll() } catch { + return HTTPResponse.error(.badRequest, message: "Failed to read body: \(error)") + } + guard let payload = try? JSONDecoder().decode(AppActionRequest.self, from: raw) else { return HTTPResponse.error(.badRequest, message: "Invalid JSON - need bundleId") } - if path.hasPrefix("/api/v1/apps/launch") { - log("[Router] POST /apps/launch: \(payload.bundleId)") - guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: payload.bundleId) else { - return HTTPResponse.error(.notFound, message: "App not found or failed to launch") + if path == "/api/v1/apps/launch" { + guard let appURL = onMain({ NSWorkspace.shared.urlForApplication(withBundleIdentifier: payload.bundleId) }) else { + return HTTPResponse.error(.notFound, message: "App not found") } let config = NSWorkspace.OpenConfiguration() let semaphore = DispatchSemaphore(value: 0) - var success = false - NSWorkspace.shared.openApplication(at: appURL, configuration: config) { _, error in - success = error == nil - semaphore.signal() + let result = LaunchResult() + onMain { + NSWorkspace.shared.openApplication(at: appURL, configuration: config) { _, error in + result.success = error == nil + semaphore.signal() + } } semaphore.wait() - return success ? HTTPResponse(status: .ok) : HTTPResponse.error(.notFound, message: "App not found or failed to launch") + return result.success ? HTTPResponse(status: .ok) : HTTPResponse.error(.notFound, message: "Failed to launch") } - if path.hasPrefix("/api/v1/apps/activate") { - log("[Router] POST /apps/activate: \(payload.bundleId)") - guard let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == payload.bundleId }) else { - return HTTPResponse.error(.notFound, message: "App not found or not running") + if path == "/api/v1/apps/activate" { + let activated = onMain { + guard let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == payload.bundleId }) else { + return false + } + return app.activate() } - let ok = app.activate() - return ok ? HTTPResponse(status: .ok) : HTTPResponse.error(.notFound, message: "App not found or not running") + return activated ? HTTPResponse(status: .ok) : HTTPResponse.error(.notFound, message: "Failed to activate") } - if path.hasPrefix("/api/v1/apps/quit") { - log("[Router] POST /apps/quit: \(payload.bundleId)") - guard let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == payload.bundleId }) else { - return HTTPResponse.error(.notFound, message: "App not found or not running") + if path == "/api/v1/apps/quit" { + let terminated = onMain { + guard let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == payload.bundleId }) else { + return false + } + return app.terminate() } - let ok = app.terminate() - return ok ? HTTPResponse(status: .ok) : HTTPResponse.error(.notFound, message: "App not found or not running") + return terminated ? HTTPResponse(status: .ok) : HTTPResponse.error(.notFound, message: "Failed to quit") } return HTTPResponse.error(.notFound, message: "Not Found") } - /// List running GUI apps (those with a Dock icon) private func listApps() -> [AppInfo] { - let frontmost = NSWorkspace.shared.frontmostApplication?.processIdentifier - return NSWorkspace.shared.runningApplications - .filter { $0.activationPolicy == .regular } - .map { app in - AppInfo( - name: app.localizedName ?? app.bundleIdentifier ?? "Unknown", - bundleId: app.bundleIdentifier ?? "", - pid: app.processIdentifier, - isActive: app.processIdentifier == frontmost - ) - } + onMain { + let frontmost = NSWorkspace.shared.frontmostApplication?.processIdentifier + return NSWorkspace.shared.runningApplications + .filter { $0.activationPolicy == .regular } + .map { app in + AppInfo( + name: app.localizedName ?? app.bundleIdentifier ?? "Unknown", + bundleId: app.bundleIdentifier ?? "", + pid: app.processIdentifier, + isActive: app.processIdentifier == frontmost + ) + } + } } // MARK: - File System - private func handleFS(_ request: HTTPRequest) -> HTTPResponse { + private func handleFS(request: HTTPRequest, body: BodyReader) -> HTTPResponse { let path = request.path.components(separatedBy: "?").first ?? request.path if path == "/api/v1/fs" && request.method == .GET { - // List directory contents let queryPath = parseQuery(request.path, key: "path") ?? NSHomeDirectory() return listDirectory(at: queryPath) } - guard request.method == .POST else { return HTTPResponse.error(.methodNotAllowed, message: "Method not allowed") } + let raw: Data + do { raw = try body.readAll() } catch { + return HTTPResponse.error(.badRequest, message: "Failed to read body: \(error)") + } + if path == "/api/v1/fs/mkdir" { - guard let body = request.body, - let payload = try? JSONDecoder().decode(FSPathRequest.self, from: body) else { + guard let payload = try? JSONDecoder().decode(FSPathRequest.self, from: raw) else { return HTTPResponse.error(.badRequest, message: "Invalid JSON - need path") } - log("[Router] POST /fs/mkdir: \(payload.path)") do { try FileManager.default.createDirectory(atPath: payload.path, withIntermediateDirectories: true) return HTTPResponse(status: .ok) } catch { - return HTTPResponse.error(.internalServerError, message: "Failed to create directory: \(error.localizedDescription)") + return HTTPResponse.error(.internalServerError, message: "mkdir failed: \(error.localizedDescription)") } } - if path == "/api/v1/fs/delete" { - guard let body = request.body, - let payload = try? JSONDecoder().decode(FSPathRequest.self, from: body) else { + guard let payload = try? JSONDecoder().decode(FSPathRequest.self, from: raw) else { return HTTPResponse.error(.badRequest, message: "Invalid JSON - need path") } - log("[Router] POST /fs/delete: \(payload.path)") do { try FileManager.default.removeItem(atPath: payload.path) return HTTPResponse(status: .ok) } catch { - return HTTPResponse.error(.internalServerError, message: "Failed to delete: \(error.localizedDescription)") + return HTTPResponse.error(.internalServerError, message: "delete failed: \(error.localizedDescription)") } } - if path == "/api/v1/fs/move" { - guard let body = request.body, - let payload = try? JSONDecoder().decode(FSMoveRequest.self, from: body) else { + guard let payload = try? JSONDecoder().decode(FSMoveRequest.self, from: raw) else { return HTTPResponse.error(.badRequest, message: "Invalid JSON - need from and to") } - log("[Router] POST /fs/move: \(payload.from) -> \(payload.to)") do { try FileManager.default.moveItem(atPath: payload.from, toPath: payload.to) return HTTPResponse(status: .ok) } catch { - return HTTPResponse.error(.internalServerError, message: "Failed to move: \(error.localizedDescription)") + return HTTPResponse.error(.internalServerError, message: "move failed: \(error.localizedDescription)") } } - return HTTPResponse.error(.notFound, message: "Not Found") } @@ -457,7 +603,6 @@ final class Router: @unchecked Sendable { guard fm.fileExists(atPath: path) else { return HTTPResponse.error(.notFound, message: "Path not found") } - do { let contents = try fm.contentsOfDirectory(atPath: path) var entries: [FSEntry] = [] @@ -472,20 +617,20 @@ final class Router: @unchecked Sendable { } let response = FSListResponse(path: path, entries: entries) guard let data = try? JSONEncoder().encode(response) else { - return HTTPResponse.error(.internalServerError, message: "Failed to encode response") + return HTTPResponse.error(.internalServerError, message: "encode failed") } return HTTPResponse.json(data) } catch { - return HTTPResponse.error(.internalServerError, message: "Failed to list directory: \(error.localizedDescription)") + return HTTPResponse.error(.internalServerError, message: "list failed: \(error.localizedDescription)") } } - // MARK: - Shell Exec + // MARK: - Exec private struct ExecRequest: Codable { let command: String let args: [String]? - let timeout: Int? // seconds, default 30 + let timeout: Int? } private struct ExecResponse: Codable { @@ -494,22 +639,21 @@ final class Router: @unchecked Sendable { let stderr: String } - private func handleExec(_ request: HTTPRequest) async -> HTTPResponse { + private func handleExec(request: HTTPRequest, body: BodyReader) -> HTTPResponse { guard request.method == .POST else { return HTTPResponse.error(.methodNotAllowed, message: "Method not allowed") } - - guard let body = request.body, - let payload = try? JSONDecoder().decode(ExecRequest.self, from: body) else { - return HTTPResponse.error(.badRequest, message: "Invalid JSON — need {\"command\": \"...\", \"args\": [...]}") + let raw: Data + do { raw = try body.readAll() } catch { + return HTTPResponse.error(.badRequest, message: "Failed to read body: \(error)") + } + guard let payload = try? JSONDecoder().decode(ExecRequest.self, from: raw) else { + return HTTPResponse.error(.badRequest, message: "Invalid JSON") } - - log("[Router] POST /exec: \(payload.command)") let process = Process() process.executableURL = URL(fileURLWithPath: payload.command) process.arguments = payload.args ?? [] - let stdoutPipe = Pipe() let stderrPipe = Pipe() process.standardOutput = stdoutPipe @@ -518,35 +662,46 @@ final class Router: @unchecked Sendable { do { try process.run() } catch { - return HTTPResponse.error(.internalServerError, message: "Failed to launch: \(error.localizedDescription)") + return HTTPResponse.error(.internalServerError, message: "Launch failed: \(error.localizedDescription)") } + // Drain stdout/stderr concurrently before waiting. Otherwise a child + // that writes more than the pipe buffer can deadlock before exit. + let output = ExecOutputCapture() let timeout = payload.timeout ?? 30 let deadline = DispatchTime.now() + .seconds(timeout) let group = DispatchGroup() group.enter() - DispatchQueue.global().async { + execOutputQueue.async { process.waitUntilExit() group.leave() } - + group.enter() + execOutputQueue.async { + output.stdout = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + group.leave() + } + group.enter() + execOutputQueue.async { + output.stderr = stderrPipe.fileHandleForReading.readDataToEndOfFile() + group.leave() + } if group.wait(timeout: deadline) == .timedOut { process.terminate() + if group.wait(timeout: .now() + .seconds(2)) == .timedOut { + kill(process.processIdentifier, SIGKILL) + } return HTTPResponse.error(.requestTimeout, message: "Process timed out after \(timeout)s") } - - let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() let result = ExecResponse( exitCode: process.terminationStatus, - stdout: String(data: stdoutData, encoding: .utf8) ?? "", - stderr: String(data: stderrData, encoding: .utf8) ?? "" + stdout: String(data: output.stdout, encoding: .utf8) ?? "", + stderr: String(data: output.stderr, encoding: .utf8) ?? "" ) - - guard let responseData = try? JSONEncoder().encode(result) else { - return HTTPResponse.error(.internalServerError, message: "Failed to encode result") + guard let data = try? JSONEncoder().encode(result) else { + return HTTPResponse.error(.internalServerError, message: "encode failed") } - return HTTPResponse.json(responseData) + return HTTPResponse.json(data) } // MARK: - Frontmost App @@ -555,11 +710,10 @@ final class Router: @unchecked Sendable { guard request.method == .GET else { return HTTPResponse.error(.methodNotAllowed, message: "Method not allowed") } - - let bundleId = NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "" + let bundleId = onMain { NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "" } let response: [String: Any] = ["bundleId": bundleId] guard let data = try? JSONSerialization.data(withJSONObject: response) else { - return HTTPResponse.error(.internalServerError, message: "Failed to encode response") + return HTTPResponse.error(.internalServerError, message: "encode failed") } return HTTPResponse.json(data) } @@ -567,22 +721,7 @@ final class Router: @unchecked Sendable { // MARK: - Query Parsing private func parseQuery(_ path: String, key: String) -> String? { - guard let queryStart = path.firstIndex(of: "?") else { return nil } - let query = String(path[path.index(after: queryStart)...]) - for pair in query.components(separatedBy: "&") { - let parts = pair.components(separatedBy: "=") - if parts.count == 2 && parts[0] == key { - return parts[1].removingPercentEncoding ?? parts[1] - } - } - return nil - } - - private func parseBoolQuery(_ path: String, key: String) -> Bool? { - guard let value = parseQuery(path, key: key)?.lowercased() else { return nil } - if value == "1" || value == "true" || value == "yes" { return true } - if value == "0" || value == "false" || value == "no" { return false } - return nil + HTTPQueryParser.parseQuery(path, key: key) } } @@ -593,7 +732,6 @@ struct HealthResponse: Codable { let version: String } - struct FileReceiveResponse: Codable { let path: String } @@ -621,8 +759,6 @@ struct OpenRequest: Codable { } } -// MARK: - App Management Types - struct AppInfo: Codable { let name: String let bundleId: String @@ -638,8 +774,6 @@ struct AppActionRequest: Codable { let bundleId: String } -// MARK: - File System Types - struct FSEntry: Codable { let name: String let isDir: Bool @@ -660,3 +794,34 @@ struct FSMoveRequest: Codable { let from: String let to: String } + +// MARK: - Internal helpers + +/// Tracks paths per batch ID so Finder reveal happens once when the last +/// file in a batch arrives. Multi-threaded since each connection lives on +/// its own worker thread. +final class RouterBatchTracker: @unchecked Sendable { + static let shared = RouterBatchTracker() + private var batchFiles: [String: [URL]] = [:] + private let lock = NSLock() + + func add(url: URL, batchID: String) { + lock.lock() + batchFiles[batchID, default: []].append(url) + lock.unlock() + } + + func finish(batchID: String) -> [URL] { + lock.lock() + let files = batchFiles.removeValue(forKey: batchID) ?? [] + lock.unlock() + return files + } +} + +/// Tiny mutable box used to capture an out-param across a Sendable callback +/// (`NSWorkspace.openApplication`'s completion handler crosses concurrency +/// domains, so a captured `var` would warn). +final class LaunchResult: @unchecked Sendable { + var success: Bool = false +} diff --git a/macOS/GhostTools/Sources/GhostTools/Server/TunnelServer.swift b/macOS/GhostTools/Sources/GhostTools/Server/TunnelServer.swift deleted file mode 100644 index f80f98e..0000000 --- a/macOS/GhostTools/Sources/GhostTools/Server/TunnelServer.swift +++ /dev/null @@ -1,474 +0,0 @@ -import Foundation -import os - -struct TunnelRuntimeError: Sendable { - enum Phase: String, Sendable { - case handshakeRead - case handshakeProtocol - case connectLocal - case bridge - } - - let phase: Phase - let message: String - let targetPort: UInt16? - let timestamp: Date - - init(phase: Phase, message: String, targetPort: UInt16? = nil, timestamp: Date = Date()) { - self.phase = phase - self.message = message - self.targetPort = targetPort - self.timestamp = timestamp - } -} - -func tunnelIsDisconnectErrno(_ err: Int32) -> Bool { - switch err { - case ECONNRESET, EPIPE, ENOTCONN, ESHUTDOWN, ECONNABORTED, ETIMEDOUT: - return true - default: - return false - } -} - -func tunnelIsOperationalBridgeError(_ error: Error) -> Bool { - guard let ioError = error as? AsyncVSockIOError else { - return false - } - switch ioError { - case .closed, .cancelled: - return true - case .syscall(_, let err): - return tunnelIsDisconnectErrno(err) - default: - return false - } -} - -/// TunnelServer listens on vsock port 5001 and handles CONNECT requests -/// from the host to bridge TCP connections to localhost services in the guest. -/// -/// Protocol: -/// 1. Host sends: "CONNECT \r\n" -/// 2. Server connects to localhost: -/// 3. Server responds: "OK\r\n" or "ERROR \r\n" -/// 4. Bidirectional bridging via async nonblocking I/O -final class TunnelServer: @unchecked Sendable { - private static let logger = Logger(subsystem: "org.ghostvm.ghosttools", category: "TunnelServer") - private let port: UInt32 = 5001 - private var serverSocket: Int32 = -1 - private var isRunning = false - - /// Status callback for connection state changes - var onStatusChange: ((Bool) -> Void)? - var onOperationalError: ((TunnelRuntimeError) -> Void)? - var onConnectionSuccess: (() -> Void)? - - init() {} - - deinit { - stop() - } - - /// Starts the tunnel server - func start() async throws { - Self.logger.info("Creating tunnel socket on port \(self.port)") - - // Create vsock socket - serverSocket = socket(AF_VSOCK, SOCK_STREAM, 0) - guard serverSocket >= 0 else { - Self.logger.fault("Socket creation failed errno=\(errno)") - throw VsockServerError.socketCreationFailed(errno) - } - - // Set socket options for reuse - var optval: Int32 = 1 - setsockopt(serverSocket, SOL_SOCKET, SO_REUSEADDR, &optval, socklen_t(MemoryLayout.size)) - - // Bind to vsock address - var addr = sockaddr_vm(port: port) - let bindResult = withUnsafePointer(to: &addr) { addrPtr in - addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.bind(serverSocket, sockaddrPtr, socklen_t(MemoryLayout.size)) - } - } - - guard bindResult == 0 else { - close(serverSocket) - throw VsockServerError.bindFailed(errno) - } - - // Listen for connections - guard listen(serverSocket, 128) == 0 else { - close(serverSocket) - throw VsockServerError.listenFailed(errno) - } - - // Keep socket BLOCKING — kqueue/poll don't fire for AF_VSOCK on macOS guests - isRunning = true - onStatusChange?(true) - Self.logger.info("Listening on vsock port \(self.port)") - - // Blocking accept loop on dedicated thread - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - while self?.isRunning == true { - var clientAddr = sockaddr_vm(port: 0) - var addrLen = socklen_t(MemoryLayout.size) - - let clientSocket = withUnsafeMutablePointer(to: &clientAddr) { addrPtr in - addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.accept(self?.serverSocket ?? -1, sockaddrPtr, &addrLen) - } - } - - if clientSocket < 0 { - let err = errno - if err == EINTR { continue } - if self?.isRunning != true || err == EBADF || err == EINVAL { - Self.logger.info("Accept loop exiting serverSocket=\(self?.serverSocket ?? -1) errno=\(err)") - break - } - self?.reportOperationalError( - TunnelRuntimeError( - phase: .bridge, - message: "accept() failed: errno=\(err) \(String(cString: strerror(err)))" - ), - connectionID: "accept-loop", - error: AsyncVSockIOError.syscall(op: "accept", errno: err) - ) - usleep(100_000) - continue - } - - Task { [weak self] in - await self?.handleConnection(clientSocket) - } - } - } - } - - /// Handles a single tunnel connection - private func handleConnection(_ vsockFd: Int32) async { - let connectionID = UUID().uuidString - Self.logger.debug("New incoming host connection id=\(connectionID, privacy: .public) fd=\(vsockFd)") - - let vsockIO = BlockingVSockChannel(fd: vsockFd, ownsFD: true) - defer { - vsockIO.close() - Self.logger.debug("Connection closed id=\(connectionID, privacy: .public) fd=\(vsockFd)") - } - - let commandData: Data - do { - commandData = try await readHandshakeCommand(vsockIO) - } catch let error as HandshakeReadError { - switch error { - case .timeout: - reportOperationalError( - TunnelRuntimeError( - phase: .handshakeRead, - message: "Timeout waiting for CONNECT command from host" - ), - connectionID: connectionID, - error: error - ) - return - case .eof: - reportOperationalError( - TunnelRuntimeError( - phase: .handshakeRead, - message: "Failed to read CONNECT command: EOF" - ), - connectionID: connectionID, - error: error - ) - return - case .transport(let ioError): - reportOperationalError( - TunnelRuntimeError( - phase: .handshakeRead, - message: describe(error: ioError) - ), - connectionID: connectionID, - error: ioError - ) - return - } - } catch { - Self.logger.fault("Unexpected handshake read error id=\(connectionID, privacy: .public): \(String(describing: error), privacy: .public)") - fatalError("[TunnelServer] Failed to read CONNECT command: unexpected error \(error)") - } - - // Parse "CONNECT \r\n" - guard let command = String(data: commandData, encoding: .utf8) else { - reportOperationalError( - TunnelRuntimeError( - phase: .handshakeProtocol, - message: "Invalid command encoding" - ), - connectionID: connectionID - ) - await sendError(vsockIO, message: "Invalid command encoding") - return - } - - let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) - Self.logger.debug("Received command id=\(connectionID, privacy: .public): \(trimmed, privacy: .public)") - - guard trimmed.hasPrefix("CONNECT ") else { - reportOperationalError( - TunnelRuntimeError( - phase: .handshakeProtocol, - message: "Expected CONNECT command, got '\(trimmed)'" - ), - connectionID: connectionID - ) - await sendError(vsockIO, message: "Expected CONNECT command") - return - } - - let portString = String(trimmed.dropFirst("CONNECT ".count)) - guard let targetPort = UInt16(portString) else { - reportOperationalError( - TunnelRuntimeError( - phase: .handshakeProtocol, - message: "Invalid port number '\(portString)'" - ), - connectionID: connectionID - ) - await sendError(vsockIO, message: "Invalid port number") - return - } - - Self.logger.debug("Connecting to localhost id=\(connectionID, privacy: .public) targetPort=\(targetPort)") - - // Connect to localhost on the target port - guard let tcpFd = connectToLocalhost(port: targetPort) else { - reportOperationalError( - TunnelRuntimeError( - phase: .connectLocal, - message: "Failed to connect to localhost:\(targetPort)", - targetPort: targetPort - ), - connectionID: connectionID - ) - await sendError(vsockIO, message: "Connection refused to port \(targetPort)") - return - } - let tcpIO = AsyncVSockIO(fd: tcpFd, ownsFD: true) - - Self.logger.info("Connected to localhost id=\(connectionID, privacy: .public) targetPort=\(targetPort)") - - // Send OK response - do { - try await vsockIO.writeAll(Data("OK\r\n".utf8)) - } catch { - reportOperationalError( - TunnelRuntimeError( - phase: .handshakeProtocol, - message: "Failed to write OK response: \(describe(error: error))", - targetPort: targetPort - ), - connectionID: connectionID, - error: error - ) - return - } - - onConnectionSuccess?() - Self.logger.info("Starting bridge id=\(connectionID, privacy: .public) targetPort=\(targetPort)") - - do { - try await pipeBidirectional(vsockIO, tcpIO) - } catch { - if tunnelIsOperationalBridgeError(error) { - reportOperationalError( - TunnelRuntimeError( - phase: .bridge, - message: describe(error: error), - targetPort: targetPort - ), - connectionID: connectionID, - error: error - ) - return - } - let described = describe(error: error) - Self.logger.fault("Unexpected bridge failure id=\(connectionID, privacy: .public) targetPort=\(targetPort): \(described, privacy: .public)") - fatalError("[TunnelServer] Bridge failed unexpectedly: \(described)") - } - } - - /// Connect to localhost on the specified port - private func connectToLocalhost(port: UInt16) -> Int32? { - // Try IPv4 first - if let fd = connectIPv4(port: port) { - return fd - } - - // Fall back to IPv6 - return connectIPv6(port: port) - } - - private func connectIPv4(port: UInt16) -> Int32? { - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { return nil } - - // Set TCP_NODELAY to disable Nagle's algorithm - var optval: Int32 = 1 - setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &optval, socklen_t(MemoryLayout.size)) - - var addr = sockaddr_in() - addr.sin_len = UInt8(MemoryLayout.size) - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = port.bigEndian - addr.sin_addr.s_addr = inet_addr("127.0.0.1") - - let result = withUnsafePointer(to: &addr) { addrPtr in - addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.connect(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) - } - } - - if result == 0 { - return fd - } - - close(fd) - return nil - } - - private func connectIPv6(port: UInt16) -> Int32? { - let fd = socket(AF_INET6, SOCK_STREAM, 0) - guard fd >= 0 else { return nil } - - // Set TCP_NODELAY to disable Nagle's algorithm - var optval: Int32 = 1 - setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &optval, socklen_t(MemoryLayout.size)) - - var addr = sockaddr_in6() - addr.sin6_len = UInt8(MemoryLayout.size) - addr.sin6_family = sa_family_t(AF_INET6) - addr.sin6_port = port.bigEndian - addr.sin6_addr = in6addr_loopback - - let result = withUnsafePointer(to: &addr) { addrPtr in - addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.connect(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) - } - } - - if result == 0 { - return fd - } - - close(fd) - return nil - } - - /// Send an error response - private func sendError(_ io: some SocketChannel, message: String) async { - let response = "ERROR \(message)\r\n" - do { - try await io.writeAll(Data(response.utf8)) - } catch { - let described = describe(error: error) - Self.logger.warning("Failed to send error response to host: \(described, privacy: .public)") - } - } - - private enum HandshakeReadError: Error { - case timeout - case eof - case transport(AsyncVSockIOError) - } - - private func readHandshakeCommand(_ io: some SocketChannel) async throws -> Data { - try await withThrowingTaskGroup(of: Data.self) { group in - group.addTask { - do { - guard let data = try await io.read(maxBytes: 255), !data.isEmpty else { - throw HandshakeReadError.eof - } - return data - } catch let error as AsyncVSockIOError { - throw HandshakeReadError.transport(error) - } - } - group.addTask { - try await Task.sleep(nanoseconds: 5_000_000_000) - throw HandshakeReadError.timeout - } - - do { - let first = try await group.next()! - group.cancelAll() - return first - } catch { - group.cancelAll() - throw error - } - } - } - - private func describe(error: Error) -> String { - if let ioError = error as? AsyncVSockIOError { - switch ioError { - case .closed: - return "closed" - case .eofBeforeExpected(let expected, let received): - return "eofBeforeExpected expected=\(expected) received=\(received)" - case .interrupted: - return "interrupted" - case .wouldBlock: - return "wouldBlock" - case .syscall(let op, let err): - return "syscall \(op) failed: errno=\(err) \(String(cString: strerror(err)))" - case .cancelled: - return "cancelled" - } - } - return String(describing: error) - } - - private func reportOperationalError( - _ runtimeError: TunnelRuntimeError, - connectionID: String, - error: Error? = nil - ) { - let errnoValue: Int32? - if let ioError = error as? AsyncVSockIOError, case .syscall(_, let err) = ioError { - errnoValue = err - } else { - errnoValue = nil - } - - let targetPortText = runtimeError.targetPort.map(String.init) ?? "none" - let useWarningLevel = runtimeError.phase == .bridge && (error.map(tunnelIsOperationalBridgeError) ?? false) - - if let err = errnoValue { - if useWarningLevel { - Self.logger.warning("Operational tunnel error id=\(connectionID, privacy: .public) phase=\(runtimeError.phase.rawValue, privacy: .public) targetPort=\(targetPortText, privacy: .public) errno=\(err): \(runtimeError.message, privacy: .public)") - } else { - Self.logger.error("Operational tunnel error id=\(connectionID, privacy: .public) phase=\(runtimeError.phase.rawValue, privacy: .public) targetPort=\(targetPortText, privacy: .public) errno=\(err): \(runtimeError.message, privacy: .public)") - } - } else { - if useWarningLevel { - Self.logger.warning("Operational tunnel error id=\(connectionID, privacy: .public) phase=\(runtimeError.phase.rawValue, privacy: .public) targetPort=\(targetPortText, privacy: .public): \(runtimeError.message, privacy: .public)") - } else { - Self.logger.error("Operational tunnel error id=\(connectionID, privacy: .public) phase=\(runtimeError.phase.rawValue, privacy: .public) targetPort=\(targetPortText, privacy: .public): \(runtimeError.message, privacy: .public)") - } - } - onOperationalError?(runtimeError) - } - - /// Stops the server - func stop() { - isRunning = false - if serverSocket >= 0 { - close(serverSocket) - serverSocket = -1 - } - onStatusChange?(false) - } -} diff --git a/macOS/GhostTools/Sources/GhostTools/Server/VsockServer.swift b/macOS/GhostTools/Sources/GhostTools/Server/VsockServer.swift deleted file mode 100644 index d0d2406..0000000 --- a/macOS/GhostTools/Sources/GhostTools/Server/VsockServer.swift +++ /dev/null @@ -1,416 +0,0 @@ -import Foundation -import AppKit - -/// AF_VSOCK socket family constant (40 on macOS) -private let AF_VSOCK: Int32 = 40 - -/// VMADDR_CID_ANY - accept connections from any CID -private let VMADDR_CID_ANY: UInt32 = 0xFFFFFFFF - -/// sockaddr_vm structure for vsock addressing -/// Must match the kernel's sockaddr_vm layout -struct sockaddr_vm { - var svm_len: UInt8 - var svm_family: UInt8 - var svm_reserved1: UInt16 - var svm_port: UInt32 - var svm_cid: UInt32 - var svm_zero: (UInt8, UInt8, UInt8, UInt8) = (0, 0, 0, 0) - - init(port: UInt32, cid: UInt32 = VMADDR_CID_ANY) { - self.svm_len = UInt8(MemoryLayout.size) - self.svm_family = UInt8(AF_VSOCK) - self.svm_reserved1 = 0 - self.svm_port = port - self.svm_cid = cid - } -} - -/// Errors that can occur in the VsockServer -enum VsockServerError: Error, LocalizedError { - case socketCreationFailed(Int32) - case bindFailed(Int32) - case listenFailed(Int32) - case acceptFailed(Int32) - case readFailed(Int32) - case writeFailed(Int32) - - var errorDescription: String? { - switch self { - case .socketCreationFailed(let errno): - return "Failed to create socket: errno \(errno)" - case .bindFailed(let errno): - return "Failed to bind socket: errno \(errno)" - case .listenFailed(let errno): - return "Failed to listen: errno \(errno)" - case .acceptFailed(let errno): - return "Failed to accept connection: errno \(errno)" - case .readFailed(let errno): - return "Failed to read from socket: errno \(errno)" - case .writeFailed(let errno): - return "Failed to write to socket: errno \(errno)" - } - } -} - -/// A simple vsock server that listens for connections from the host -final class VsockServer: @unchecked Sendable { - private let port: UInt32 - private var serverSocket: Int32 = -1 - private var isRunning = false - private let router: Router - - /// Tracks file paths per batch ID for batched Finder reveal - private var batchFiles: [String: [URL]] = [:] - private let batchLock = NSLock() - - /// Status callback for connection state changes - var onStatusChange: ((Bool) -> Void)? - - init(port: UInt32 = 80, router: Router) { - self.port = port - self.router = router - } - - deinit { - stop() - } - - /// Starts the vsock server - func start() async throws { - print("[VsockServer] Creating socket with AF_VSOCK=\(AF_VSOCK), SOCK_STREAM=\(SOCK_STREAM)") - // Create vsock socket - serverSocket = socket(AF_VSOCK, SOCK_STREAM, 0) - print("[VsockServer] socket() returned: \(serverSocket), errno: \(errno)") - guard serverSocket >= 0 else { - print("[VsockServer] Socket creation failed! errno=\(errno)") - throw VsockServerError.socketCreationFailed(errno) - } - - // Set socket options for reuse - var optval: Int32 = 1 - setsockopt(serverSocket, SOL_SOCKET, SO_REUSEADDR, &optval, socklen_t(MemoryLayout.size)) - - // Bind to vsock address - var addr = sockaddr_vm(port: port) - let bindResult = withUnsafePointer(to: &addr) { addrPtr in - addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.bind(serverSocket, sockaddrPtr, socklen_t(MemoryLayout.size)) - } - } - - guard bindResult == 0 else { - close(serverSocket) - throw VsockServerError.bindFailed(errno) - } - - // Listen for connections - guard listen(serverSocket, 10) == 0 else { - close(serverSocket) - throw VsockServerError.listenFailed(errno) - } - - // Keep socket BLOCKING — kqueue/poll don't fire for AF_VSOCK on macOS guests - isRunning = true - onStatusChange?(true) - print("VsockServer listening on port \(port)") - - // Blocking accept loop on dedicated GCD thread (not async Task — would block cooperative pool) - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - while self?.isRunning == true { - var clientAddr = sockaddr_vm(port: 0) - var addrLen = socklen_t(MemoryLayout.size) - - let clientSocket = withUnsafeMutablePointer(to: &clientAddr) { addrPtr in - addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.accept(self?.serverSocket ?? -1, sockaddrPtr, &addrLen) - } - } - - if clientSocket < 0 { - if errno == EINTR { continue } - break // socket closed by stop() - } - - // Handle connection in a task - Task { - await self?.handleConnection(clientSocket) - } - } - } - } - - /// Handles a single client connection - private func handleConnection(_ socket: Int32) async { - defer { - close(socket) - } - - // Read headers first - guard let (headers, initialBody) = readHTTPHeaders(from: socket) else { - return - } - - // Parse the request line and headers - guard let request = HTTPParser.parseRequest(headers) else { - let response = HTTPResponse(status: .badRequest, body: Data(#"{"error":"Invalid HTTP request"}"#.utf8)) - writeResponse(response, to: socket) - return - } - - // Check if this is a streaming file upload - if request.path == "/api/v1/files/receive" && request.method == .POST { - let response = await handleStreamingFileReceive( - request: request, - socket: socket, - initialBody: initialBody - ) - writeResponse(response, to: socket) - return - } - - // For other requests, read the full body if needed - let contentLength = Int(request.header("Content-Length") ?? "0") ?? 0 - var fullBody = initialBody - - if contentLength > initialBody.count { - let remaining = contentLength - initialBody.count - if let moreData = readExactBytes(from: socket, count: remaining) { - fullBody.append(moreData) - } - } - - // Create request with full body - let fullRequest = HTTPRequest( - method: request.method, - path: request.path, - headers: request.headers, - body: fullBody.isEmpty ? nil : fullBody - ) - - // Route the request and get response - let response = await router.handle(fullRequest) - - // Write response - writeResponse(response, to: socket) - } - - /// Handle streaming file upload - writes directly to disk - private func handleStreamingFileReceive( - request: HTTPRequest, - socket: Int32, - initialBody: Data - ) async -> HTTPResponse { - let rawFilename = request.header("X-Filename") ?? "received_file_\(Int(Date().timeIntervalSince1970))" - let contentLength = Int(request.header("Content-Length") ?? "0") ?? 0 - - // Sanitize the path to prevent traversal while preserving folder structure - let filename = sanitizeRelativePath(rawFilename) - - print("[VsockServer] Streaming file receive: \(filename) (\(contentLength) bytes)") - - // Base directory for received files - let baseURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Downloads") - .appendingPathComponent("GhostVM") - - let destURL = baseURL.appendingPathComponent(filename) - - // Create ALL intermediate directories (including subfolders in the relative path) - let parentDir = destURL.deletingLastPathComponent() - do { - try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) - } catch { - print("[VsockServer] Failed to create directory: \(parentDir.path) - \(error)") - return HTTPResponse.error(.internalServerError, message: "Failed to create directory: \(error.localizedDescription)") - } - - FileManager.default.createFile(atPath: destURL.path, contents: nil) - - guard let fileHandle = FileHandle(forWritingAtPath: destURL.path) else { - return HTTPResponse.error(.internalServerError, message: "Failed to create file") - } - - defer { - try? fileHandle.close() - } - - // Write initial body data - var bytesWritten = 0 - if !initialBody.isEmpty { - do { - try fileHandle.write(contentsOf: initialBody) - bytesWritten += initialBody.count - } catch { - return HTTPResponse.error(.internalServerError, message: "Failed to write file") - } - } - - // Stream remaining data directly to file - var buffer = [UInt8](repeating: 0, count: 65536) - while bytesWritten < contentLength { - let toRead = min(buffer.count, contentLength - bytesWritten) - let bytesRead = read(socket, &buffer, toRead) - - if bytesRead <= 0 { - print("[VsockServer] Read error or EOF at \(bytesWritten)/\(contentLength)") - break - } - - do { - try fileHandle.write(contentsOf: buffer[0.. (headers: Data, initialBody: Data)? { - var headerData = Data() - var buffer = [UInt8](repeating: 0, count: 4096) - let headerEnd = Data("\r\n\r\n".utf8) - - while true { - let bytesRead = read(socket, &buffer, buffer.count) - if bytesRead <= 0 { - return headerData.isEmpty ? nil : (headerData, Data()) - } - - headerData.append(contentsOf: buffer[0.. 65536 { - return nil - } - } - } - - /// Reads exactly `count` bytes from socket - private func readExactBytes(from socket: Int32, count: Int) -> Data? { - var data = Data() - var buffer = [UInt8](repeating: 0, count: min(65536, count)) - - while data.count < count { - let toRead = min(buffer.count, count - data.count) - let bytesRead = read(socket, &buffer, toRead) - if bytesRead <= 0 { - break - } - data.append(contentsOf: buffer[0..= 0 { - close(serverSocket) - serverSocket = -1 - } - onStatusChange?(false) - } - - /// Given a list of file URLs under baseURL, returns the unique top-level items (files or folders) - /// For example, if files are baseURL/MyApp.app/Contents/MacOS/binary and baseURL/MyApp.app/Contents/Info.plist, - /// this returns [baseURL/MyApp.app] - private func computeTopLevelItems(_ urls: [URL], baseURL: URL) -> [URL] { - let basePath = baseURL.path - var topLevelNames = Set() - for url in urls { - let relativePath = String(url.path.dropFirst(basePath.count)) - .trimmingCharacters(in: CharacterSet(charactersIn: "/")) - let firstComponent = relativePath.components(separatedBy: "/").first ?? relativePath - if !firstComponent.isEmpty { - topLevelNames.insert(firstComponent) - } - } - return topLevelNames.map { baseURL.appendingPathComponent($0) } - } - - /// Sanitize a relative path, preserving folder structure but preventing traversal attacks - private func sanitizeRelativePath(_ path: String) -> String { - // Split into components and filter out dangerous ones - let components = path.components(separatedBy: "/") - .filter { !$0.isEmpty && $0 != "." && $0 != ".." } - .map { $0.replacingOccurrences(of: "..", with: "_").replacingOccurrences(of: "\\", with: "_") } - - // Ensure we have at least a filename - if components.isEmpty { - return "unnamed" - } - - return components.joined(separator: "/") - } -} diff --git a/macOS/GhostTools/Sources/GhostTools/Server/VsockTypes.swift b/macOS/GhostTools/Sources/GhostTools/Server/VsockTypes.swift new file mode 100644 index 0000000..40e44f2 --- /dev/null +++ b/macOS/GhostTools/Sources/GhostTools/Server/VsockTypes.swift @@ -0,0 +1,53 @@ +import Foundation + +/// AF_VSOCK socket family constant (40 on macOS) +let AF_VSOCK: Int32 = 40 + +/// VMADDR_CID_ANY - accept connections from any CID +let VMADDR_CID_ANY: UInt32 = 0xFFFFFFFF + +/// sockaddr_vm structure for vsock addressing +/// Must match the kernel's sockaddr_vm layout +struct sockaddr_vm { + var svm_len: UInt8 + var svm_family: UInt8 + var svm_reserved1: UInt16 + var svm_port: UInt32 + var svm_cid: UInt32 + var svm_zero: (UInt8, UInt8, UInt8, UInt8) = (0, 0, 0, 0) + + init(port: UInt32, cid: UInt32 = VMADDR_CID_ANY) { + self.svm_len = UInt8(MemoryLayout.size) + self.svm_family = UInt8(AF_VSOCK) + self.svm_reserved1 = 0 + self.svm_port = port + self.svm_cid = cid + } +} + +/// Errors that can occur in vsock servers +enum VsockServerError: Error, LocalizedError { + case socketCreationFailed(Int32) + case bindFailed(Int32) + case listenFailed(Int32) + case acceptFailed(Int32) + case readFailed(Int32) + case writeFailed(Int32) + + var errorDescription: String? { + switch self { + case .socketCreationFailed(let errno): + return "Failed to create socket: errno \(errno)" + case .bindFailed(let errno): + return "Failed to bind socket: errno \(errno)" + case .listenFailed(let errno): + return "Failed to listen: errno \(errno)" + case .acceptFailed(let errno): + return "Failed to accept connection: errno \(errno)" + case .readFailed(let errno): + return "Failed to read from socket: errno \(errno)" + case .writeFailed(let errno): + return "Failed to write to socket: errno \(errno)" + } + } +} diff --git a/macOS/GhostTools/Sources/GhostTools/Services/ClipboardService.swift b/macOS/GhostTools/Sources/GhostTools/Services/ClipboardService.swift index 846614a..5d75c9f 100644 --- a/macOS/GhostTools/Sources/GhostTools/Services/ClipboardService.swift +++ b/macOS/GhostTools/Sources/GhostTools/Services/ClipboardService.swift @@ -3,6 +3,7 @@ import Foundation /// Service for managing clipboard operations between host and guest /// The guest always allows clipboard operations - the host controls sync policy +@MainActor final class ClipboardService { static let shared = ClipboardService() @@ -12,8 +13,8 @@ final class ClipboardService { /// Pasteboard types to check, in priority order (richest first) private static let typePriority: [NSPasteboard.PasteboardType] = [ .png, - .tiff, .string, + .tiff, ] private init() { @@ -42,7 +43,10 @@ final class ClipboardService { /// Gets the best available clipboard data and its UTI type /// - Returns: Tuple of (data, uti) or nil if clipboard is empty func getClipboardData() -> (data: Data, type: String)? { - for pbType in Self.typePriority { + var seenTypes = Set(Self.typePriority.map(\.rawValue)) + let candidateTypes = Self.typePriority + (pasteboard.types ?? []).filter { seenTypes.insert($0.rawValue).inserted } + + for pbType in candidateTypes { if pbType == .string { if let text = pasteboard.string(forType: .string) { return (Data(text.utf8), utiString(for: pbType)) @@ -66,7 +70,10 @@ final class ClipboardService { let pbType = pasteboardType(for: type) pasteboard.clearContents() let success: Bool - if pbType == .string, let text = String(data: data, encoding: .utf8) { + if pbType == .string { + guard let text = String(data: data, encoding: .utf8) else { + return false + } success = pasteboard.setString(text, forType: .string) } else { success = pasteboard.setData(data, forType: pbType) @@ -102,6 +109,8 @@ final class ClipboardService { switch uti { case "public.png": return .png case "public.tiff": return .tiff + case "public.jpeg": return NSPasteboard.PasteboardType("public.jpeg") + case "public.rtf": return .rtf case "public.utf8-plain-text": return .string default: return NSPasteboard.PasteboardType(uti) } diff --git a/macOS/GhostTools/Sources/GhostTools/Services/FileService.swift b/macOS/GhostTools/Sources/GhostTools/Services/FileService.swift index 09b6aa5..3ff44c3 100644 --- a/macOS/GhostTools/Sources/GhostTools/Services/FileService.swift +++ b/macOS/GhostTools/Sources/GhostTools/Services/FileService.swift @@ -2,7 +2,7 @@ import Foundation /// Service for managing file transfer operations between host and guest final class FileService { - static let shared = FileService() + nonisolated(unsafe) static let shared = FileService() /// Base directory for received files private let receiveDirectory: URL @@ -51,25 +51,25 @@ final class FileService { /// - Parameter path: The file path to read (relative paths are resolved from home directory) /// - Returns: The file data and filename func readFile(at path: String) throws -> (data: Data, filename: String, permissions: Int?) { + let info = try statFile(at: path) + let data = try Data(contentsOf: info.url) + return (data, info.filename, info.permissions) + } + + /// Returns file metadata without reading contents. + /// Validates path security (outgoing queue or allowed path). + func statFile(at path: String) throws -> (url: URL, size: Int, filename: String, permissions: Int?) { let url = resolveFilePath(path) - // Security check: allow files the user explicitly queued for sending, - // otherwise block sensitive system directories guard isOutgoingFile(url.path) || isPathAllowed(url) else { throw FileServiceError.accessDenied } - let data = try Data(contentsOf: url) - let filename = url.lastPathComponent - - // Read POSIX permissions - var permissions: Int? = nil - if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), - let posix = attrs[.posixPermissions] as? Int { - permissions = posix - } + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + let size = (attrs[.size] as? Int) ?? 0 + let permissions = attrs[.posixPermissions] as? Int - return (data, filename, permissions) + return (url, size, url.lastPathComponent, permissions) } /// Lists files in the receive directory @@ -98,7 +98,7 @@ final class FileService { } outgoingLock.unlock() if added { - EventPushServer.shared.pushEvent(.files(listOutgoingFiles())) + EventPushService.shared.pushEvent(.files(listOutgoingFiles())) } } @@ -114,7 +114,7 @@ final class FileService { } outgoingLock.unlock() if changed { - EventPushServer.shared.pushEvent(.files(listOutgoingFiles())) + EventPushService.shared.pushEvent(.files(listOutgoingFiles())) } } @@ -130,7 +130,7 @@ final class FileService { outgoingLock.lock() outgoingFiles.removeAll() outgoingLock.unlock() - EventPushServer.shared.pushEvent(.files([])) + EventPushService.shared.pushEvent(.files([])) NotificationCenter.default.post(name: .outgoingFilesChanged, object: nil) } diff --git a/macOS/GhostTools/Sources/GhostTools/Services/ForegroundAppService.swift b/macOS/GhostTools/Sources/GhostTools/Services/ForegroundAppService.swift index 275c313..b6ce621 100644 --- a/macOS/GhostTools/Sources/GhostTools/Services/ForegroundAppService.swift +++ b/macOS/GhostTools/Sources/GhostTools/Services/ForegroundAppService.swift @@ -3,7 +3,7 @@ import AppKit /// Observes the frontmost application and pushes foreground-app events to the host. /// Events include the app name, bundle ID, and a 128x128 PNG icon (base64-encoded). /// A 500ms debounce prevents rapid Cmd+Tab from flooding the channel. -final class ForegroundAppService { +final class ForegroundAppService: @unchecked Sendable { static let shared = ForegroundAppService() private var observer: NSObjectProtocol? @@ -25,12 +25,6 @@ final class ForegroundAppService { self?.pushCurrentApp() } - // Re-push when a new host client connects (they missed the initial push) - EventPushServer.shared.onClientConnected = { [weak self] in - print("[ForegroundAppService] Client connected, pushing current app") - self?.pushCurrentApp() - } - observer = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didActivateApplicationNotification, object: nil, @@ -42,7 +36,6 @@ final class ForegroundAppService { } func stop() { - EventPushServer.shared.onClientConnected = nil if let obs = observer { NSWorkspace.shared.notificationCenter.removeObserver(obs) observer = nil @@ -52,6 +45,10 @@ final class ForegroundAppService { previousBundleId = nil } + func pushCurrentAppToConnectedClient() { + pushCurrentApp() + } + private func pushCurrentApp() { previousBundleId = nil // Force re-push even if same app @@ -122,6 +119,6 @@ final class ForegroundAppService { } } - EventPushServer.shared.pushEvent(.app(name: name, bundleId: bundleId, iconBase64: iconBase64)) + EventPushService.shared.pushEvent(.app(name: name, bundleId: bundleId, iconBase64: iconBase64)) } } diff --git a/macOS/GhostTools/Sources/GhostTools/Services/LogService.swift b/macOS/GhostTools/Sources/GhostTools/Services/LogService.swift index 54fa287..1e953ca 100644 --- a/macOS/GhostTools/Sources/GhostTools/Services/LogService.swift +++ b/macOS/GhostTools/Sources/GhostTools/Services/LogService.swift @@ -33,9 +33,9 @@ final class LogService: @unchecked Sendable { } /// Global logging function that prints locally, buffers for host polling, -/// and pushes to host via EventPushServer +/// and pushes to host via EventPushService func log(_ message: String) { print(message) LogService.shared.append(message) - EventPushServer.shared.pushEvent(.log(message)) + EventPushService.shared.pushEvent(.log(message)) } diff --git a/macOS/GhostTools/Sources/GhostTools/Services/PortScanner.swift b/macOS/GhostTools/Sources/GhostTools/Services/PortScanner.swift index d9dd54b..db47529 100644 --- a/macOS/GhostTools/Sources/GhostTools/Services/PortScanner.swift +++ b/macOS/GhostTools/Sources/GhostTools/Services/PortScanner.swift @@ -14,7 +14,7 @@ struct PortInfo { /// Scans for listening TCP ports using libproc. final class PortScanner { - static let shared = PortScanner() + nonisolated(unsafe) static let shared = PortScanner() /// Minimum port to report (skip well-known/system ports) var minimumPort: UInt16 = 1025 diff --git a/macOS/GhostTools/Sources/GhostTools/Services/PortScannerService.swift b/macOS/GhostTools/Sources/GhostTools/Services/PortScannerService.swift index 040dbb7..35a51db 100644 --- a/macOS/GhostTools/Sources/GhostTools/Services/PortScannerService.swift +++ b/macOS/GhostTools/Sources/GhostTools/Services/PortScannerService.swift @@ -1,9 +1,9 @@ import Foundation /// Periodically scans for listening TCP ports and pushes changes to the host -/// via EventPushServer. Only sends updates when the set of ports changes. +/// via EventPushService. Only sends updates when the set of ports changes. final class PortScannerService { - static let shared = PortScannerService() + nonisolated(unsafe) static let shared = PortScannerService() private var timer: Timer? private var previousPorts: Set = [] @@ -35,6 +35,6 @@ final class PortScannerService { previousPorts = currentPorts let summary = portInfos.map { "\($0.process.isEmpty ? "?" : $0.process):\($0.port)" } print("[PortScannerService] Ports changed: \(summary)") - EventPushServer.shared.pushEvent(.ports(portInfos)) + EventPushService.shared.pushEvent(.ports(portInfos)) } } diff --git a/macOS/GhostTools/Sources/GhostTools/Services/URLService.swift b/macOS/GhostTools/Sources/GhostTools/Services/URLService.swift index 7ab1957..00f3276 100644 --- a/macOS/GhostTools/Sources/GhostTools/Services/URLService.swift +++ b/macOS/GhostTools/Sources/GhostTools/Services/URLService.swift @@ -2,7 +2,7 @@ import Foundation /// Service for managing URLs to be opened on the host final class URLService { - static let shared = URLService() + nonisolated(unsafe) static let shared = URLService() /// URLs queued for opening on host private var pendingURLs: [URL] = [] @@ -15,7 +15,7 @@ final class URLService { lock.lock() pendingURLs.append(url) lock.unlock() - EventPushServer.shared.pushEvent(.urls([url.absoluteString])) + EventPushService.shared.pushEvent(.urls([url.absoluteString])) log("[URLService] Queued URL: \(url.absoluteString)") } diff --git a/macOS/GhostTools/Tests/GhostToolsTests/AsyncVSockIOTests.swift b/macOS/GhostTools/Tests/GhostToolsTests/AsyncVSockIOTests.swift deleted file mode 100644 index a00aaeb..0000000 --- a/macOS/GhostTools/Tests/GhostToolsTests/AsyncVSockIOTests.swift +++ /dev/null @@ -1,259 +0,0 @@ -import XCTest -@testable import GhostTools - -final class AsyncVSockIOTests: XCTestCase { - private func makeSocketPair() throws -> (Int32, Int32) { - var fds = [Int32](repeating: -1, count: 2) - let rc = socketpair(AF_UNIX, SOCK_STREAM, 0, &fds) - XCTAssertEqual(rc, 0, "socketpair failed: errno=\(errno)") - guard rc == 0 else { - throw AsyncVSockIOError.syscall(op: "socketpair", errno: errno) - } - return (fds[0], fds[1]) - } - - // MARK: - AsyncVSockIO tests - - func testWriteAllAndRead() async throws { - let (fdA, fdB) = try makeSocketPair() - let ioA = AsyncVSockIO(fd: fdA, ownsFD: true) - let ioB = AsyncVSockIO(fd: fdB, ownsFD: true) - - try await ioA.writeAll(Data("hello".utf8)) - let data = try await ioB.read(maxBytes: 32) - XCTAssertEqual(String(data: data ?? Data(), encoding: .utf8), "hello") - - ioA.close() - ioB.close() - } - - func testReadExactlyThrowsOnEarlyEOF() async throws { - let (fdA, fdB) = try makeSocketPair() - let ioA = AsyncVSockIO(fd: fdA, ownsFD: true) - let ioB = AsyncVSockIO(fd: fdB, ownsFD: true) - - try await ioA.writeAll(Data([1, 2, 3])) - ioA.close() - - do { - _ = try await ioB.readExactly(4) - XCTFail("Expected eofBeforeExpected") - } catch let AsyncVSockIOError.eofBeforeExpected(expected, received) { - XCTAssertEqual(expected, 4) - XCTAssertEqual(received, 3) - } - - ioB.close() - } - - func testReadReturnsNilOnEOF() async throws { - let (fdA, fdB) = try makeSocketPair() - let ioA = AsyncVSockIO(fd: fdA, ownsFD: true) - let ioB = AsyncVSockIO(fd: fdB, ownsFD: true) - - ioA.close() - let read = try await ioB.read(maxBytes: 16) - XCTAssertNil(read) - - ioB.close() - } - - func testCancellationDuringRead() async throws { - let (fdA, fdB) = try makeSocketPair() - let ioA = AsyncVSockIO(fd: fdA, ownsFD: true) - let ioB = AsyncVSockIO(fd: fdB, ownsFD: true) - - let task = Task { - try await ioB.read(maxBytes: 16) - } - - try await Task.sleep(nanoseconds: 50_000_000) - task.cancel() - - do { - _ = try await task.value - XCTFail("Expected cancellation") - } catch AsyncVSockIOError.cancelled { - // expected - } - - ioA.close() - ioB.close() - } - - func testCloseIsIdempotent() async throws { - let (fdA, fdB) = try makeSocketPair() - let ioA = AsyncVSockIO(fd: fdA, ownsFD: true) - let ioB = AsyncVSockIO(fd: fdB, ownsFD: true) - - ioA.close() - ioA.close() - - do { - try await ioA.writeAll(Data("x".utf8)) - XCTFail("Expected closed error") - } catch AsyncVSockIOError.closed { - // expected - } - - ioB.close() - } - - func testPipeBidirectionalCopiesBothDirections() async throws { - let (fdL1, fdL2) = try makeSocketPair() - let (fdR1, fdR2) = try makeSocketPair() - - let bridgeLeft = AsyncVSockIO(fd: fdL2, ownsFD: true) - let bridgeRight = AsyncVSockIO(fd: fdR2, ownsFD: true) - let clientLeft = AsyncVSockIO(fd: fdL1, ownsFD: true) - let clientRight = AsyncVSockIO(fd: fdR1, ownsFD: true) - - let bridgeTask = Task { - try await pipeBidirectional(bridgeLeft, bridgeRight) - } - - try await clientLeft.writeAll(Data("left->right".utf8)) - let fromLeft = try await clientRight.readExactly("left->right".utf8.count) - XCTAssertEqual(String(data: fromLeft, encoding: .utf8), "left->right") - - try await clientRight.writeAll(Data("right->left".utf8)) - let fromRight = try await clientLeft.readExactly("right->left".utf8.count) - XCTAssertEqual(String(data: fromRight, encoding: .utf8), "right->left") - - clientLeft.close() - clientRight.close() - - _ = try? await bridgeTask.value - bridgeLeft.close() - bridgeRight.close() - } - - // MARK: - BlockingVSockChannel tests - - func testBlockingChannelWriteAndRead() async throws { - let (fdA, fdB) = try makeSocketPair() - let chA = BlockingVSockChannel(fd: fdA, ownsFD: true) - let chB = BlockingVSockChannel(fd: fdB, ownsFD: true) - - try await chA.writeAll(Data("hello-blocking".utf8)) - let data = try await chB.read(maxBytes: 32) - XCTAssertEqual(String(data: data ?? Data(), encoding: .utf8), "hello-blocking") - - chA.close() - chB.close() - } - - func testBlockingChannelLargeTransfer() async throws { - let (fdA, fdB) = try makeSocketPair() - let chA = BlockingVSockChannel(fd: fdA, ownsFD: true) - let chB = BlockingVSockChannel(fd: fdB, ownsFD: true) - - // 1MB — large enough to trigger backpressure - let size = 1024 * 1024 - let payload = Data((0..right".utf8)) - let fromLeft = try await clientRight.readExactly("left->right".utf8.count) - XCTAssertEqual(String(data: fromLeft, encoding: .utf8), "left->right") - - try await clientRight.writeAll(Data("right->left".utf8)) - let fromRight = try await clientLeft.readExactly("right->left".utf8.count) - XCTAssertEqual(String(data: fromRight, encoding: .utf8), "right->left") - - clientLeft.close() - clientRight.close() - - _ = try? await bridgeTask.value - bridgeLeft.close() - bridgeRight.close() - } - - // MARK: - Mixed channel pipe (simulates real port-forward: TCP AsyncVSockIO <-> vsock BlockingVSockChannel) - - func testMixedChannelBidirectionalPipe() async throws { - let (fdL1, fdL2) = try makeSocketPair() - let (fdR1, fdR2) = try makeSocketPair() - - let tcpSide = AsyncVSockIO(fd: fdL2, ownsFD: true) - let vsockSide = BlockingVSockChannel(fd: fdR2, ownsFD: true) - let clientLeft = AsyncVSockIO(fd: fdL1, ownsFD: true) - let clientRight = AsyncVSockIO(fd: fdR1, ownsFD: true) - - let bridgeTask = Task { - try await pipeBidirectional(tcpSide, vsockSide) - } - - try await clientLeft.writeAll(Data("tcp->vsock".utf8)) - let fromTCP = try await clientRight.readExactly("tcp->vsock".utf8.count) - XCTAssertEqual(String(data: fromTCP, encoding: .utf8), "tcp->vsock") - - try await clientRight.writeAll(Data("vsock->tcp".utf8)) - let fromVsock = try await clientLeft.readExactly("vsock->tcp".utf8.count) - XCTAssertEqual(String(data: fromVsock, encoding: .utf8), "vsock->tcp") - - clientLeft.close() - clientRight.close() - - _ = try? await bridgeTask.value - tcpSide.close() - vsockSide.close() - } -} diff --git a/macOS/GhostTools/Tests/GhostToolsTests/TunnelServerTests.swift b/macOS/GhostTools/Tests/GhostToolsTests/TunnelServerTests.swift deleted file mode 100644 index 3d48b89..0000000 --- a/macOS/GhostTools/Tests/GhostToolsTests/TunnelServerTests.swift +++ /dev/null @@ -1,19 +0,0 @@ -import XCTest -@testable import GhostTools - -final class TunnelServerTests: XCTestCase { - func testDisconnectErrnoClassification() { - XCTAssertTrue(tunnelIsDisconnectErrno(ECONNRESET)) - XCTAssertTrue(tunnelIsDisconnectErrno(EPIPE)) - XCTAssertTrue(tunnelIsDisconnectErrno(ETIMEDOUT)) - XCTAssertFalse(tunnelIsDisconnectErrno(EINVAL)) - } - - func testOperationalBridgeErrorClassification() { - XCTAssertTrue(tunnelIsOperationalBridgeError(AsyncVSockIOError.closed)) - XCTAssertTrue(tunnelIsOperationalBridgeError(AsyncVSockIOError.cancelled)) - XCTAssertTrue(tunnelIsOperationalBridgeError(AsyncVSockIOError.syscall(op: "read", errno: ECONNRESET))) - XCTAssertFalse(tunnelIsOperationalBridgeError(AsyncVSockIOError.syscall(op: "read", errno: EINVAL))) - } -} - diff --git a/macOS/GhostTools/Tests/GhostToolsTests/WSFrameParserTests.swift b/macOS/GhostTools/Tests/GhostToolsTests/WSFrameParserTests.swift new file mode 100644 index 0000000..4ca6002 --- /dev/null +++ b/macOS/GhostTools/Tests/GhostToolsTests/WSFrameParserTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import GhostTools + +final class WSFrameParserTests: XCTestCase { + + func testSingleFrameRoundTrip() { + var parser = WSFrameParser() + let payload: [UInt8] = Array("hello".utf8) + parser.feed(WSFrameEncoder.encode(opcode: .binary, payload: payload, mask: true)) + + let frame = parser.nextFrame() + XCTAssertEqual(frame?.opcode, .binary) + XCTAssertEqual(frame?.payload, payload) + XCTAssertNil(parser.nextFrame()) + } + + func testFragmentedBinaryReassembles() { + var parser = WSFrameParser() + let part1: [UInt8] = Array("hello ".utf8) + let part2: [UInt8] = Array("frag".utf8) + let part3: [UInt8] = Array("mented".utf8) + + parser.feed(WSFrameEncoder.encode(opcode: .binary, payload: part1, mask: true, fin: false)) + XCTAssertNil(parser.nextFrame(), "incomplete message must not surface") + + parser.feed(WSFrameEncoder.encode(opcode: .continuation, payload: part2, mask: true, fin: false)) + XCTAssertNil(parser.nextFrame()) + + parser.feed(WSFrameEncoder.encode(opcode: .continuation, payload: part3, mask: true, fin: true)) + let frame = parser.nextFrame() + XCTAssertEqual(frame?.opcode, .binary) + XCTAssertEqual(frame?.payload, part1 + part2 + part3) + } + + func testFragmentedTextPreservesOriginalOpcode() { + var parser = WSFrameParser() + let part1: [UInt8] = Array(#"{"type":"#.utf8) + let part2: [UInt8] = Array(#""resize","cols":80,"rows":24}"#.utf8) + + parser.feed(WSFrameEncoder.encode(opcode: .text, payload: part1, mask: true, fin: false)) + parser.feed(WSFrameEncoder.encode(opcode: .continuation, payload: part2, mask: true, fin: true)) + + let frame = parser.nextFrame() + XCTAssertEqual(frame?.opcode, .text) + XCTAssertEqual(frame?.payload, part1 + part2) + } + + func testControlFrameInterleavedBetweenFragments() { + var parser = WSFrameParser() + let part1: [UInt8] = [0x01, 0x02] + let part2: [UInt8] = [0x03, 0x04] + let pingPayload: [UInt8] = [0xAA] + + parser.feed(WSFrameEncoder.encode(opcode: .binary, payload: part1, mask: true, fin: false)) + parser.feed(WSFrameEncoder.encode(opcode: .ping, payload: pingPayload, mask: true)) + parser.feed(WSFrameEncoder.encode(opcode: .continuation, payload: part2, mask: true, fin: true)) + + let ping = parser.nextFrame() + XCTAssertEqual(ping?.opcode, .ping) + XCTAssertEqual(ping?.payload, pingPayload) + + let data = parser.nextFrame() + XCTAssertEqual(data?.opcode, .binary) + XCTAssertEqual(data?.payload, part1 + part2) + } + + func testUnexpectedContinuationProducesClose() { + var parser = WSFrameParser() + parser.feed(WSFrameEncoder.encode(opcode: .continuation, payload: [0x01], mask: true, fin: true)) + + let frame = parser.nextFrame() + XCTAssertEqual(frame?.opcode, .close) + } + + func testNewDataFrameWhileFragmentedProducesClose() { + var parser = WSFrameParser() + parser.feed(WSFrameEncoder.encode(opcode: .binary, payload: [0x01], mask: true, fin: false)) + parser.feed(WSFrameEncoder.encode(opcode: .text, payload: [0x02], mask: true, fin: true)) + + let frame = parser.nextFrame() + XCTAssertEqual(frame?.opcode, .close) + } + + func testFragmentedControlFrameProducesClose() { + var parser = WSFrameParser() + // Hand-build: FIN=0, opcode=ping, mask=0, len=0. + parser.feed([0x09, 0x00]) + + let frame = parser.nextFrame() + XCTAssertEqual(frame?.opcode, .close) + } +} diff --git a/macOS/GhostTools/Tests/GhostToolsTests/WebSocketShellIntegrationTests.swift b/macOS/GhostTools/Tests/GhostToolsTests/WebSocketShellIntegrationTests.swift new file mode 100644 index 0000000..10aa969 --- /dev/null +++ b/macOS/GhostTools/Tests/GhostToolsTests/WebSocketShellIntegrationTests.swift @@ -0,0 +1,197 @@ +import XCTest +import Foundation +import GhostHTTP +@testable import GhostTools + +final class WebSocketShellIntegrationTests: XCTestCase { + + private final class LockedErrorBox: @unchecked Sendable { + private let lock = NSLock() + private var storedError: Error? + + func set(_ error: Error) { + lock.lock() + storedError = error + lock.unlock() + } + + func get() -> Error? { + lock.lock() + defer { lock.unlock() } + return storedError + } + } + + func testShellRoundTripPlainText() throws { + let session = try runShellSession(command: "printf 'alpha\\nbeta\\n'") + XCTAssertEqual(session.output, "alpha\r\nbeta\r\n") + } + + func testShellNoDoubleCarriageReturnOnLF() throws { + let session = try runShellSession(command: "printf 'left\\nright\\n'") + XCTAssertFalse(session.output.contains("\r\r\n")) + } + + func testShellResizeControlMessage() throws { + let resizeMessage = #"{"type":"resize","cols":132,"rows":43}"# + let session = try runShellSession( + command: "while [ \"$(stty size)\" != \"43 132\" ]; do sleep 0.05; done; stty size", + clientFrames: [ + WSFrameEncoder.encode(opcode: .text, payload: Array(resizeMessage.utf8), mask: true) + ] + ) + XCTAssertEqual(session.output, "43 132\r\n", "expected resized PTY dimensions in output, got: \(session.output)") + } + + private func runShellSession( + command: String, + clientFrames: [[UInt8]] = [], + file: StaticString = #filePath, + line: UInt = #line + ) throws -> ShellSessionResult { + var fds = [Int32](repeating: -1, count: 2) + XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0, file: file, line: line) + let serverFD = fds[0] + let clientFD = fds[1] + + configureTimeouts(fd: serverFD) + configureTimeouts(fd: clientFD) + + let serverDone = DispatchGroup() + let serverError = LockedErrorBox() + serverDone.enter() + DispatchQueue.global(qos: .userInitiated).async { + defer { + Darwin.close(serverFD) + serverDone.leave() + } + do { + let (request, prelude) = try HTTPCodec.readRequest(fd: serverFD) + try WebSocketShell.handleUpgradeAndRun( + fd: serverFD, + request: request, + cols: 80, + rows: 24, + term: "xterm-256color", + prelude: prelude, + launchConfiguration: .init( + executablePath: "/bin/sh", + arguments: ["sh", "-c", command], + environment: ["TERM": "xterm-256color"] + ) + ) + } catch { + serverError.set(error) + } + } + + defer { + Darwin.close(clientFD) + } + + var wsKeyBytes = [UInt8](repeating: 0, count: 16) + arc4random_buf(&wsKeyBytes, wsKeyBytes.count) + let upgraded = try HTTPClient.performUpgradeRequest( + fd: clientFD, + path: "/api/v1/shell?cols=80&rows=24&term=xterm-256color", + headers: HTTPHeaders([ + "Host": "localhost", + "Upgrade": "websocket", + "Connection": "Upgrade", + "Sec-WebSocket-Key": Data(wsKeyBytes).base64EncodedString(), + "Sec-WebSocket-Version": "13", + ]) + ) + XCTAssertEqual(upgraded.responseHead.status, .switchingProtocols) + + for frame in clientFrames { + try writeAll(fd: clientFD, data: Data(frame)) + } + + var parser = WSFrameParser() + if !upgraded.prelude.isEmpty { + parser.feed(Array(upgraded.prelude)) + } + + var output = Data() + readLoop: while true { + while let frame = parser.nextFrame() { + switch frame.opcode { + case .binary: + output.append(contentsOf: frame.payload) + case .close: + break readLoop + case .ping: + let pong = WSFrameEncoder.encode(opcode: .pong, payload: frame.payload, mask: true) + try writeAll(fd: clientFD, data: Data(pong)) + default: + break + } + } + + var buffer = [UInt8](repeating: 0, count: 4096) + let n = Darwin.read(clientFD, &buffer, buffer.count) + if n > 0 { + parser.feed(Array(buffer[0...size)) + }, + 0 + ) + XCTAssertEqual( + withUnsafePointer(to: &timeout) { + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, $0, socklen_t(MemoryLayout.size)) + }, + 0 + ) + } + + private func writeAll(fd: Int32, data: Data) throws { + try data.withUnsafeBytes { bytes in + guard let base = bytes.baseAddress else { return } + var offset = 0 + while offset < bytes.count { + let n = Darwin.write(fd, base + offset, bytes.count - offset) + if n > 0 { + offset += n + } else if n < 0 && errno == EINTR { + continue + } else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + } + } + } +} + +private struct ShellSessionResult { + let output: String +} diff --git a/macOS/GhostVM/App2Models.swift b/macOS/GhostVM/App2Models.swift index b041127..9a41b52 100644 --- a/macOS/GhostVM/App2Models.swift +++ b/macOS/GhostVM/App2Models.swift @@ -153,6 +153,22 @@ final class App2VMStore: ObservableObject { vms[index] = updated } + /// Replace a VM's bundle URL after migration. The old URL is swapped out, + /// the new one takes its place in the list. The old bundle is NOT deleted. + func replaceBundleURL(vmID: App2VM.ID, newBundleURL: URL) { + guard let index = vms.firstIndex(where: { $0.id == vmID }) else { return } + let old = vms[index] + vms[index] = App2VM( + id: old.id, + name: controller.displayName(for: newBundleURL), + bundlePath: newBundleURL.path, + osVersion: old.osVersion, + status: old.status, + installed: old.installed + ) + persistKnownVMs() + } + func removeFromList(_ vm: App2VM) { vms.removeAll { $0.id == vm.id } persistKnownVMs() diff --git a/macOS/GhostVM/App2VMRuntime.swift b/macOS/GhostVM/App2VMRuntime.swift index ed6ef16..1f59c64 100644 --- a/macOS/GhostVM/App2VMRuntime.swift +++ b/macOS/GhostVM/App2VMRuntime.swift @@ -38,6 +38,283 @@ final class App2VMSessionRegistry { session?.terminate() } + // MARK: - ASIF Migration + + /// Retained reference to the migration prompt panel so it isn't + /// deallocated while visible (avoids CoreAnimation use-after-free). + fileprivate static var migrationPanel: NSPanel? + + private static func promptMigration(bundleURL: URL, store: App2VMStore, vmID: App2VM.ID, recovery: Bool) { + DispatchQueue.main.async { + let vmName = bundleURL.deletingPathExtension().lastPathComponent + + // Build a lightweight NSPanel instead of NSAlert. + // NSAlert's internal _NSWindowTransformAnimation is deallocated + // by SwiftUI's window management, causing a use-after-free crash + // ~30s after dismissal on macOS 26. A plain NSPanel we retain + // ourselves has no such animation object. + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 200), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + panel.title = "Upgrade Disk Format" + panel.isFloatingPanel = true + panel.becomesKeyOnlyIfNeeded = false + panel.level = .modalPanel + panel.isReleasedWhenClosed = false + Self.migrationPanel = panel + + // --- Layout --- + let contentView = NSView(frame: panel.contentView!.bounds) + contentView.autoresizingMask = [.width, .height] + + let icon = NSImageView(frame: NSRect(x: 20, y: 130, width: 48, height: 48)) + icon.image = NSImage(named: NSImage.cautionName) + contentView.addSubview(icon) + + let titleLabel = NSTextField(labelWithString: "Upgrade \"\(vmName)\" Disk Format?") + titleLabel.font = .boldSystemFont(ofSize: 13) + titleLabel.frame = NSRect(x: 78, y: 158, width: 320, height: 20) + contentView.addSubview(titleLabel) + + let infoLabel = NSTextField(wrappingLabelWithString: + "This VM uses an older disk format. Migrating to ASIF gives near-native SSD performance.\n\nThe original VM will not be modified.\n\n⚠ Snapshots and suspend state are not migrated.") + infoLabel.frame = NSRect(x: 78, y: 48, width: 320, height: 108) + infoLabel.font = .systemFont(ofSize: 11) + contentView.addSubview(infoLabel) + + let migrateButton = NSButton(title: "Migrate\u{2026}", target: nil, action: nil) + migrateButton.bezelStyle = .rounded + migrateButton.keyEquivalent = "\r" + migrateButton.frame = NSRect(x: 310, y: 12, width: 95, height: 30) + contentView.addSubview(migrateButton) + + let notNowButton = NSButton(title: "Cancel", target: nil, action: nil) + notNowButton.bezelStyle = .rounded + notNowButton.keyEquivalent = "\u{1b}" // Escape + notNowButton.frame = NSRect(x: 215, y: 12, width: 90, height: 30) + contentView.addSubview(notNowButton) + + panel.contentView = contentView + + // --- Actions --- + let handler = MigrationPromptHandler( + panel: panel, + bundleURL: bundleURL, + store: store, + vmID: vmID, + recovery: recovery + ) + // Keep handler alive as long as the panel is visible + objc_setAssociatedObject(panel, "handler", handler, .OBJC_ASSOCIATION_RETAIN) + + migrateButton.target = handler + migrateButton.action = #selector(MigrationPromptHandler.migrate) + + notNowButton.target = handler + notNowButton.action = #selector(MigrationPromptHandler.notNow) + + panel.center() + panel.makeKeyAndOrderFront(nil) + } + } + + fileprivate static func runMigration( + source: URL, + destination: URL, + store: App2VMStore, + vmID: App2VM.ID, + recovery: Bool + ) { + let migrationService = VMMigrationService() + + // Create progress window with terminal output + let progressWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + progressWindow.title = "Migrating VM…" + progressWindow.center() + + let statusLabel = NSTextField(labelWithString: "Starting migration…") + statusLabel.frame = NSRect(x: 20, y: 288, width: 480, height: 20) + statusLabel.font = .systemFont(ofSize: 12) + + let progressBar = NSProgressIndicator(frame: NSRect(x: 20, y: 265, width: 480, height: 20)) + progressBar.style = .bar + progressBar.minValue = 0 + progressBar.maxValue = 1 + progressBar.isIndeterminate = false + + // Terminal-style output view + let scrollView = NSScrollView(frame: NSRect(x: 20, y: 50, width: 480, height: 210)) + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.borderType = .bezelBorder + + let textView = NSTextView(frame: scrollView.contentView.bounds) + textView.isEditable = false + textView.isSelectable = true + textView.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + textView.backgroundColor = NSColor.textBackgroundColor + textView.textColor = NSColor.labelColor + textView.autoresizingMask = [.width, .height] + scrollView.documentView = textView + + let cancelButton = NSButton(title: "Cancel", target: nil, action: nil) + cancelButton.frame = NSRect(x: 420, y: 12, width: 80, height: 30) + + let contentView = NSView(frame: NSRect(x: 0, y: 0, width: 520, height: 320)) + contentView.addSubview(statusLabel) + contentView.addSubview(progressBar) + contentView.addSubview(scrollView) + contentView.addSubview(cancelButton) + progressWindow.contentView = contentView + progressWindow.makeKeyAndOrderFront(nil) + + cancelButton.target = migrationService + cancelButton.action = #selector(VMMigrationService.cancel) + + DispatchQueue.global(qos: .userInitiated).async { + do { + try migrationService.migrate( + source: source, + destination: destination, + progressHandler: { fraction, message in + DispatchQueue.main.async { + progressBar.doubleValue = fraction + statusLabel.stringValue = message + } + }, + outputHandler: { line in + DispatchQueue.main.async { + textView.textStorage?.append(NSAttributedString( + string: line, + attributes: [ + .font: NSFont.monospacedSystemFont(ofSize: 11, weight: .regular), + .foregroundColor: NSColor.labelColor, + ] + )) + textView.scrollToEndOfDocument(nil) + } + } + ) + + DispatchQueue.main.async { + // Swap VM in list + store.replaceBundleURL(vmID: vmID, newBundleURL: destination) + + // Show success state with Boot button + progressBar.doubleValue = 1.0 + statusLabel.stringValue = "Migration complete" + progressWindow.title = "Migration Complete" + cancelButton.isHidden = true + + let bootButton = NSButton(title: "Boot Now", target: nil, action: nil) + bootButton.bezelStyle = .rounded + bootButton.keyEquivalent = "\r" + bootButton.frame = NSRect(x: 400, y: 12, width: 100, height: 30) + contentView.addSubview(bootButton) + + // Boot action: hide window immediately, boot after animations settle + let bootAction = MigrationBootAction(window: progressWindow) { + App2VMSessionRegistry.shared.startVMSession( + bundleURL: destination, store: store, vmID: vmID, recovery: recovery + ) + } + objc_setAssociatedObject(bootButton, "bootAction", bootAction, .OBJC_ASSOCIATION_RETAIN) + bootButton.target = bootAction + bootButton.action = #selector(MigrationBootAction.boot) + } + } catch { + DispatchQueue.main.async { + statusLabel.stringValue = "Migration failed: \(error.localizedDescription)" + progressWindow.title = "Migration Failed" + let closeAction = MigrationBootAction(window: progressWindow) {} + objc_setAssociatedObject(cancelButton, "closeAction", closeAction, .OBJC_ASSOCIATION_RETAIN) + cancelButton.title = "Close" + cancelButton.target = closeAction + cancelButton.action = #selector(MigrationBootAction.boot) + } + } + } + } + +} + +/// Handles Migrate / Not Now buttons in the migration prompt panel. +/// Stored as an associated object on the panel so it stays alive while visible. +private final class MigrationPromptHandler: NSObject { + private let panel: NSPanel + private let bundleURL: URL + private let store: App2VMStore + private let vmID: App2VM.ID + private let recovery: Bool + + init(panel: NSPanel, bundleURL: URL, store: App2VMStore, vmID: App2VM.ID, recovery: Bool) { + self.panel = panel + self.bundleURL = bundleURL + self.store = store + self.vmID = vmID + self.recovery = recovery + } + + private func dismissPanel() { + panel.orderOut(nil) + // Release the strong reference so the panel can be deallocated + App2VMSessionRegistry.migrationPanel = nil + } + + @objc func migrate() { + dismissPanel() + + let savePanel = NSSavePanel() + savePanel.title = "Save Migrated VM" + savePanel.nameFieldStringValue = bundleURL.lastPathComponent + savePanel.canCreateDirectories = true + + savePanel.begin { [bundleURL, store, vmID, recovery] response in + if response == .OK, let destURL = savePanel.url { + App2VMSessionRegistry.runMigration( + source: bundleURL, + destination: destURL, + store: store, + vmID: vmID, + recovery: recovery + ) + } + } + } + + @objc func notNow() { + dismissPanel() + } +} + +/// Handles Boot Now button: hides window, waits for animations to flush, then boots. +private final class MigrationBootAction: NSObject { + private weak var window: NSWindow? + private var onBoot: (() -> Void)? + init(window: NSWindow, onBoot: @escaping () -> Void) { + self.window = window + self.onBoot = onBoot + } + @objc func boot() { + window?.orderOut(nil) + let callback = onBoot + onBoot = nil // release captured references + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.window?.close() + callback?() + } + } +} + +extension App2VMSessionRegistry { /// Starts a VM by launching its helper app, or reconnects to an existing helper. /// Creates a session if needed. /// - Parameters: @@ -46,6 +323,36 @@ final class App2VMSessionRegistry { /// - vmID: The VM's ID in the store for status updates /// - runningPID: If non-nil, reconnect to an already-running helper instead of launching a new one func startVM(bundleURL: URL, store: App2VMStore, vmID: App2VM.ID, runningPID: pid_t? = nil, recovery: Bool = false) { + if runningPID == nil { + let layout = VMFileLayout(bundleURL: bundleURL) + let format = DiskFormat.detect(at: layout.diskURL) + if !format.isSupportedForCurrentHost { + let message: String + switch format { + case .asif: + message = "This VM uses an ASIF disk image, which requires macOS 26 or later." + case .unknown: + message = "GhostVM could not identify this VM's disk image format." + case .raw: + message = "This VM's disk image format is not supported on this Mac." + } + store.updateStatus(for: vmID, status: "Error") + store.lastStartError = message + return + } + } + + startVMSession(bundleURL: bundleURL, store: store, vmID: vmID, runningPID: runningPID, recovery: recovery) + } + + func migrateToASIF(bundleURL: URL, store: App2VMStore, vmID: App2VM.ID) { + guard DiskImageFormat.isASIFCreationSupported else { return } + let layout = VMFileLayout(bundleURL: bundleURL) + guard DiskFormat.detect(at: layout.diskURL) == .raw else { return } + Self.promptMigration(bundleURL: bundleURL, store: store, vmID: vmID, recovery: false) + } + + fileprivate func startVMSession(bundleURL: URL, store: App2VMStore, vmID: App2VM.ID, runningPID: pid_t? = nil, recovery: Bool = false, skipHelperCopy: Bool = false) { let path = bundleURL.standardizedFileURL.path lock.lock() var session = sessions[path] @@ -84,6 +391,7 @@ final class App2VMSessionRegistry { session?.reconnectToRunningHelper(pid: pid) } else { session?.recoveryBoot = recovery + session?.skipHelperCopy = skipHelperCopy session?.startIfNeeded() } } @@ -110,6 +418,10 @@ final class App2VMRunSession: NSObject, ObservableObject, @unchecked Sendable { /// When true, the helper app will boot into macOS Recovery mode. var recoveryBoot: Bool = false + /// When true, skip copying the helper app/GhostTools into the VM bundle. + /// Used when launching a VM without copying helper resources. + var skipHelperCopy: Bool = false + /// The VM's dispatch queue, needed for vsock operations var vmQueue: DispatchQueue? { windowlessSession?.vmQueue } @@ -310,62 +622,71 @@ final class App2VMRunSession: NSObject, ObservableObject, @unchecked Sendable { private func launchHelperApp() { guard helperProcess == nil else { return } - // Find the helper app in the main bundle - guard let sourceHelperURL = VMHelperBundleManager.findHelperInMainBundle() else { - print("[App2VMRunSession] GhostVMHelper.app not found in main bundle") - transition(to: .failed("Helper app not found")) - return + let helperAppURL: URL + if skipHelperCopy { + // Legacy VM — use existing helper in the VM bundle + guard let existingURL = helperBundleManager.helperAppURL(vmBundleURL: bundleURL) else { + print("[App2VMRunSession] No existing helper found in VM bundle") + transition(to: .failed("Helper app not found in VM bundle")) + return + } + helperAppURL = existingURL + } else { + // Normal path — copy latest helper into VM bundle + guard let sourceHelperURL = VMHelperBundleManager.findHelperInMainBundle() else { + print("[App2VMRunSession] GhostVMHelper.app not found in main bundle") + transition(to: .failed("Helper app not found")) + return + } + do { + helperAppURL = try helperBundleManager.copyHelperApp( + vmBundleURL: bundleURL, + sourceHelperAppURL: sourceHelperURL + ) + } catch { + print("[App2VMRunSession] Failed to copy helper: \(error)") + transition(to: .failed("Failed to copy helper: \(error.localizedDescription)")) + return + } } - do { - // Copy helper to VM bundle (preserves signature) - let helperAppURL = try helperBundleManager.copyHelperApp( - vmBundleURL: bundleURL, - sourceHelperAppURL: sourceHelperURL - ) + // Register for state change notifications from helper + // IMPORTANT: Use standardized path to match helper's path normalization + let standardizedPath = bundleURL.standardizedFileURL.path + let bundlePathHash = standardizedPath.stableHash + print("[App2VMRunSession] Registering for helper notifications: com.ghostvm.helper.state.\(bundlePathHash)") + print("[App2VMRunSession] Bundle path (standardized): \(standardizedPath)") + helperStateObserver = DistributedNotificationCenter.default().addObserver( + forName: NSNotification.Name("com.ghostvm.helper.state.\(bundlePathHash)"), + object: nil, + queue: .main + ) { [weak self] notification in + self?.handleHelperStateChange(notification) + } - // Register for state change notifications from helper - // IMPORTANT: Use standardized path to match helper's path normalization - let standardizedPath = bundleURL.standardizedFileURL.path - let bundlePathHash = standardizedPath.stableHash - print("[App2VMRunSession] Registering for helper notifications: com.ghostvm.helper.state.\(bundlePathHash)") - print("[App2VMRunSession] Bundle path (standardized): \(standardizedPath)") - helperStateObserver = DistributedNotificationCenter.default().addObserver( - forName: NSNotification.Name("com.ghostvm.helper.state.\(bundlePathHash)"), - object: nil, - queue: .main - ) { [weak self] notification in - self?.handleHelperStateChange(notification) - } + // Launch the helper app with VM bundle path (use standardized path) + let configuration = NSWorkspace.OpenConfiguration() + var args = ["--vm-bundle", standardizedPath] + if recoveryBoot { + args.append("--recovery") + } + configuration.arguments = args + configuration.activates = true + configuration.createsNewApplicationInstance = true - // Launch the helper app with VM bundle path (use standardized path) - let configuration = NSWorkspace.OpenConfiguration() - var args = ["--vm-bundle", standardizedPath] - if recoveryBoot { - args.append("--recovery") - } - configuration.arguments = args - configuration.activates = true - configuration.createsNewApplicationInstance = true - - NSWorkspace.shared.openApplication( - at: helperAppURL, - configuration: configuration - ) { [weak self] app, error in - DispatchQueue.main.async { - if let error = error { - print("[App2VMRunSession] Failed to launch helper: \(error)") - self?.transition(to: .failed(error.localizedDescription)) - } else if let app = app { - self?.helperProcess = app - print("[App2VMRunSession] Helper launched for '\(self?.vmName ?? "unknown")' (PID \(app.processIdentifier))") - } + NSWorkspace.shared.openApplication( + at: helperAppURL, + configuration: configuration + ) { [weak self] app, error in + DispatchQueue.main.async { + if let error = error { + print("[App2VMRunSession] Failed to launch helper: \(error)") + self?.transition(to: .failed(error.localizedDescription)) + } else if let app = app { + self?.helperProcess = app + print("[App2VMRunSession] Helper launched for '\(self?.vmName ?? "unknown")' (PID \(app.processIdentifier))") } } - - } catch { - print("[App2VMRunSession] Failed to prepare helper: \(error)") - transition(to: .failed(error.localizedDescription)) } } diff --git a/macOS/GhostVM/Services/EventStreamService.swift b/macOS/GhostVM/Services/EventStreamService.swift index bcd1b62..275a6c4 100644 --- a/macOS/GhostVM/Services/EventStreamService.swift +++ b/macOS/GhostVM/Services/EventStreamService.swift @@ -1,7 +1,8 @@ import Foundation import AppKit import Combine -import Virtualization +@preconcurrency import Virtualization +import GhostHTTP import GhostVMKit /// A guest port with optional process name. @@ -17,8 +18,9 @@ public struct GuestForegroundApp: Equatable { public let icon: NSImage? } -/// Persistent event stream from guest via vsock port 5003. -/// Reads NDJSON lines and dispatches: file queue updates, URL opens, log messages. +/// Persistent event stream from guest via the unified HTTP server on vsock port 5000. +/// Uses HTTP upgrade, then reads NDJSON lines and dispatches: file queue updates, +/// URL opens, log messages. @MainActor public final class EventStreamService: ObservableObject { @Published public private(set) var queuedGuestFiles: [String] = [] @@ -30,8 +32,7 @@ public final class EventStreamService: ObservableObject { private var client: GhostClient? private var task: Task? - private let port: UInt32 = 5003 - + private var currentConnection: VZVirtioSocketConnection? public init() {} public func start(client: GhostClient) { @@ -45,6 +46,8 @@ public final class EventStreamService: ObservableObject { public func stop() { task?.cancel() task = nil + currentConnection?.close() + currentConnection = nil client = nil queuedGuestFiles = [] detectedGuestPorts = [] @@ -60,27 +63,56 @@ public final class EventStreamService: ObservableObject { while !Task.isCancelled { guard let client = client else { break } - NSLog("EventStream: connecting to port %u...", port) + NSLog("EventStream: connecting to unified HTTP event stream...") do { - // IMPORTANT: Hold `connection` alive — dropping it closes the fd. - let connection = try await client.connectRaw(port: port) + let connection = try await client.connectRaw(port: 5000) let fd = connection.fileDescriptor + currentConnection = connection + clearReceiveTimeout(fd: fd) + + let upgraded: HTTPUpgradedConnection = try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + do { + let upgraded = try HTTPClient.performUpgradeRequest( + fd: fd, + path: "/api/v1/event-stream", + headers: [ + "Host": "localhost", + "Upgrade": "event-stream", + "Connection": "Upgrade", + ] + ) + continuation.resume(returning: upgraded) + } catch { + continuation.resume(throwing: error) + } + } + } + + guard upgraded.responseHead.status == .switchingProtocols else { + connection.close() + throw GhostClientError.invalidResponse(upgraded.responseHead.status.rawValue) + } - NSLog("EventStream: connected (fd=%d), reading NDJSON lines...", fd) + NSLog("EventStream: upgraded (fd=%d), reading NDJSON lines...", fd) // Read NDJSON lines on background queue. // `connection` is captured to keep it alive. await withCheckedContinuation { (continuation: CheckedContinuation) in DispatchQueue.global(qos: .utility).async { [weak self] in - self?.readLines(fd: fd) + self?.readLines(fd: fd, initialData: upgraded.prelude) NSLog("EventStream: connection lost (EOF)") connection.close() continuation.resume() } } + if currentConnection?.fileDescriptor == fd { + currentConnection = nil + } } catch { NSLog("EventStream: connection failed: %@", error.localizedDescription) + currentConnection = nil } // Wait before retrying @@ -93,17 +125,11 @@ public final class EventStreamService: ObservableObject { } /// Read NDJSON lines from the fd until EOF. Dispatches events to MainActor. - private nonisolated func readLines(fd: Int32) { + private nonisolated func readLines(fd: Int32, initialData: Data = Data()) { let bufferSize = 4096 - var leftover = Data() + var leftover = initialData var buffer = [UInt8](repeating: 0, count: bufferSize) - while true { - let n = Darwin.read(fd, &buffer, bufferSize) - if n <= 0 { break } - - leftover.append(contentsOf: buffer[0...size)) + } + private nonisolated func dispatchEvent(_ jsonLine: String) { guard let data = jsonLine.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], diff --git a/macOS/GhostVM/Services/FileTransferService.swift b/macOS/GhostVM/Services/FileTransferService.swift index 1a87601..204b368 100644 --- a/macOS/GhostVM/Services/FileTransferService.swift +++ b/macOS/GhostVM/Services/FileTransferService.swift @@ -2,6 +2,7 @@ import Foundation import AppKit import Combine import GhostVMKit +import os /// Represents the state of a file transfer public enum FileTransferState: Equatable { @@ -47,6 +48,8 @@ public struct FileTransfer: Identifiable { /// Service for managing file transfers between host and guest VM @MainActor public final class FileTransferService: ObservableObject { + private static let logger = Logger(subsystem: "org.ghostvm.ghostvm", category: "FileTransfer") + /// Active transfers @Published public private(set) var transfers: [FileTransfer] = [] @@ -198,7 +201,7 @@ public final class FileTransferService: ObservableObject { } } - /// Fetch a file from the guest VM and save to host + /// Fetch a file from the guest VM and save to host (streamed to disk with progress) /// - Parameter guestPath: Path in the guest to fetch /// - Parameter savePanel: Whether to show a save panel (true) or save to Downloads (false) public func fetchFile(at guestPath: String, showSavePanel: Bool = true) { @@ -217,42 +220,68 @@ public final class FileTransferService: ObservableObject { Task { do { - updateTransferState(id: transferId, state: .transferring(progress: 0.5)) - - let (data, fetchedFilename, permissions) = try await client.fetchFile(at: guestPath) - - // Determine save location + // Determine save location before starting the transfer + let suggestedName = filename let saveURL: URL if showSavePanel { - guard let url = await showSavePanelAsync(suggestedFilename: fetchedFilename) else { + guard let url = await showSavePanelAsync(suggestedFilename: suggestedName) else { updateTransferState(id: transferId, state: .failed(error: "Save cancelled")) updateIsTransferring() return } saveURL = url } else { - // Save to Downloads let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! - saveURL = downloadsURL.appendingPathComponent(fetchedFilename) + saveURL = downloadsURL.appendingPathComponent(suggestedName) } - // Write file - try data.write(to: saveURL) - Self.applyQuarantine(to: saveURL) + // Stream directly to a temp file, then move to final location + let tempURL = saveURL.appendingPathExtension("ghostvm-partial") + + updateTransferState(id: transferId, state: .transferring(progress: 0.0)) + + let (fetchedFilename, permissions) = try await client.fetchFile( + at: guestPath, + to: tempURL + ) { [weak self] progress in + Task { @MainActor in + self?.updateTransferState(id: transferId, state: .transferring(progress: progress)) + } + } + + // Move temp file to final location (use fetched filename if save panel wasn't shown) + var finalURL = saveURL + if !showSavePanel { + let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! + finalURL = downloadsURL.appendingPathComponent(fetchedFilename) + } + if FileManager.default.fileExists(atPath: finalURL.path) { + try FileManager.default.removeItem(at: finalURL) + } + try FileManager.default.moveItem(at: tempURL, to: finalURL) + + Self.applyQuarantine(to: finalURL) - // Apply permissions if provided if let permissions = permissions { - try? FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: saveURL.path) + try? FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: finalURL.path) } - updateTransferState(id: transferId, state: .completed(path: saveURL.path)) + updateTransferState(id: transferId, state: .completed(path: finalURL.path)) - // Reveal in Finder (only for single-file fetch with save panel) if showSavePanel { - NSWorkspace.shared.activateFileViewerSelecting([saveURL]) + NSWorkspace.shared.activateFileViewerSelecting([finalURL]) } } catch { + // Clean up any partial download from a failed/interrupted fetch. + // tempURL is constructed deterministically as saveURL+".ghostvm-partial" + // but at this point saveURL may not have been computed yet on the + // early-cancel path; we best-effort scan the candidate locations. + let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + if let downloadsURL { + let candidate = downloadsURL.appendingPathComponent(filename).appendingPathExtension("ghostvm-partial") + try? FileManager.default.removeItem(at: candidate) + } updateTransferState(id: transferId, state: .failed(error: error.localizedDescription)) lastError = "Fetch failed: \(error.localizedDescription)" } @@ -271,10 +300,9 @@ public final class FileTransferService: ObservableObject { /// Fetch all queued files from the guest and save to Downloads public func fetchAllGuestFiles() { - NSLog("[FileTransfer] fetchAllGuestFiles called, ghostClient=%@, queuedPaths=%d", - ghostClient != nil ? "present" : "NIL", queuedGuestFilePaths.count) + Self.logger.info("fetchAllGuestFiles called, ghostClient=\(self.ghostClient != nil ? "present" : "NIL", privacy: .public), queuedPaths=\(self.queuedGuestFilePaths.count)") guard let client = ghostClient else { - NSLog("[FileTransfer] ERROR: ghostClient is nil — cannot fetch files") + Self.logger.error("fetchAllGuestFiles: ghostClient is nil — cannot fetch files") lastError = "Not connected to guest" return } @@ -282,10 +310,10 @@ public final class FileTransferService: ObservableObject { // Use the file paths we already have from the event stream // instead of making a redundant HTTP round-trip via listGuestFiles() let files = queuedGuestFilePaths - NSLog("[FileTransfer] Using %d file path(s) from event stream: %@", files.count, files.description) + Self.logger.info("fetchAllGuestFiles: using \(files.count) file path(s): \(files.description, privacy: .public)") guard !files.isEmpty else { - NSLog("[FileTransfer] No files to fetch — clearing stale toolbar state") + Self.logger.info("fetchAllGuestFiles: no files to fetch — clearing stale toolbar state") queuedGuestFilePaths = [] queuedGuestFileCount = 0 return @@ -295,13 +323,25 @@ public final class FileTransferService: ObservableObject { var savedURLs: [URL] = [] var failedPaths: [String] = [] let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! + Self.logger.info("fetchAllGuestFiles: downloadsURL=\(downloadsURL.path, privacy: .public)") for guestPath in files { + let tempURL = downloadsURL.appendingPathComponent(UUID().uuidString + ".ghostvm-partial") do { - NSLog("[FileTransfer] Fetching: %@", guestPath) - let (data, fetchedFilename, permissions) = try await client.fetchFile(at: guestPath) + Self.logger.info("fetchAllGuestFiles: fetching \(guestPath, privacy: .public) → temp \(tempURL.path, privacy: .public)") + let (fetchedFilename, permissions) = try await client.fetchFile( + at: guestPath, + to: tempURL + ) { _ in /* batch fetch — no per-file progress UI */ } + Self.logger.info("fetchAllGuestFiles: client.fetchFile returned filename=\(fetchedFilename, privacy: .public)") + let saveURL = downloadsURL.appendingPathComponent(fetchedFilename) - try data.write(to: saveURL) + if FileManager.default.fileExists(atPath: saveURL.path) { + Self.logger.info("fetchAllGuestFiles: removing existing \(saveURL.path, privacy: .public)") + try FileManager.default.removeItem(at: saveURL) + } + Self.logger.info("fetchAllGuestFiles: moveItem \(tempURL.path, privacy: .public) → \(saveURL.path, privacy: .public)") + try FileManager.default.moveItem(at: tempURL, to: saveURL) Self.applyQuarantine(to: saveURL) if let permissions = permissions { @@ -309,9 +349,10 @@ public final class FileTransferService: ObservableObject { } savedURLs.append(saveURL) - NSLog("[FileTransfer] Fetched: %@", fetchedFilename) + Self.logger.info("fetchAllGuestFiles: fetched \(fetchedFilename, privacy: .public) → \(saveURL.path, privacy: .public)") } catch { - NSLog("[FileTransfer] Failed to fetch %@: %@", guestPath, error.localizedDescription) + Self.logger.error("fetchAllGuestFiles: failed to fetch \(guestPath, privacy: .public): \(error.localizedDescription, privacy: .public)") + try? FileManager.default.removeItem(at: tempURL) failedPaths.append(URL(fileURLWithPath: guestPath).lastPathComponent) } } @@ -327,14 +368,14 @@ public final class FileTransferService: ObservableObject { try await client.clearFileQueue() self.queuedGuestFilePaths = [] self.queuedGuestFileCount = 0 - NSLog("[FileTransfer] Cleared guest file queue") + Self.logger.info("fetchAllGuestFiles: cleared guest file queue") } catch { - NSLog("[FileTransfer] ERROR clearing queue: %@", error.localizedDescription) + Self.logger.error("fetchAllGuestFiles: failed clearing queue: \(error.localizedDescription, privacy: .public)") self.lastError = "Failed to clear queue: \(error.localizedDescription)" } } else { self.lastError = "Failed to fetch: \(failedPaths.joined(separator: ", "))" - NSLog("[FileTransfer] %d file(s) failed, queue NOT cleared", failedPaths.count) + Self.logger.error("fetchAllGuestFiles: \(failedPaths.count) file(s) failed, queue NOT cleared") } } } diff --git a/macOS/GhostVM/Services/GhostClient.swift b/macOS/GhostVM/Services/GhostClient.swift index 2850f8f..e79c94a 100644 --- a/macOS/GhostVM/Services/GhostClient.swift +++ b/macOS/GhostVM/Services/GhostClient.swift @@ -1,11 +1,14 @@ import Foundation import Virtualization import GhostVMKit +import GhostHTTP +import os /// HTTP client for communicating with GhostTools running in the guest VM /// Supports both vsock (production) and TCP (development) connections @MainActor public final class GhostClient: GhostClientProtocol { + private static let logger = Logger(subsystem: "org.ghostvm.ghostvm", category: "GhostClient") private nonisolated(unsafe) let virtualMachine: VZVirtualMachine? private nonisolated(unsafe) let vmQueue: DispatchQueue? private let vsockPort: UInt32 = 5000 @@ -86,14 +89,25 @@ public final class GhostClient: GhostClientProtocol { } } - /// Fetch a file from the guest VM - /// - Parameter path: The file path in the guest to fetch - /// - Returns: The file data and filename - public func fetchFile(at path: String) async throws -> (data: Data, filename: String, permissions: Int?) { - if let tcpHost = tcpHost, let tcpPort = tcpPort { - return try await fetchFileViaTCP(host: tcpHost, port: tcpPort, path: path) - } else if let vm = virtualMachine { - return try await fetchFileViaVsock(vm: vm, path: path) + /// Fetch a file from the guest VM, streaming directly to disk. + /// - Parameters: + /// - path: The file path in the guest to fetch + /// - destinationURL: Local file URL to write to + /// - progress: Called with 0.0–1.0 as bytes are received + /// - Returns: The filename and optional POSIX permissions + public func fetchFile( + at path: String, + to destinationURL: URL, + progress: @escaping (Double) -> Void + ) async throws -> (filename: String, permissions: Int?) { + if let vm = virtualMachine { + return try await fetchFileStreamingViaVsock(vm: vm, path: path, destinationURL: destinationURL, progress: progress) + } else if let tcpHost = tcpHost, let tcpPort = tcpPort { + // TCP fallback: use the old buffered path + let (data, filename, permissions) = try await fetchFileViaTCP(host: tcpHost, port: tcpPort, path: path) + try data.write(to: destinationURL) + progress(1.0) + return (filename, permissions) } else { throw GhostClientError.notConnected } @@ -166,14 +180,14 @@ public final class GhostClient: GhostClientProtocol { } private func openPathViaVsock(vm: VZVirtualMachine, body: Data) async throws { - let responseData = try await sendHTTPRequest( + let response = try await sendHTTPRequest( vm: vm, method: "POST", path: "/api/v1/open", body: body, contentType: "application/json" ) - let (statusCode, _) = try HTTPResponseParser.parse(responseData) + let statusCode = response.head.status.rawValue guard statusCode == 200 else { throw GhostClientError.invalidResponse(statusCode) } @@ -184,8 +198,9 @@ public final class GhostClient: GhostClientProtocol { /// List running GUI apps in the guest public func listApps() async throws -> AppListResponse { if let vm = virtualMachine { - let responseData = try await sendHTTPRequest(vm: vm, method: "GET", path: "/api/v1/apps", body: nil) - let (statusCode, body) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "GET", path: "/api/v1/apps", body: nil) + let statusCode = response.head.status.rawValue + let body = responseBody(response) guard statusCode == 200, let body = body else { throw GhostClientError.invalidResponse(statusCode) } @@ -198,8 +213,8 @@ public final class GhostClient: GhostClientProtocol { public func launchApp(bundleId: String) async throws { let body = try JSONEncoder().encode(["bundleId": bundleId]) if let vm = virtualMachine { - let responseData = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/apps/launch", body: body, contentType: "application/json") - let (statusCode, _) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/apps/launch", body: body, contentType: "application/json") + let statusCode = response.head.status.rawValue guard statusCode == 200 else { throw GhostClientError.invalidResponse(statusCode) } return } @@ -210,8 +225,8 @@ public final class GhostClient: GhostClientProtocol { public func activateApp(bundleId: String) async throws { let body = try JSONEncoder().encode(["bundleId": bundleId]) if let vm = virtualMachine { - let responseData = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/apps/activate", body: body, contentType: "application/json") - let (statusCode, _) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/apps/activate", body: body, contentType: "application/json") + let statusCode = response.head.status.rawValue guard statusCode == 200 else { throw GhostClientError.invalidResponse(statusCode) } return } @@ -222,8 +237,8 @@ public final class GhostClient: GhostClientProtocol { public func quitApp(bundleId: String) async throws { let body = try JSONEncoder().encode(["bundleId": bundleId]) if let vm = virtualMachine { - let responseData = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/apps/quit", body: body, contentType: "application/json") - let (statusCode, _) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/apps/quit", body: body, contentType: "application/json") + let statusCode = response.head.status.rawValue guard statusCode == 200 else { throw GhostClientError.invalidResponse(statusCode) } return } @@ -236,8 +251,9 @@ public final class GhostClient: GhostClientProtocol { public func listDirectory(path: String) async throws -> FSListResponse { let encodedPath = path.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? path if let vm = virtualMachine { - let responseData = try await sendHTTPRequest(vm: vm, method: "GET", path: "/api/v1/fs?path=\(encodedPath)", body: nil) - let (statusCode, body) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "GET", path: "/api/v1/fs?path=\(encodedPath)", body: nil) + let statusCode = response.head.status.rawValue + let body = responseBody(response) guard statusCode == 200, let body = body else { throw GhostClientError.invalidResponse(statusCode) } @@ -250,8 +266,8 @@ public final class GhostClient: GhostClientProtocol { public func mkdir(path: String) async throws { let body = try JSONEncoder().encode(["path": path]) if let vm = virtualMachine { - let responseData = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/fs/mkdir", body: body, contentType: "application/json") - let (statusCode, _) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/fs/mkdir", body: body, contentType: "application/json") + let statusCode = response.head.status.rawValue guard statusCode == 200 else { throw GhostClientError.invalidResponse(statusCode) } return } @@ -262,8 +278,8 @@ public final class GhostClient: GhostClientProtocol { public func deleteFile(path: String) async throws { let body = try JSONEncoder().encode(["path": path]) if let vm = virtualMachine { - let responseData = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/fs/delete", body: body, contentType: "application/json") - let (statusCode, _) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/fs/delete", body: body, contentType: "application/json") + let statusCode = response.head.status.rawValue guard statusCode == 200 else { throw GhostClientError.invalidResponse(statusCode) } return } @@ -274,8 +290,8 @@ public final class GhostClient: GhostClientProtocol { public func moveFile(from: String, to: String) async throws { let body = try JSONEncoder().encode(["from": from, "to": to]) if let vm = virtualMachine { - let responseData = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/fs/move", body: body, contentType: "application/json") - let (statusCode, _) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/fs/move", body: body, contentType: "application/json") + let statusCode = response.head.status.rawValue guard statusCode == 200 else { throw GhostClientError.invalidResponse(statusCode) } return } @@ -300,8 +316,9 @@ public final class GhostClient: GhostClientProtocol { public func getAccessibilityTree(depth: Int = 5, target: AXTarget = .front) async throws -> AXTreeResponse { if let vm = virtualMachine { let path = a11yPath("/api/v1/accessibility", depth: depth, target: target) - let responseData = try await sendHTTPRequest(vm: vm, method: "GET", path: path, body: nil) - let (statusCode, body) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "GET", path: path, body: nil) + let statusCode = response.head.status.rawValue + let body = responseBody(response) guard statusCode == 200, let body = body else { throw guestError(body, statusCode: statusCode) } @@ -314,8 +331,9 @@ public final class GhostClient: GhostClientProtocol { public func getAccessibilityTrees(depth: Int = 5, target: AXTarget = .front) async throws -> [AXTreeResponse] { if let vm = virtualMachine { let path = a11yPath("/api/v1/accessibility", depth: depth, target: target) - let responseData = try await sendHTTPRequest(vm: vm, method: "GET", path: path, body: nil) - let (statusCode, body) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "GET", path: path, body: nil) + let statusCode = response.head.status.rawValue + let body = responseBody(response) guard statusCode == 200, let body = body else { throw guestError(body, statusCode: statusCode) } @@ -351,8 +369,9 @@ public final class GhostClient: GhostClientProtocol { if let vm = virtualMachine { let path = a11yPath("/api/v1/accessibility/action", target: target) - let responseData = try await sendHTTPRequest(vm: vm, method: "POST", path: path, body: body, contentType: "application/json") - let (statusCode, respBody) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "POST", path: path, body: body, contentType: "application/json") + let statusCode = response.head.status.rawValue + let respBody = responseBody(response) guard statusCode == 200 else { throw guestError(respBody, statusCode: statusCode) } return } @@ -367,8 +386,9 @@ public final class GhostClient: GhostClientProtocol { if let vm = virtualMachine { let urlPath = a11yPath("/api/v1/accessibility/menu", target: target) - let responseData = try await sendHTTPRequest(vm: vm, method: "POST", path: urlPath, body: body, contentType: "application/json") - let (statusCode, respBody) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "POST", path: urlPath, body: body, contentType: "application/json") + let statusCode = response.head.status.rawValue + let respBody = responseBody(response) guard statusCode == 200 else { throw guestError(respBody, statusCode: statusCode) } return } @@ -384,8 +404,9 @@ public final class GhostClient: GhostClientProtocol { if let vm = virtualMachine { let path = a11yPath("/api/v1/accessibility/type", target: target) - let responseData = try await sendHTTPRequest(vm: vm, method: "POST", path: path, body: body, contentType: "application/json") - let (statusCode, respBody) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "POST", path: path, body: body, contentType: "application/json") + let statusCode = response.head.status.rawValue + let respBody = responseBody(response) guard statusCode == 200 else { throw guestError(respBody, statusCode: statusCode) } return } @@ -396,8 +417,9 @@ public final class GhostClient: GhostClientProtocol { public func getFocusedElement(target: AXTarget = .front) async throws -> [String: Any] { if let vm = virtualMachine { let path = a11yPath("/api/v1/accessibility/focused", target: target) - let responseData = try await sendHTTPRequest(vm: vm, method: "GET", path: path, body: nil) - let (statusCode, body) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "GET", path: path, body: nil) + let statusCode = response.head.status.rawValue + let body = responseBody(response) guard statusCode == 200, let body = body else { throw GhostClientError.invalidResponse(statusCode) } @@ -424,8 +446,9 @@ public final class GhostClient: GhostClientProtocol { let body = try JSONSerialization.data(withJSONObject: payload) if let vm = virtualMachine { - let responseData = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/pointer", body: body, contentType: "application/json") - let (statusCode, respBody) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/pointer", body: body, contentType: "application/json") + let statusCode = response.head.status.rawValue + let respBody = responseBody(response) guard statusCode == 200 else { if statusCode == 403 { throw GhostClientError.connectionFailed("Accessibility permission required in guest") @@ -450,8 +473,8 @@ public final class GhostClient: GhostClientProtocol { let body = try JSONSerialization.data(withJSONObject: payload) if let vm = virtualMachine { - let responseData = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/input", body: body, contentType: "application/json") - let (statusCode, _) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/input", body: body, contentType: "application/json") + let statusCode = response.head.status.rawValue guard statusCode == 200 else { if statusCode == 403 { throw GhostClientError.connectionFailed("Accessibility permission required in guest") @@ -473,8 +496,9 @@ public final class GhostClient: GhostClientProtocol { let body = try JSONSerialization.data(withJSONObject: payload) if let vm = virtualMachine { - let responseData = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/exec", body: body, contentType: "application/json") - let (statusCode, respBody) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/exec", body: body, contentType: "application/json") + let statusCode = response.head.status.rawValue + let respBody = responseBody(response) guard statusCode == 200, let respBody = respBody else { throw GhostClientError.invalidResponse(statusCode) } @@ -488,8 +512,9 @@ public final class GhostClient: GhostClientProtocol { /// Get interactive elements from guest (shows overlays in guest, returns element JSON) public func getElements() async throws -> Data { if let vm = virtualMachine { - let responseData = try await sendHTTPRequest(vm: vm, method: "GET", path: "/api/v1/elements", body: nil) - let (statusCode, body) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "GET", path: "/api/v1/elements", body: nil) + let statusCode = response.head.status.rawValue + let body = responseBody(response) guard statusCode == 200, let body = body else { throw guestError(body, statusCode: statusCode) } @@ -504,12 +529,13 @@ public final class GhostClient: GhostClientProtocol { public func captureGuestScreenshot(format: String = "png", scale: Double = 1.0) async throws -> (data: Data, contentType: String) { if let vm = virtualMachine { let path = "/vm/screenshot?format=\(format)&scale=\(scale)" - let responseData = try await sendHTTPRequest(vm: vm, method: "GET", path: path, body: nil) - let (statusCode, headers, body) = try HTTPResponseParser.parseBinaryWithHeaders(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "GET", path: path, body: nil) + let statusCode = response.head.status.rawValue + let body = responseBody(response) guard statusCode == 200, let body = body else { throw guestError(body, statusCode: statusCode) } - let contentType = headers["Content-Type"] ?? "application/octet-stream" + let contentType = response.head.headers["Content-Type"] ?? "application/octet-stream" return (body, contentType) } throw GhostClientError.notConnected @@ -519,8 +545,9 @@ public final class GhostClient: GhostClientProtocol { public func captureGuestAnnotatedScreenshot(scale: Double = 0.5) async throws -> Data { if let vm = virtualMachine { let path = "/vm/screenshot/annotated?scale=\(scale)" - let responseData = try await sendHTTPRequest(vm: vm, method: "GET", path: path, body: nil) - let (statusCode, body) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "GET", path: path, body: nil) + let statusCode = response.head.status.rawValue + let body = responseBody(response) guard statusCode == 200, let body = body else { throw guestError(body, statusCode: statusCode) } @@ -533,14 +560,15 @@ public final class GhostClient: GhostClientProtocol { public func executeGuestBatch(_ request: BatchRequest) async throws -> Data { let body = try JSONEncoder().encode(request) if let vm = virtualMachine { - let responseData = try await sendHTTPRequest( + let response = try await sendHTTPRequest( vm: vm, method: "POST", path: "/api/v1/batch", body: body, contentType: "application/json" ) - let (statusCode, respBody) = try HTTPResponseParser.parse(responseData) + let statusCode = response.head.status.rawValue + let respBody = responseBody(response) guard statusCode == 200, let respBody = respBody else { throw guestError(respBody, statusCode: statusCode) } @@ -552,8 +580,8 @@ public final class GhostClient: GhostClientProtocol { /// Show wait indicator overlay in guest public func showWaitIndicator() async throws { if let vm = virtualMachine { - let responseData = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/overlay/wait-show", body: nil) - let (statusCode, _) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/overlay/wait-show", body: nil) + let statusCode = response.head.status.rawValue guard statusCode == 200 else { throw GhostClientError.invalidResponse(statusCode) } return } @@ -563,8 +591,8 @@ public final class GhostClient: GhostClientProtocol { /// Hide wait indicator overlay in guest public func hideWaitIndicator() async throws { if let vm = virtualMachine { - let responseData = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/overlay/wait-hide", body: nil) - let (statusCode, _) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "POST", path: "/api/v1/overlay/wait-hide", body: nil) + let statusCode = response.head.status.rawValue guard statusCode == 200 else { throw GhostClientError.invalidResponse(statusCode) } return } @@ -574,8 +602,9 @@ public final class GhostClient: GhostClientProtocol { /// Get frontmost app bundle ID from guest public func getFrontmostApp() async throws -> String? { if let vm = virtualMachine { - let responseData = try await sendHTTPRequest(vm: vm, method: "GET", path: "/api/v1/apps/frontmost", body: nil) - let (statusCode, body) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: "GET", path: "/api/v1/apps/frontmost", body: nil) + let statusCode = response.head.status.rawValue + let body = responseBody(response) guard statusCode == 200, let body = body else { throw GhostClientError.invalidResponse(statusCode) } @@ -596,8 +625,9 @@ public final class GhostClient: GhostClientProtocol { public func checkPermissions(prompt: Bool = false) async throws -> Data { let method = prompt ? "POST" : "GET" if let vm = virtualMachine { - let responseData = try await sendHTTPRequest(vm: vm, method: method, path: "/api/v1/permissions", body: nil) - let (statusCode, body) = try HTTPResponseParser.parse(responseData) + let response = try await sendHTTPRequest(vm: vm, method: method, path: "/api/v1/permissions", body: nil) + let statusCode = response.head.status.rawValue + let body = responseBody(response) guard statusCode == 200, let body = body else { throw GhostClientError.invalidResponse(statusCode) } @@ -649,31 +679,11 @@ public final class GhostClient: GhostClientProtocol { } private func checkHealthViaVsock(vm: VZVirtualMachine) async -> Bool { - guard let queue = self.vmQueue else { - return false - } - - let port = self.vsockPort + Self.logger.debug("checkHealth: connecting to port \(self.vsockPort)") - // ALL VZVirtualMachine access must happen on vmQueue per Apple's requirements let connection: VZVirtioSocketConnection do { - connection = try await withCheckedThrowingContinuation { continuation in - queue.async { - guard let socketDevice = vm.socketDevices.first as? VZVirtioSocketDevice else { - continuation.resume(throwing: GhostClientError.connectionFailed("No socket device")) - return - } - socketDevice.connect(toPort: port) { result in - switch result { - case .success(let conn): - continuation.resume(returning: conn) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } + connection = try await connectRaw(port: vsockPort) } catch { return false } @@ -682,22 +692,20 @@ public final class GhostClient: GhostClientProtocol { let fd = connection.fileDescriptor let result: Bool = await withCheckedContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { - // Send HTTP health check using HTTPUtilities - let requestData = HTTPUtilities.buildRequest(method: "GET", path: "/health") - requestData.withUnsafeBytes { ptr in - _ = Darwin.write(fd, ptr.baseAddress!, requestData.count) - } - Darwin.shutdown(fd, SHUT_WR) - - // Read response - var buffer = [CChar](repeating: 0, count: 1024) - let bytesRead = Darwin.read(fd, &buffer, buffer.count - 1) - connection.close() - - if bytesRead > 0 { - let response = String(cString: buffer) - continuation.resume(returning: response.contains("200")) - } else { + do { + let response = try HTTPClient.performRequest( + fd: fd, + method: "GET", + path: "/health", + shutdownWrite: false + ) + connection.close() + let ok = response.head.status == .ok + Self.logger.info("checkHealth: status \(response.head.status.rawValue), ok=\(ok)") + continuation.resume(returning: ok) + } catch { + connection.close() + Self.logger.warning("checkHealth: request failed: \(error.localizedDescription, privacy: .public)") continuation.resume(returning: false) } } @@ -748,6 +756,30 @@ public final class GhostClient: GhostClientProtocol { } } + // VZVirtioSocketConnection may hand back a non-blocking fd. + // Callers use blocking read()/poll(), so ensure blocking mode. + let fd = connection.fileDescriptor + let flags = fcntl(fd, F_GETFL, 0) + guard flags >= 0 else { + NSLog("connectRaw: fcntl(F_GETFL) failed on fd=%d errno=%d", fd, errno) + throw GhostClientError.connectionFailed("Failed to get fd flags") + } + if (flags & O_NONBLOCK) != 0 { + guard fcntl(fd, F_SETFL, flags & ~O_NONBLOCK) >= 0 else { + NSLog("connectRaw: fcntl(F_SETFL) failed to clear O_NONBLOCK on fd=%d errno=%d", fd, errno) + throw GhostClientError.connectionFailed("Failed to set blocking mode") + } + } + + var timeout = timeval(tv_sec: 30, tv_usec: 0) + let timeoutLen = socklen_t(MemoryLayout.size) + if setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, timeoutLen) != 0 { + NSLog("connectRaw: failed to set SO_RCVTIMEO on fd=%d errno=%d", fd, errno) + } + if setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, timeoutLen) != 0 { + NSLog("connectRaw: failed to set SO_SNDTIMEO on fd=%d errno=%d", fd, errno) + } + return connection } @@ -968,14 +1000,14 @@ public final class GhostClient: GhostClientProtocol { // MARK: - Vsock Implementation (Production) private func getClipboardViaVsock(vm: VZVirtualMachine) async throws -> ClipboardGetResponse { - let responseData = try await sendHTTPRequest( + let response = try await sendHTTPRequest( vm: vm, method: "GET", path: "/api/v1/clipboard", body: nil ) - - let (statusCode, headers, body) = try HTTPResponseParser.parseBinaryWithHeaders(responseData) + let statusCode = response.head.status.rawValue + let body = responseBody(response) if statusCode == 204 { throw GhostClientError.noContent @@ -989,12 +1021,12 @@ public final class GhostClient: GhostClientProtocol { throw GhostClientError.noContent } - let clipType = headers["X-Clipboard-Type"] ?? "public.utf8-plain-text" + let clipType = response.head.headers["X-Clipboard-Type"] ?? "public.utf8-plain-text" return ClipboardGetResponse(data: body, type: clipType) } private func setClipboardViaVsock(vm: VZVirtualMachine, data: Data, type: String) async throws { - let responseData = try await sendHTTPRequest( + let response = try await sendHTTPRequest( vm: vm, method: "POST", path: "/api/v1/clipboard", @@ -1002,8 +1034,7 @@ public final class GhostClient: GhostClientProtocol { contentType: "application/octet-stream", extraHeaders: ["X-Clipboard-Type": type] ) - - let (statusCode, _) = try HTTPResponseParser.parse(responseData) + let statusCode = response.head.status.rawValue guard statusCode == 200 else { throw GhostClientError.invalidResponse(statusCode) @@ -1023,36 +1054,18 @@ public final class GhostClient: GhostClientProtocol { } defer { try? fileHandle.close() } - guard let queue = self.vmQueue else { - throw GhostClientError.connectionFailed("VM queue not available") - } - - // Connect - ALL VZVirtualMachine access must happen on vmQueue per Apple's requirements - let port = self.vsockPort - let connection: VZVirtioSocketConnection = try await withCheckedThrowingContinuation { continuation in - queue.async { - guard let socketDevice = vm.socketDevices.first as? VZVirtioSocketDevice else { - continuation.resume(throwing: GhostClientError.connectionFailed("No socket device available")) - return - } - socketDevice.connect(toPort: port) { result in - switch result { - case .success(let conn): - continuation.resume(returning: conn) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - + let connection = try await connectRaw(port: vsockPort) let fd = connection.fileDescriptor // Build HTTP headers using HTTPUtilities - use relativePath to preserve folder structure - var headers: [String: String] = [ + var headers = HTTPHeaders([ "Content-Type": "application/octet-stream", "X-Filename": relativePath - ] + ]) + + if let token = authToken { + headers["Authorization"] = "Bearer \(token)" + } if let batchID = batchID { headers["X-Batch-ID"] = batchID @@ -1063,25 +1076,19 @@ public final class GhostClient: GhostClientProtocol { if let permissions = permissions { headers["X-Permissions"] = String(permissions, radix: 8) } + headers["Content-Length"] = "\(fileSize)" - // Note: We manually construct this since HTTPUtilities.buildRequest includes Content-Length - // but we need to stream the body separately in chunks below - var httpHeaders = "POST /api/v1/files/receive HTTP/1.1\r\n" - httpHeaders += "Host: localhost\r\n" - httpHeaders += "Connection: close\r\n" - for (key, value) in headers { - httpHeaders += "\(key): \(value)\r\n" - } - httpHeaders += "Content-Length: \(fileSize)\r\n" - httpHeaders += "\r\n" - - let headerData = Data(httpHeaders.utf8) - let headerWritten = headerData.withUnsafeBytes { ptr in - Darwin.write(fd, ptr.baseAddress!, headerData.count) - } - if headerWritten < 0 { + let headerData = HTTPCodec.requestData( + method: "POST", + path: "/api/v1/files/receive", + headers: headers, + body: nil + ) + do { + try HTTPCodec.writeAll(fd: fd, data: headerData) + } catch { connection.close() - throw GhostClientError.connectionFailed("Failed to write headers") + throw error } // Stream file in chunks @@ -1094,16 +1101,11 @@ public final class GhostClient: GhostClientProtocol { let chunk = fileHandle.readData(ofLength: chunkSize) if chunk.isEmpty { break } - var offset = 0 - while offset < chunk.count { - let written = chunk.withUnsafeBytes { ptr in - Darwin.write(fd, ptr.baseAddress! + offset, chunk.count - offset) - } - if written < 0 { - connection.close() - throw GhostClientError.connectionFailed("Write failed: errno \(errno)") - } - offset += written + do { + try HTTPCodec.writeAll(fd: fd, data: chunk) + } catch { + connection.close() + throw error } bytesSent += Int64(chunk.count) @@ -1114,72 +1116,137 @@ public final class GhostClient: GhostClientProtocol { // Signal end of request Darwin.shutdown(fd, SHUT_WR) - // Read response - var responseData = Data() - var buffer = [UInt8](repeating: 0, count: 4096) - while true { - let bytesRead = Darwin.read(fd, &buffer, buffer.count) - if bytesRead <= 0 { break } - responseData.append(contentsOf: buffer[0.. (data: Data, filename: String, permissions: Int?) { + /// Stream a file from guest to a local file, reporting progress. + private func fetchFileStreamingViaVsock( + vm: VZVirtualMachine, + path: String, + destinationURL: URL, + progress: @escaping (Double) -> Void + ) async throws -> (filename: String, permissions: Int?) { let encodedPath = path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? path - let responseData = try await sendHTTPRequest( - vm: vm, - method: "GET", - path: "/api/v1/files/\(encodedPath)", - body: nil - ) - - let (statusCode, headers, body) = try HTTPResponseParser.parseBinaryWithHeaders(responseData) + let connection = try await connectRaw(port: vsockPort) + let fd = connection.fileDescriptor - guard statusCode == 200 else { - throw GhostClientError.invalidResponse(statusCode) + // Build and send GET request + var headers = HTTPHeaders() + if let token = authToken { + headers["Authorization"] = "Bearer \(token)" } - guard let body = body else { - throw GhostClientError.noContent - } + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { [self] in + do { + try HTTPCodec.writeRequest( + fd: fd, + method: "GET", + path: "/api/v1/files/\(encodedPath)", + headers: headers, + body: nil + ) + // No SHUT_WR — the server knows the request is complete from + // the HTTP framing. Shutting down the write side can cause the + // vsock peer to tear down before the full response is sent. + + let (responseHead, bodyReader) = try HTTPClient.readResponseHead(fd: fd) + guard responseHead.status == .ok else { + connection.close() + throw GhostClientError.invalidResponse(responseHead.status.rawValue) + } - let filename = URL(fileURLWithPath: path).lastPathComponent - var permissions: Int? = nil - if let permStr = headers["X-Permissions"] { - permissions = Int(permStr, radix: 8) + let contentLength = responseHead.contentLength ?? 0 + let filename: String + if let disposition = responseHead.header("Content-Disposition"), + let range = disposition.range(of: "filename=\""), + let endQuote = disposition[range.upperBound...].firstIndex(of: "\"") { + filename = String(disposition[range.upperBound.. 0 { + progress(Double(bytesReceived) / Double(contentLength)) + } + } + } + if contentLength > 0 && bytesReceived != contentLength { + Self.logger.error("fetchFile: EOF after \(bytesReceived)/\(contentLength) bytes for \(path, privacy: .public)") + readError = GhostClientError.connectionFailed( + "Server closed connection after \(bytesReceived)/\(contentLength) bytes" + ) + } + + fileHandle.closeFile() + connection.close() + + if let readError = readError { + Self.logger.error("fetchFile: throwing readError for \(path, privacy: .public)") + continuation.resume(throwing: readError) + } else { + Self.logger.info("fetchFile: complete \(contentLength) bytes for \(path, privacy: .public)") + continuation.resume(returning: (filename, permissions)) + } + } catch { + connection.close() + continuation.resume(throwing: error) + } + } } - return (body, filename, permissions) } private func listFilesViaVsock(vm: VZVirtualMachine) async throws -> [String] { - let responseData = try await sendHTTPRequest( + let response = try await sendHTTPRequest( vm: vm, method: "GET", path: "/api/v1/files", body: nil ) - - let (statusCode, body) = try HTTPResponseParser.parse(responseData) + let statusCode = response.head.status.rawValue + let body = responseBody(response) guard statusCode == 200 else { throw GhostClientError.invalidResponse(statusCode) @@ -1195,14 +1262,13 @@ public final class GhostClient: GhostClientProtocol { } private func clearFileQueueViaVsock(vm: VZVirtualMachine) async throws { - let responseData = try await sendHTTPRequest( + let response = try await sendHTTPRequest( vm: vm, method: "DELETE", path: "/api/v1/files", body: nil ) - - let (statusCode, _) = try HTTPResponseParser.parse(responseData) + let statusCode = response.head.status.rawValue guard statusCode == 200 else { throw GhostClientError.invalidResponse(statusCode) @@ -1210,14 +1276,14 @@ public final class GhostClient: GhostClientProtocol { } private func fetchURLsViaVsock(vm: VZVirtualMachine) async throws -> [String] { - let responseData = try await sendHTTPRequest( + let response = try await sendHTTPRequest( vm: vm, method: "GET", path: "/api/v1/urls", body: nil ) - - let (statusCode, body) = try HTTPResponseParser.parse(responseData) + let statusCode = response.head.status.rawValue + let body = responseBody(response) guard statusCode == 200 else { throw GhostClientError.invalidResponse(statusCode) @@ -1233,14 +1299,14 @@ public final class GhostClient: GhostClientProtocol { } private func fetchLogsViaVsock(vm: VZVirtualMachine) async throws -> [String] { - let responseData = try await sendHTTPRequest( + let response = try await sendHTTPRequest( vm: vm, method: "GET", path: "/api/v1/logs", body: nil ) - - let (statusCode, body) = try HTTPResponseParser.parse(responseData) + let statusCode = response.head.status.rawValue + let body = responseBody(response) guard statusCode == 200 else { throw GhostClientError.invalidResponse(statusCode) @@ -1262,34 +1328,12 @@ public final class GhostClient: GhostClientProtocol { body: Data?, contentType: String? = nil, extraHeaders: [String: String]? = nil - ) async throws -> Data { - // Ensure we have the VM queue - guard let queue = self.vmQueue else { - throw GhostClientError.connectionFailed("VM queue not available") - } + ) async throws -> HTTPBufferedResponse { + Self.logger.info("sendHTTPRequest: \(method, privacy: .public) \(path, privacy: .public)") - // Connect to the guest on the vsock port - // ALL VZVirtualMachine access must happen on vmQueue per Apple's requirements - let port = self.vsockPort - let connection: VZVirtioSocketConnection = try await withCheckedThrowingContinuation { continuation in - queue.async { - guard let socketDevice = vm.socketDevices.first as? VZVirtioSocketDevice else { - continuation.resume(throwing: GhostClientError.connectionFailed("No socket device available")) - return - } - socketDevice.connect(toPort: port) { result in - switch result { - case .success(let conn): - continuation.resume(returning: conn) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } + let connection = try await connectRaw(port: vsockPort) - // Build HTTP request using HTTPUtilities - var headers: [String: String] = [:] + var headers = HTTPHeaders() if let token = authToken { headers["Authorization"] = "Bearer \(token)" @@ -1300,54 +1344,33 @@ public final class GhostClient: GhostClientProtocol { } if let extraHeaders = extraHeaders { - headers.merge(extraHeaders) { _, new in new } + for (key, value) in extraHeaders { + headers[key] = value + } } - let requestData = HTTPUtilities.buildRequest( - method: method, - path: path, - headers: headers, - body: body - ) - // Get file descriptor let fd = connection.fileDescriptor // Do all blocking I/O on a background queue - NOT on main actor! - let responseData: Data = try await withCheckedThrowingContinuation { continuation in + let responseData: HTTPBufferedResponse = try await withCheckedThrowingContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { - // Send request in chunks to handle large files - let chunkSize = 65536 // 64KB chunks - var offset = 0 - while offset < requestData.count { - let end = min(offset + chunkSize, requestData.count) - let chunk = requestData[offset.. Data { - var result = Data() - while true { - let chunk = handle.availableData - if chunk.isEmpty { - break - } - result.append(chunk) - } - return result + private func responseBody(_ response: HTTPBufferedResponse) -> Data? { + response.body.isEmpty ? nil : response.body } private func withTimeout(seconds: Double, operation: @escaping () -> T) async throws -> T { diff --git a/macOS/GhostVM/Services/HealthCheckService.swift b/macOS/GhostVM/Services/HealthCheckService.swift index b1993d3..93f0012 100644 --- a/macOS/GhostVM/Services/HealthCheckService.swift +++ b/macOS/GhostVM/Services/HealthCheckService.swift @@ -3,9 +3,8 @@ import Combine import Virtualization import GhostVMKit -/// Persistent health check via vsock port 5002. -/// Connects once, reads version line, then blocks on read until EOF. -/// Connection close = unhealthy. Reconnects after a delay. +/// Periodic health check via the unified HTTP server on vsock port 5000. +/// Polls `/health` and treats success as healthy. @MainActor public final class HealthCheckService: ObservableObject { @Published public private(set) var status: GuestToolsStatus = .connecting @@ -14,7 +13,6 @@ public final class HealthCheckService: ObservableObject { private var client: GhostClient? private var task: Task? - private let port: UInt32 = 5002 private var notFoundDeadline: Date? private var deadlineTask: Task? @@ -59,67 +57,21 @@ public final class HealthCheckService: ObservableObject { while !Task.isCancelled { guard let client = client else { break } - NSLog("HealthCheck: connecting to port %u...", port) - - do { - // IMPORTANT: Hold `connection` alive — dropping it closes the fd. - let connection = try await client.connectRaw(port: port) - let fd = connection.fileDescriptor - - NSLog("HealthCheck: connected, reading version (fd=%d)...", fd) - - // Read version line on a background queue - let versionOK: Bool = await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .utility).async { - var buffer = [UInt8](repeating: 0, count: 512) - let n = Darwin.read(fd, &buffer, buffer.count - 1) - NSLog("HealthCheck: version read n=%d", n) - continuation.resume(returning: n > 0) - } - } - - if versionOK { - NSLog("HealthCheck: CONNECTED") - deadlineTask?.cancel() - deadlineTask = nil - notFoundDeadline = nil - status = .connected - - // Block on read until EOF (guest disconnects or dies). - // `connection` is captured by the closure to keep it alive. - await withCheckedContinuation { (continuation: CheckedContinuation) in - DispatchQueue.global(qos: .utility).async { - var buf = [UInt8](repeating: 0, count: 1) - while true { - var pfd = pollfd(fd: fd, events: Int16(POLLIN | POLLHUP | POLLERR), revents: 0) - let ret = poll(&pfd, 1, 5000) // 5-second timeout - if ret > 0 { - if pfd.revents & Int16(POLLHUP | POLLERR) != 0 { - NSLog("HealthCheck: connection hangup/error detected via poll") - break - } - let n = Darwin.read(fd, &buf, 1) - if n <= 0 { break } - } else if ret < 0 { - break // poll error - } - // ret == 0: timeout, loop back and poll again - } - NSLog("HealthCheck: connection lost (EOF)") - connection.close() - continuation.resume() - } - } - - // Connection lost — restart deadline + NSLog("HealthCheck: polling unified HTTP health endpoint...") + + let isHealthy = await client.checkHealth() + if isHealthy { + NSLog("HealthCheck: CONNECTED") + deadlineTask?.cancel() + deadlineTask = nil + notFoundDeadline = nil + status = .connected + } else { + NSLog("HealthCheck: request failed") + if status == .connected { status = .connecting startNotFoundDeadline() - } else { - NSLog("HealthCheck: version read failed, closing") - connection.close() } - } catch { - NSLog("HealthCheck: connection failed: %@", error.localizedDescription) } // Update status based on deadline (if deadline task already fired) diff --git a/macOS/GhostVM/Services/PortForwardListener.swift b/macOS/GhostVM/Services/PortForwardListener.swift index 27057d5..4480b67 100644 --- a/macOS/GhostVM/Services/PortForwardListener.swift +++ b/macOS/GhostVM/Services/PortForwardListener.swift @@ -1,4 +1,5 @@ import Foundation +import GhostHTTP import GhostVMKit import Virtualization import os @@ -30,10 +31,9 @@ enum PortForwardError: Error, LocalizedError { /// Listens on a host TCP port and forwards connections to the guest VM via vsock. /// /// Each incoming TCP connection triggers: -/// 1. Vsock connection to guest on port 5001 -/// 2. Send "CONNECT \r\n" -/// 3. Wait for "OK\r\n" -/// 4. Bridge TCP <-> vsock bidirectionally +/// 1. Vsock connection to guest on port 5000 +/// 2. HTTP upgrade request to `/api/v1/tunnel-connect` +/// 3. Raw byte bridge after `101 Switching Protocols` final class PortForwardListener: @unchecked Sendable { private static let logger = Logger(subsystem: "org.ghostvm.ghostvm", category: "PortForwardListener") private let hostPort: UInt16 @@ -48,8 +48,8 @@ final class PortForwardListener: @unchecked Sendable { private var activeConnections = 0 private let connectionLock = NSLock() - /// The vsock port where TunnelServer listens in the guest - private let tunnelServerPort: UInt32 = 5001 + /// The unified guest HTTP server port. + private let tunnelServerPort: UInt32 = 5000 init( hostPort: UInt16, @@ -243,55 +243,44 @@ final class PortForwardListener: @unchecked Sendable { let tcpIO = AsyncVSockIO(fd: tcpFd, ownsFD: false) let vsockIO = BlockingVSockChannel(fd: vsockFd, ownsFD: false) - // Send CONNECT command - Self.logger.debug("Sending CONNECT command id=\(connectionID, privacy: .public) guestPort=\(self.guestPort)") do { - try await vsockIO.writeAll(Data("CONNECT \(guestPort)\r\n".utf8)) - Self.logger.debug("CONNECT command write completed id=\(connectionID, privacy: .public)") - } catch { - reportOperationalError( - phase: .handshakeWrite, - message: describe(error: error), - error: error, - connectionID: connectionID - ) - return - } - - let responseData: Data - do { - responseData = try await readConnectResponse(vsockIO) - Self.logger.debug("CONNECT response bytes read id=\(connectionID, privacy: .public) bytes=\(responseData.count)") - } catch let error as ConnectReadError { - switch error { - case .timeout: - reportOperationalError( - phase: .handshakeRead, - message: "Timeout waiting for CONNECT response", - error: error, - connectionID: connectionID - ) - return - case .eof: - reportOperationalError( - phase: .handshakeRead, - message: "Failed to read CONNECT response: bytesRead=0 EOF", - error: error, - connectionID: connectionID - ) - return - case .transport(let ioError): - reportOperationalError( - phase: .handshakeRead, - message: describe(error: ioError), - error: ioError, - connectionID: connectionID - ) - return + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + DispatchQueue.global(qos: .userInitiated).async { + do { + let flags = fcntl(vsockFd, F_GETFL, 0) + if flags >= 0 && (flags & O_NONBLOCK) != 0 { + _ = fcntl(vsockFd, F_SETFL, flags & ~O_NONBLOCK) + } + let upgraded = try HTTPClient.performUpgradeRequest( + fd: vsockFd, + path: "/api/v1/tunnel-connect", + headers: [ + "Host": "localhost", + "Upgrade": "tunnel", + "Connection": "Upgrade", + "Tunnel-Port": "\(self.guestPort)", + ] + ) + guard upgraded.responseHead.status == .switchingProtocols else { + throw PortForwardError.protocolError("Guest refused tunnel upgrade with status \(upgraded.responseHead.status.rawValue)") + } + try self.writePrelude(upgraded.prelude, to: tcpFd) + continuation.resume(returning: ()) + } catch { + continuation.resume(throwing: error) + } + } } + Self.logger.debug("Tunnel upgrade completed id=\(connectionID, privacy: .public) guestPort=\(self.guestPort)") } catch { + let phase: PortForwardRuntimeError.Phase + if error is PortForwardError { + phase = .handshakeProtocol + } else { + phase = .handshakeRead + } reportOperationalError( - phase: .handshakeRead, + phase: phase, message: describe(error: error), error: error, connectionID: connectionID @@ -299,27 +288,6 @@ final class PortForwardListener: @unchecked Sendable { return } - guard let response = String(data: responseData, encoding: .utf8) else { - reportOperationalError( - phase: .handshakeProtocol, - message: "Invalid CONNECT response encoding", - connectionID: connectionID - ) - return - } - - let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines) - Self.logger.debug("CONNECT response id=\(connectionID, privacy: .public): \(trimmed, privacy: .public)") - - if trimmed != "OK" { - reportOperationalError( - phase: .handshakeProtocol, - message: "Guest refused connection: '\(trimmed)'", - connectionID: connectionID - ) - return - } - Self.logger.info("Starting bridge id=\(connectionID, privacy: .public) hostPort=\(self.hostPort) guestPort=\(self.guestPort)") await bridgeConnections(tcpIO: tcpIO, vsockIO: vsockIO, connection: conn, connectionID: connectionID) Self.logger.debug("Bridge returned id=\(connectionID, privacy: .public)") @@ -354,8 +322,13 @@ final class PortForwardListener: @unchecked Sendable { } let described = describe(error: error) - Self.logger.fault("Unexpected bridge error id=\(connectionID, privacy: .public) hostPort=\(self.hostPort) guestPort=\(self.guestPort): \(described, privacy: .public)") - fatalError("[PortForwardListener] Bridge failed unexpectedly: \(described)") + Self.logger.error("Unexpected bridge error id=\(connectionID, privacy: .public) hostPort=\(self.hostPort) guestPort=\(self.guestPort): \(described, privacy: .public)") + reportOperationalError( + phase: .bridge, + message: described, + error: error, + connectionID: connectionID + ) } } @@ -382,40 +355,6 @@ final class PortForwardListener: @unchecked Sendable { } } - private enum ConnectReadError: Error { - case timeout - case eof - case transport(AsyncVSockIOError) - } - - private func readConnectResponse(_ io: some SocketChannel) async throws -> Data { - try await withThrowingTaskGroup(of: Data.self) { group in - group.addTask { - do { - guard let data = try await io.read(maxBytes: 255), !data.isEmpty else { - throw ConnectReadError.eof - } - return data - } catch let error as AsyncVSockIOError { - throw ConnectReadError.transport(error) - } - } - group.addTask { - try await Task.sleep(nanoseconds: 5_000_000_000) - throw ConnectReadError.timeout - } - - do { - let first = try await group.next()! - group.cancelAll() - return first - } catch { - group.cancelAll() - throw error - } - } - } - private func describe(error: Error) -> String { if let ioError = error as? AsyncVSockIOError { switch ioError { @@ -433,6 +372,9 @@ final class PortForwardListener: @unchecked Sendable { return "cancelled" } } + if let portError = error as? PortForwardError { + return portError.localizedDescription + } return String(describing: error) } @@ -470,4 +412,27 @@ final class PortForwardListener: @unchecked Sendable { } onOperationalError(runtimeError) } + + private func writePrelude(_ data: Data, to fd: Int32) throws { + guard !data.isEmpty else { return } + + var offset = 0 + while offset < data.count { + let written = data.withUnsafeBytes { ptr in + Darwin.write(fd, ptr.baseAddress! + offset, data.count - offset) + } + if written > 0 { + offset += written + continue + } + if written == 0 { + throw PortForwardError.connectFailed("short write while replaying tunnel prelude") + } + let err = errno + if err == EINTR { + continue + } + throw PortForwardError.connectFailed("failed to replay tunnel prelude: errno \(err)") + } + } } diff --git a/macOS/GhostVM/SwiftUIDemoApp.swift b/macOS/GhostVM/SwiftUIDemoApp.swift index 46237b1..5e70068 100644 --- a/macOS/GhostVM/SwiftUIDemoApp.swift +++ b/macOS/GhostVM/SwiftUIDemoApp.swift @@ -736,6 +736,7 @@ struct CreateVMDemoView: View { @State private var cpuCount: String = "4" @State private var memoryGiB: String = "8" @State private var diskGiB: String = "256" + @State private var diskImageFormat: DiskImageFormat = .defaultForCurrentHost @State private var sharedFolders: [SharedFolderConfig] = [] @State private var networkConfig: NetworkConfig = NetworkConfig.defaultConfig @State private var restoreItems: [RestoreItem] = [] @@ -791,10 +792,22 @@ struct CreateVMDemoView: View { .foregroundStyle(.secondary) } } + + labeledRow("Disk Format") { + Picker("Disk Format", selection: $diskImageFormat) { + ForEach(availableDiskImageFormats, id: \.self) { format in + Text(format.displayName).tag(format) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(maxWidth: 260, alignment: .leading) + .accessibilityIdentifier("createVM.diskFormatPicker") + } HStack(alignment: .top, spacing: 12) { Color.clear.frame(width: labelWidth) Label( - "APFS sparse file: Finder may show the full 256 GiB logical size, but physical usage only grows as blocks are written. Resize after install is not supported.", + diskImageFormat.diskFormatHelp, systemImage: "exclamationmark.triangle.fill" ) .font(.caption) @@ -843,6 +856,11 @@ struct CreateVMDemoView: View { .padding(EdgeInsets(top: 18, leading: 24, bottom: 18, trailing: 24)) .frame(minWidth: 520) .onAppear(perform: reloadRestoreItems) + .onAppear { + if !diskImageFormat.isSupportedForCreation { + diskImageFormat = .defaultForCurrentHost + } + } .onChange(of: restoreStore.images) { reloadRestoreItems() } @@ -862,6 +880,10 @@ struct CreateVMDemoView: View { return selectedRestorePath != nil } + private var availableDiskImageFormats: [DiskImageFormat] { + DiskImageFormat.allCases.filter(\.isSupportedForCreation) + } + @ViewBuilder private var restorePicker: some View { Picker("Restore Image", selection: restoreSelectionBinding) { @@ -875,7 +897,7 @@ struct CreateVMDemoView: View { .tag("__manage_restore__") } .labelsHidden() - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, alignment: .leading) .onChange(of: restoreSelectionBinding.wrappedValue) { oldValue, newValue in if newValue == "__manage_restore__" { DispatchQueue.main.async { @@ -983,6 +1005,7 @@ struct CreateVMDemoView: View { opts.cpus = Int(cpuCount) ?? 4 opts.memoryGiB = UInt64(memoryGiB) ?? 8 opts.diskGiB = UInt64(diskGiB) ?? 256 + opts.diskImageFormat = diskImageFormat opts.restoreImagePath = restorePath opts.sharedFolders = validFolders opts.networkConfig = networkConfig @@ -1010,6 +1033,26 @@ struct CreateVMDemoView: View { } +private extension DiskImageFormat { + var displayName: String { + switch self { + case .asif: + return "ASIF" + case .sparseFile: + return "Sparse File" + } + } + + var diskFormatHelp: String { + switch self { + case .asif: + return "ASIF is the default on macOS 26+ and provides near-native SSD performance. VMs using ASIF require macOS 26 or later." + case .sparseFile: + return "Sparse file disks work on macOS 15+. Finder may show the full logical size, but physical usage only grows as blocks are written." + } + } +} + @available(macOS 13.0, *) struct VMRowView: View { let vm: App2VM @@ -2025,6 +2068,7 @@ struct VMContextMenu: View { let isSuspended = lowerStatus.contains("suspended") let canSuspend = lowerStatus.contains("running") let canShutDown = lowerStatus.contains("running") + let diskFormat = DiskFormat.detect(at: VMFileLayout(bundleURL: vm.bundleURL).diskURL) // Show Install for macOS VMs that need installation if vm.needsInstall { @@ -2072,7 +2116,14 @@ struct VMContextMenu: View { Button("Clone…") { requestClone() } - .disabled(isRunning || !vm.installed) + .disabled(!vm.installed) + + if #available(macOS 26.0, *), diskFormat == .raw { + Button("Upgrade Disk to ASIF…") { + App2VMSessionRegistry.shared.migrateToASIF(bundleURL: vm.bundleURL, store: store, vmID: vm.id) + } + .disabled(isRunning) + } Divider() diff --git a/macOS/GhostVM/entitlements-debug.plist b/macOS/GhostVM/entitlements-debug.plist new file mode 100644 index 0000000..9971921 --- /dev/null +++ b/macOS/GhostVM/entitlements-debug.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.virtualization + + com.apple.security.network.client + + + diff --git a/macOS/GhostVM/vmctl/CLI.swift b/macOS/GhostVM/vmctl/CLI.swift index 023acc7..118689a 100644 --- a/macOS/GhostVM/vmctl/CLI.swift +++ b/macOS/GhostVM/vmctl/CLI.swift @@ -45,6 +45,12 @@ struct CLI { } catch { fail(error) } + case "suspend": + do { + try handleSuspend(arguments: Array(arguments.dropFirst())) + } catch { + fail(error) + } case "status": do { try handleStatus(arguments: Array(arguments.dropFirst())) @@ -69,12 +75,30 @@ struct CLI { } catch { fail(error) } + case "clone": + do { + try handleClone(arguments: Array(arguments.dropFirst())) + } catch { + fail(error) + } case "remote": do { try RemoteCommand.run(arguments: Array(arguments.dropFirst())) } catch { fail(error) } + case "shell": + do { + try ShellCommand.run(arguments: Array(arguments.dropFirst())) + } catch { + fail(error) + } + case "vsock": + do { + try VsockCommand.run(arguments: Array(arguments.dropFirst())) + } catch { + fail(error) + } case "socket": do { try handleSocket(arguments: Array(arguments.dropFirst())) @@ -164,6 +188,16 @@ struct CLI { throw VMError.message("Missing value for --disk.") } opts.diskGiB = try parseBytes(from: arguments[index], defaultUnit: 1 << 30) >> 30 + case "--disk-format": + index += 1 + guard index < arguments.count else { + throw VMError.message("Missing value for --disk-format.") + } + guard let format = DiskImageFormat(rawValue: arguments[index].lowercased()), + format == .sparseFile || format == .asif else { + throw VMError.message("Invalid value for --disk-format. Use 'sparse-file' or 'asif'.") + } + opts.diskImageFormat = format case "--restore-image": index += 1 guard index < arguments.count else { @@ -249,6 +283,23 @@ struct CLI { try controller.stopVM(bundleURL: bundleURL) } + private func handleSuspend(arguments: [String]) throws { + guard let bundleArg = arguments.first else { + throw VMError.message("Usage: vmctl suspend ") + } + let bundleURL = try resolveBundleURL(argument: bundleArg, mustExist: true) + try controller.suspendVM(bundleURL: bundleURL) + } + + private func handleClone(arguments: [String]) throws { + guard arguments.count >= 2 else { + throw VMError.message("Usage: vmctl clone ") + } + let bundleURL = try resolveBundleURL(argument: arguments[0], mustExist: true) + let newURL = try controller.cloneVM(bundleURL: bundleURL, newName: arguments[1]) + print("Cloned '\(controller.displayName(for: bundleURL))' to '\(newURL.path)' (APFS copy-on-write).") + } + private func handleStatus(arguments: [String]) throws { guard let bundleArg = arguments.first else { throw VMError.message("Usage: vmctl status ") @@ -453,15 +504,18 @@ Usage: vmctl [options] Commands: list List VMs and their state socket Get socket path for running VM - init [--cpus N] [--memory GiB] [--disk GiB] [--restore-image PATH] [--shared-folder PATH] [--writable] + init [--cpus N] [--memory GiB] [--disk GiB] [--disk-format sparse-file|asif] [--restore-image PATH] [--shared-folder PATH] [--writable] install start [--headless] [--shared-folder PATH] [--writable|--read-only] stop + suspend status resume [--headless] [--shared-folder PATH] [--writable|--read-only] discard-suspend + clone Clone a VM (APFS copy-on-write, instant) snapshot list snapshot + shell --name [--command /bin/bash] Interactive terminal on guest remote --name [--json] [args...] remote --socket [--json] [args...] @@ -482,9 +536,11 @@ Examples: vmctl start ~/VMs/sandbox.GhostVM --headless # headless (SSH after setup) vmctl start ~/VMs/sandbox.GhostVM --shared-folder ~/Projects --writable vmctl stop ~/VMs/sandbox.GhostVM + vmctl suspend ~/VMs/sandbox.GhostVM # Save state and suspend vmctl status ~/VMs/sandbox.GhostVM vmctl resume ~/VMs/sandbox.GhostVM # Resume from suspended state vmctl discard-suspend ~/VMs/sandbox.GhostVM # Discard suspended state + vmctl clone ~/VMs/sandbox.GhostVM sandbox-clone # Clone VM (instant, APFS COW) vmctl snapshot ~/VMs/sandbox.GhostVM list vmctl snapshot ~/VMs/sandbox.GhostVM create clean vmctl snapshot ~/VMs/sandbox.GhostVM revert clean @@ -500,7 +556,6 @@ Examples: Notes: - After installation, enable Remote Login (SSH) inside the guest for convenient headless access. - Apple's EULA requires macOS guests to run on Apple-branded hardware. - - Use Virtual Machine > Suspend menu (Cmd+S) to suspend a running VM. """ print(help) exit(exitCode) diff --git a/macOS/GhostVM/vmctl/ShellCommand.swift b/macOS/GhostVM/vmctl/ShellCommand.swift new file mode 100644 index 0000000..96cf621 --- /dev/null +++ b/macOS/GhostVM/vmctl/ShellCommand.swift @@ -0,0 +1,225 @@ +import Foundation +import GhostHTTP +import GhostVMKit +import CryptoKit +#if canImport(Darwin) +import Darwin +#endif + +/// `vmctl shell` — opens an interactive PTY session on the guest VM via WebSocket. +/// +/// Usage: +/// vmctl shell --name MyVM +/// vmctl shell --socket /path/to/sock +/// +/// Protocol: +/// 1. HTTP/1.1 Upgrade to WebSocket on /api/v1/shell?cols=N&rows=N +/// 2. Binary WebSocket frames = PTY data (bidirectional) +/// 3. Text WebSocket frames = JSON control messages (resize, exit) +enum ShellCommand { + + static func run(arguments: [String]) throws { + var args = arguments + var socketPath: String? + var vmName: String? + + while !args.isEmpty { + if args[0] == "--socket" || args[0] == "-s" { + args.removeFirst() + guard !args.isEmpty else { throw VMError.message("Missing value for --socket") } + socketPath = args.removeFirst() + } else if args[0] == "--name" || args[0] == "-n" { + args.removeFirst() + guard !args.isEmpty else { throw VMError.message("Missing value for --name") } + vmName = args.removeFirst() + } else if args[0] == "--help" || args[0] == "-h" { + showHelp() + return + } else { + throw VMError.message("Unknown argument: \(args[0])\nUsage: vmctl shell --name ") + } + } + + // Resolve socket path + let resolvedPath: String + if let sp = socketPath { + resolvedPath = (sp as NSString).expandingTildeInPath + } else if let name = vmName { + let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + resolvedPath = supportDir.appendingPathComponent("GhostVM/api/\(name).GhostVM.sock").path + } else { + throw VMError.message("Must specify --socket or --name .\nUsage: vmctl shell --name ") + } + + guard FileManager.default.fileExists(atPath: resolvedPath) else { + throw VMError.message("Socket not found at \(resolvedPath)\nIs the VM running?") + } + + guard isatty(STDIN_FILENO) != 0 else { + throw VMError.message("vmctl shell requires a terminal (stdin must be a TTY)") + } + + // Get terminal size + var winSize = winsize() + _ = ioctl(STDOUT_FILENO, TIOCGWINSZ, &winSize) + let cols = winSize.ws_col > 0 ? winSize.ws_col : 80 + let rows = winSize.ws_row > 0 ? winSize.ws_row : 24 + + // Connect + let fd = try connectUnixSocket(path: resolvedPath) + + // WebSocket upgrade handshake. Forward our TERM so the in-VM shell + // can emit color/clear escapes that this terminal can interpret. + let wsKey = generateWebSocketKey() + let term = ProcessInfo.processInfo.environment["TERM"] ?? "xterm-256color" + let termEncoded = term.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "xterm-256color" + let path = "/api/v1/shell?cols=\(cols)&rows=\(rows)&term=\(termEncoded)" + + let upgraded = try HTTPClient.performUpgradeRequest( + fd: fd, + path: path, + headers: [ + "Host": "localhost", + "Upgrade": "websocket", + "Connection": "Upgrade", + "Sec-WebSocket-Key": wsKey, + "Sec-WebSocket-Version": "13", + ] + ) + guard upgraded.responseHead.status == .switchingProtocols else { + Darwin.close(fd) + throw VMError.message("WebSocket upgrade failed with status \(upgraded.responseHead.status.rawValue)") + } + let expectedAccept = webSocketAccept(for: wsKey) + let actualAccept = upgraded.responseHead.headers["Sec-WebSocket-Accept"] + guard actualAccept == expectedAccept else { + Darwin.close(fd) + throw VMError.message("WebSocket upgrade failed: invalid Sec-WebSocket-Accept") + } + + // WebSocket connection established — enter raw terminal mode + var originalTermios = termios() + tcgetattr(STDIN_FILENO, &originalTermios) + + var raw = originalTermios + cfmakeraw(&raw) + // Keep the local tty fully raw. The guest PTY's slave-side termios is + // responsible for newline translation; re-enabling local `OPOST` or + // `ONLCR` here causes double CR/LF mapping and subtle TUI drift. + tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) + + // Only the SOCKET goes non-blocking — that's what DispatchSource's + // writability semantics need. + // + // We deliberately leave STDIN alone. STDIN and STDOUT share the + // open file description on a TTY, so flipping O_NONBLOCK on STDIN + // also flips it on STDOUT. With non-blocking STDOUT, TUI-redraw + // bursts return EAGAIN mid-write, the inner retry loop silently + // dropped bytes on edge cases, and the rendered output came out + // slightly mis-aligned (lines offset, digits in wrong columns). + // DispatchSource.makeReadSource works fine on a blocking fd — + // it only invokes the handler when data is available — and a + // blocking STDOUT write just back-pressures naturally. + let sockFlags = fcntl(fd, F_GETFL, 0) + _ = fcntl(fd, F_SETFL, sockFlags | O_NONBLOCK) + + defer { + tcsetattr(STDIN_FILENO, TCSAFLUSH, &originalTermios) + } + + WebSocketShellClient.run( + configuration: .init( + socketFD: fd, + inputFD: STDIN_FILENO, + outputFD: STDOUT_FILENO, + prelude: upgraded.prelude, + installWindowResizeHandler: true, + installInterruptHandler: true, + onControlMessage: handleControlMessage + ) + ) + + print("") // newline after shell exits + } + + // MARK: - Control Messages + + private static func handleControlMessage(_ text: String) { + guard let data = text.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = json["type"] as? String else { return } + + if type == "exit" { + let code = json["code"] as? Int ?? 0 + if code != 0 { + FileHandle.standardError.write(Data("Shell exited with code \(code)\n".utf8)) + } + } + } + + // MARK: - Socket Helpers + + private static func connectUnixSocket(path: String) throws -> Int32 { + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw VMError.message("Failed to create socket: errno \(errno)") + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = path.utf8CString + guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { + Darwin.close(fd) + throw VMError.message("Socket path too long") + } + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in + pathBytes.withUnsafeBufferPointer { src in + _ = memcpy(dest, src.baseAddress!, pathBytes.count) + } + } + } + + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + Darwin.connect(fd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + Darwin.close(fd) + throw VMError.message("Failed to connect to \(path): errno \(errno)") + } + + return fd + } + + private static func generateWebSocketKey() -> String { + var bytes = [UInt8](repeating: 0, count: 16) + arc4random_buf(&bytes, 16) + return Data(bytes).base64EncodedString() + } + + private static func webSocketAccept(for key: String) -> String { + let magic = key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + let digest = Insecure.SHA1.hash(data: Data(magic.utf8)) + return Data(digest).base64EncodedString() + } + + private static func showHelp() { + let help = """ + Usage: vmctl shell --name + vmctl shell --socket + + Options: + --name VM name (resolves socket from VM bundle) + --socket Unix socket path + + Opens an interactive terminal session on the guest VM. + Uses WebSocket for bidirectional PTY streaming. + + Examples: + vmctl shell --name MyVM + """ + print(help) + } +} diff --git a/macOS/GhostVM/vmctl/UnixSocketClient.swift b/macOS/GhostVM/vmctl/UnixSocketClient.swift index e7fb1c3..16f0f70 100644 --- a/macOS/GhostVM/vmctl/UnixSocketClient.swift +++ b/macOS/GhostVM/vmctl/UnixSocketClient.swift @@ -1,5 +1,6 @@ import Foundation import GhostVMKit +import GhostHTTP /// Synchronous HTTP/1.1 client over a Unix domain socket. /// Blocking I/O is fine for CLI usage. @@ -73,57 +74,24 @@ struct UnixSocketClient { throw VMError.message("Failed to connect to socket at \(socketPath): errno \(errno)") } - // Build HTTP request - var requestStr = "\(method) \(path) HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n" - if let body = body { - requestStr += "Content-Length: \(body.count)\r\n" - if let ct = contentType { - requestStr += "Content-Type: \(ct)\r\n" - } - } - requestStr += "\r\n" - - // Write request - let headerData = Data(requestStr.utf8) - try headerData.withUnsafeBytes { ptr in - let written = Darwin.write(fd, ptr.baseAddress!, headerData.count) - guard written == headerData.count else { - throw VMError.message("Failed to write request headers") - } - } - if let body = body, !body.isEmpty { - try body.withUnsafeBytes { ptr in - let written = Darwin.write(fd, ptr.baseAddress!, body.count) - guard written == body.count else { - throw VMError.message("Failed to write request body") - } - } + var headers = HTTPHeaders() + if let contentType { + headers["Content-Type"] = contentType } - - // Signal we're done writing - Darwin.shutdown(fd, SHUT_WR) - - // Read response until EOF - var responseData = Data() - var buffer = [UInt8](repeating: 0, count: 65536) - while true { - let n = Darwin.read(fd, &buffer, buffer.count) - if n <= 0 { break } - responseData.append(contentsOf: buffer[0..` — opens a raw vsock connection to the guest +/// on the given port and bridges it to vmctl's stdin/stdout. Netcat for vsock. +/// +/// Usage: +/// vmctl vsock connect --name MyVM +/// vmctl vsock connect --socket /path/to/sock +/// +/// Pipe-friendly: +/// vmctl vsock connect --name MyVM 5004 | dd of=/dev/null bs=1M +/// dd if=/dev/zero bs=1M count=100 | vmctl vsock connect --name MyVM 5004 +/// +/// Half-close semantics (matches nc): +/// ^D / stdin EOF → SHUT_WR on the unix socket → helper SHUT_WRs the vsock +/// → peer in VM sees EOF on its read side. We keep reading +/// until the peer closes back. +/// ^C / SIGINT → default signal handler exits the process; OS closes fds +/// → cascade to helper closing the vsock. +enum VsockCommand { + + static func run(arguments: [String]) throws { + var args = arguments + + // First positional after parsing flags is the subcommand: `connect`. + guard !args.isEmpty else { + showHelp() + throw VMError.message("Missing subcommand. Try `vmctl vsock connect `.") + } + + var socketPath: String? + var vmName: String? + + // Pull off flags from anywhere on the line. + var positional: [String] = [] + while !args.isEmpty { + switch args[0] { + case "--socket", "-s": + args.removeFirst() + guard !args.isEmpty else { throw VMError.message("Missing value for --socket") } + socketPath = args.removeFirst() + case "--name", "-n": + args.removeFirst() + guard !args.isEmpty else { throw VMError.message("Missing value for --name") } + vmName = args.removeFirst() + case "--help", "-h": + showHelp() + return + default: + positional.append(args.removeFirst()) + } + } + + guard let sub = positional.first else { + showHelp() + throw VMError.message("Missing subcommand. Try `vmctl vsock connect `.") + } + + switch sub { + case "connect": + guard positional.count >= 2, let port = UInt32(positional[1]) else { + throw VMError.message("Usage: vmctl vsock connect ") + } + try runConnect(port: port, socketPath: socketPath, vmName: vmName) + default: + throw VMError.message("Unknown vsock subcommand: \(sub). Try `connect`.") + } + } + + // MARK: - connect + + private static func runConnect(port: UInt32, socketPath: String?, vmName: String?) throws { + let resolvedPath = try resolveHelperSocket(socketPath: socketPath, vmName: vmName) + + let fd = try connectUnixSocket(path: resolvedPath) + // We intentionally don't `defer Darwin.close(fd)` — control transfers + // to the byte-bridge threads below, and they own fd lifetime. + + // Tell helper which vsock port we want. The endpoint takes a header, + // not a query, so the body is empty. + let upgraded = try HTTPClient.performUpgradeRequest( + fd: fd, + path: "/api/v1/vsock-connect", + headers: [ + "Host": "localhost", + "Vsock-Port": "\(port)", + "Connection": "Upgrade", + "Upgrade": "vsock", + ] + ) + guard upgraded.responseHead.status == .switchingProtocols else { + Darwin.close(fd) + throw VMError.message("Helper rejected vsock-connect with status \(upgraded.responseHead.status.rawValue)") + } + if !upgraded.prelude.isEmpty { + let ok = upgraded.prelude.withUnsafeBytes { ptr -> Bool in + guard let base = ptr.baseAddress else { return true } + return writeAllRaw(fd: STDOUT_FILENO, ptr: base, count: upgraded.prelude.count) + } + if !ok { + Darwin.close(fd) + throw VMError.message("Failed to write initial vsock payload to stdout") + } + } + + // Bidirectional blocking byte bridge. + bridgeBlocking(unixFD: fd) + } + + // MARK: - Byte bridge + + /// Spawns two threads doing blocking I/O between stdin/stdout and the + /// unix-socket fd. Returns when the network-side direction (socket → + /// stdout) hits EOF. The stdin-→-socket thread is fire-and-forget; if + /// it's still blocked on a read at exit, the process termination will + /// reap it. This matches `nc`'s behavior when nothing is piped in. + private static func bridgeBlocking(unixFD: Int32) { + let stdoutDoneSem = DispatchSemaphore(value: 0) + + // stdin → unix socket (fire and forget; we don't wait on this side) + DispatchQueue.global(qos: .userInitiated).async { + var buffer = [UInt8](repeating: 0, count: 65536) + while true { + let n = Darwin.read(STDIN_FILENO, &buffer, buffer.count) + if n < 0 && errno == EINTR { continue } + if n <= 0 { break } + _ = buffer.withUnsafeBufferPointer { ptr -> Bool in + guard let base = ptr.baseAddress else { return n == 0 } + return writeAllRaw(fd: unixFD, ptr: base, count: n) + } + } + // stdin EOF → half-close the write side so the helper sees EOF + // and propagates SHUT_WR to the vsock peer. + Darwin.shutdown(unixFD, SHUT_WR) + } + + // unix socket → stdout (this is the one we wait on) + DispatchQueue.global(qos: .userInitiated).async { + var buffer = [UInt8](repeating: 0, count: 65536) + while true { + let n = Darwin.read(unixFD, &buffer, buffer.count) + if n < 0 && errno == EINTR { continue } + if n <= 0 { break } + _ = buffer.withUnsafeBufferPointer { ptr -> Bool in + guard let base = ptr.baseAddress else { return n == 0 } + return writeAllRaw(fd: STDOUT_FILENO, ptr: base, count: n) + } + } + stdoutDoneSem.signal() + } + + stdoutDoneSem.wait() + Darwin.close(unixFD) + } + + // MARK: - Helpers + + private static func resolveHelperSocket(socketPath: String?, vmName: String?) throws -> String { + let resolved: String + if let sp = socketPath { + resolved = (sp as NSString).expandingTildeInPath + } else if let name = vmName { + let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + resolved = supportDir.appendingPathComponent("GhostVM/api/\(name).GhostVM.sock").path + } else { + throw VMError.message("Specify --socket or --name ") + } + + guard FileManager.default.fileExists(atPath: resolved) else { + throw VMError.message("Helper socket not found at \(resolved). Is the VM running?") + } + return resolved + } + + private static func connectUnixSocket(path: String) throws -> Int32 { + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw VMError.message("socket(AF_UNIX) failed: errno \(errno)") } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let nul: CChar = 0 + _ = path.withCString { src in + withUnsafeMutablePointer(to: &addr.sun_path) { dst in + dst.withMemoryRebound(to: CChar.self, capacity: 104) { dstPtr in + strncpy(dstPtr, src, 103) + dstPtr[103] = nul + } + } + } + + let rc = withUnsafePointer(to: &addr) { ptr -> Int32 in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard rc == 0 else { + let e = errno + Darwin.close(fd) + throw VMError.message("connect(\(path)) failed: errno \(e)") + } + return fd + } + + private static func writeAllRaw(fd: Int32, ptr: UnsafeRawPointer, count: Int) -> Bool { + var offset = 0 + while offset < count { + let n = Darwin.write(fd, ptr + offset, count - offset) + if n > 0 { offset += n } + else if n < 0 && (errno == EAGAIN || errno == EINTR) { usleep(1000) } + else { return false } + } + return true + } + + // MARK: - Help + + private static func showHelp() { + let text = """ + Usage: + vmctl vsock connect --name + vmctl vsock connect --socket + + Opens a raw vsock connection to the guest on the given port, bridged + to vmctl's stdin and stdout (netcat-style). Use shell pipes for I/O: + + vmctl vsock connect --name MyVM 5004 | dd of=/dev/null bs=1M + dd if=/dev/zero bs=1M count=100 | vmctl vsock connect -n MyVM 5004 + printf 'GET / HTTP/1.1\\r\\nHost: x\\r\\n\\r\\n' | vmctl vsock connect -n MyVM 5000 + + Options: + --name, -n Resolve helper socket from VM name. + --socket, -s Use a specific helper socket path. + --help, -h Show this help. + """ + print(text) + } +} diff --git a/macOS/GhostVM/vmctl/entitlements-debug.plist b/macOS/GhostVM/vmctl/entitlements-debug.plist new file mode 100644 index 0000000..9971921 --- /dev/null +++ b/macOS/GhostVM/vmctl/entitlements-debug.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.virtualization + + com.apple.security.network.client + + + diff --git a/macOS/GhostVMHelper/HelperToolbar.swift b/macOS/GhostVMHelper/HelperToolbar.swift index 09c1ef6..1f0d505 100644 --- a/macOS/GhostVMHelper/HelperToolbar.swift +++ b/macOS/GhostVMHelper/HelperToolbar.swift @@ -63,6 +63,7 @@ final class HelperToolbar: NSObject, NSToolbarDelegate, NSMenuDelegate, PortForw static let clipboardSync = NSToolbarItem.Identifier("clipboardSync") static let iconChooser = NSToolbarItem.Identifier("iconChooser") + static let terminal = NSToolbarItem.Identifier("terminal") static let captureKeys = NSToolbarItem.Identifier("captureKeys") static let captureCommands = NSToolbarItem.Identifier("captureCommands") static let queuedFiles = NSToolbarItem.Identifier("queuedFiles") @@ -74,6 +75,7 @@ final class HelperToolbar: NSObject, NSToolbarDelegate, NSMenuDelegate, PortForw // MARK: - Properties weak var delegate: HelperToolbarDelegate? + var vmName: String = "" private let toolbar: NSToolbar private var guestToolsStatus: GuestToolsStatus = .connecting @@ -99,6 +101,7 @@ final class HelperToolbar: NSObject, NSToolbarDelegate, NSMenuDelegate, PortForw private var portForwardsItem: NSToolbarItem? private var sharedFoldersItem: NSMenuToolbarItem? private var clipboardSyncItem: NSToolbarItem? + private var terminalItem: NSMenuToolbarItem? private var captureKeysItem: NSToolbarItem? private var captureCommandsItem: NSMenuToolbarItem? private var queuedFilesItem: NSToolbarItem? @@ -325,6 +328,7 @@ final class HelperToolbar: NSObject, NSToolbarDelegate, NSMenuDelegate, PortForw ItemID.portForwards, ItemID.sharedFolders, ItemID.clipboardSync, + ItemID.terminal, ItemID.captureKeys, ItemID.captureCommands, ItemID.queuedFiles, @@ -340,6 +344,7 @@ final class HelperToolbar: NSObject, NSToolbarDelegate, NSMenuDelegate, PortForw ItemID.portForwards, ItemID.sharedFolders, ItemID.clipboardSync, + ItemID.terminal, ItemID.captureKeys, ItemID.captureCommands, ItemID.queuedFiles, @@ -360,6 +365,8 @@ final class HelperToolbar: NSObject, NSToolbarDelegate, NSMenuDelegate, PortForw return makeSharedFoldersItem() case ItemID.clipboardSync: return makeClipboardSyncItem() + case ItemID.terminal: + return makeTerminalItem() case ItemID.captureKeys: return makeCaptureKeysItem() case ItemID.captureCommands: @@ -489,6 +496,79 @@ final class HelperToolbar: NSObject, NSToolbarDelegate, NSMenuDelegate, PortForw return item } + private func makeTerminalItem() -> NSToolbarItem { + let item = NSMenuToolbarItem(itemIdentifier: ItemID.terminal) + item.label = "Terminal" + item.paletteLabel = "Terminal" + item.toolTip = "Open shell in guest VM" + item.image = NSImage(systemSymbolName: "terminal", accessibilityDescription: "Terminal")?.withSymbolConfiguration(iconConfig) + + let menu = NSMenu() + let openItem = NSMenuItem(title: "Open Terminal", action: #selector(openTerminal), keyEquivalent: "") + openItem.target = self + openItem.image = NSImage(systemSymbolName: "terminal", accessibilityDescription: nil) + menu.addItem(openItem) + + menu.addItem(.separator()) + + let copyItem = NSMenuItem(title: "Copy vmctl Command", action: #selector(copyVmctlCommand), keyEquivalent: "") + copyItem.target = self + copyItem.image = NSImage(systemSymbolName: "doc.on.doc", accessibilityDescription: nil) + menu.addItem(copyItem) + + item.menu = menu + terminalItem = item + return item + } + + /// Resolve the full path to the vmctl binary. + /// vmctl.app is copied as a sibling of the helper .app in the VM bundle's Helper/ directory. + private func vmctlPath() -> String { + let helperBundle = Bundle.main.bundlePath // .../Helper/.app + let helperDir = (helperBundle as NSString).deletingLastPathComponent // .../Helper/ + let vmctl = (helperDir as NSString).appendingPathComponent("vmctl.app/Contents/MacOS/vmctl") + if FileManager.default.fileExists(atPath: vmctl) { + return vmctl + } + NSLog("HelperToolbar: vmctl not found at \(vmctl)") + return "vmctl" + } + + /// Shell-escape a string by wrapping in single quotes and escaping embedded single quotes. + private func shellEscape(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + /// Full vmctl shell command with properly escaped paths. + private func vmctlCommand() -> String { + "\(shellEscape(vmctlPath())) shell --name \(shellEscape(vmName))" + } + + @objc private func openTerminal() { + // AppleScript's `do script` opens a new Terminal window and runs the + // command in it directly. Cleaner than writing a self-deleting temp + // script to disk and `open -a Terminal`-ing it. + let command = vmctlCommand() + let escaped = command + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + let appleScript = """ + tell application "Terminal" + activate + do script "\(escaped)" + end tell + """ + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", appleScript] + try? process.run() + } + + @objc private func copyVmctlCommand() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(vmctlCommand(), forType: .string) + } + private func makeCaptureKeysItem() -> NSToolbarItem { let item = NSToolbarItem(itemIdentifier: ItemID.captureKeys) item.label = "Capture Inputs" diff --git a/macOS/GhostVMHelper/HostAPIService.swift b/macOS/GhostVMHelper/HostAPIService.swift index 9e993da..efc3454 100644 --- a/macOS/GhostVMHelper/HostAPIService.swift +++ b/macOS/GhostVMHelper/HostAPIService.swift @@ -1,12 +1,14 @@ import AppKit import Foundation +import GhostHTTP import GhostVMKit +@preconcurrency import Virtualization /// Host-side HTTP API served over a Unix domain socket. /// vmctl connects here. Requests are proxied to GhostTools in the guest /// (including screenshot and batch automation paths). -@MainActor final class HostAPIService { + private let connectionSlots = DispatchSemaphore(value: 64) private weak var client: GhostClient? private var vmName: String private var socketPath: String @@ -82,9 +84,12 @@ final class HostAPIService { if errno == EBADF || errno == EINVAL { break // Socket closed } + usleep(10_000) continue } - Task { @MainActor [weak self] in + self?.connectionSlots.wait() + Task { [weak self] in + defer { self?.connectionSlots.signal() } await self?.handleConnection(clientFD) } } @@ -101,208 +106,394 @@ final class HostAPIService { NSLog("HostAPIService: Stopped") } - // MARK: - Connection Handler - - private func handleConnection(_ fd: Int32) async { - // Read HTTP request from the socket on a background queue - let requestData: Data = await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - var data = Data() - var buffer = [UInt8](repeating: 0, count: 65536) - // Read until we have the full request (headers + body) - var headerEnd = -1 - var contentLength = 0 + nonisolated private static func setReceiveTimeout(fd: Int32, seconds: Int) { + var timeout = timeval(tv_sec: seconds, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + } - while true { - let n = Darwin.read(fd, &buffer, buffer.count) - if n <= 0 { break } - data.append(contentsOf: buffer[0..= 0 && data.count >= headerEnd + contentLength { - break - } + nonisolated private static func connectRawWithTimeout( + client: GhostClient, + port: UInt32, + seconds: TimeInterval + ) async throws -> VZVirtioSocketConnection { + final class ConnectState: @unchecked Sendable { + private let lock = NSLock() + let semaphore = DispatchSemaphore(value: 0) + var completed = false + var timedOut = false + var connection: VZVirtioSocketConnection? + var error: Error? + + func finish(connection: VZVirtioSocketConnection) { + lock.lock() + completed = true + if timedOut { + lock.unlock() + connection.close() + semaphore.signal() + return } - continuation.resume(returning: data) + self.connection = connection + lock.unlock() + semaphore.signal() + } + + func finish(error: Error) { + lock.lock() + completed = true + self.error = error + lock.unlock() + semaphore.signal() + } + + func markTimedOut() { + lock.lock() + timedOut = true + lock.unlock() } } - guard !requestData.isEmpty, - let requestStr = String(data: requestData, encoding: .utf8) else { - Darwin.close(fd) - return + let state = ConnectState() + Task { + do { + let connection = try await client.connectRaw(port: port) + state.finish(connection: connection) + } catch { + state.finish(error: error) + } } - // Parse method, path, body - let (method, path, headers, body) = parseHTTPRequest(requestStr, rawData: requestData) + let completed = try await runBlocking { + state.semaphore.wait(timeout: .now() + seconds) == .success + } + guard completed else { + state.markTimedOut() + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(ETIMEDOUT), + userInfo: [NSLocalizedDescriptionKey: "Operation timed out after \(seconds) seconds"] + ) + } + if let error = state.error { + throw error + } + if let connection = state.connection { + return connection + } + throw GhostClientError.connectionFailed("Connection attempt completed without a result") + } - // Route and get response - let response = await route(method: method, path: path, headers: headers, body: body) + // MARK: - Connection Handler - // Write response on background queue - await withCheckedContinuation { (continuation: CheckedContinuation) in - DispatchQueue.global(qos: .userInitiated).async { - var headerStr = "HTTP/1.1 \(response.statusCode) \(response.statusText)\r\nContent-Type: \(response.contentType)\r\nContent-Length: \(response.body.count)\r\nConnection: close\r\n" - for (key, value) in response.extraHeaders { - headerStr += "\(key): \(value)\r\n" - } - headerStr += "\r\n" - let headerData = Data(headerStr.utf8) - headerData.withUnsafeBytes { ptr in - _ = Darwin.write(fd, ptr.baseAddress!, headerData.count) - } - if !response.body.isEmpty { - response.body.withUnsafeBytes { ptr in - _ = Darwin.write(fd, ptr.baseAddress!, response.body.count) - } - } - Darwin.close(fd) - continuation.resume() + private func handleConnection(_ fd: Int32) async { + Self.setReceiveTimeout(fd: fd, seconds: 30) + var responseStarted = false + + do { + let (request, prelude) = try await Self.runBlocking { + try HTTPCodec.readRequest(fd: fd) + } + + let cleanPath = request.path.components(separatedBy: "?").first ?? request.path + if cleanPath == "/api/v1/shell" { + await handleShellProxy(vmctlFD: fd, request: request) + return + } + if cleanPath == "/api/v1/vsock-connect" { + await handleVsockConnectProxy(vmctlFD: fd, headers: request.headers) + return + } + + let body = try await Self.readRequestBody(fd: fd, request: request, prelude: prelude) + let response = await route(request: request, body: body) + responseStarted = true + try await Self.writeResponse(fd: fd, response: response) + } catch { + Self.logTransportFailure("Failed to handle request: \(error)") + if !responseStarted { + try? await Self.writeResponse(fd: fd, response: Self.errorResponse(for: error)) } } + + Darwin.close(fd) } - // MARK: - HTTP Parsing + // MARK: - Shell Proxy + + /// Proxies a shell session: vmctl ↔ HostAPIService ↔ GhostTools (vsock). + /// Instead of request/response, switches to bidirectional byte bridging. + private func handleShellProxy(vmctlFD: Int32, request: HTTPRequestHead) async { + guard let client = client else { + try? Self.writeResponseSync(fd: vmctlFD, response: .error(.internalServerError, message: "Guest client not available")) + Darwin.close(vmctlFD) + return + } + + NSLog("HostAPIService: Shell proxy starting") + + // Open a raw vsock connection to GhostTools on port 5000 + let guestConnection: VZVirtioSocketConnection + do { + guestConnection = try await Self.connectRawWithTimeout(client: client, port: 5000, seconds: 10) + } catch { + NSLog("HostAPIService: Shell proxy failed to connect to guest: \(error)") + try? Self.writeResponseSync(fd: vmctlFD, response: .error(.internalServerError, message: "Failed to connect shell proxy")) + Darwin.close(vmctlFD) + return + } - private func parseHTTPRequest(_ str: String, rawData: Data) -> (method: String, path: String, headers: [String: String]?, body: Data?) { - let lines = str.components(separatedBy: "\r\n") - guard let requestLine = lines.first else { return ("GET", "/", nil, nil) } - let parts = requestLine.components(separatedBy: " ") - let method = parts.count > 0 ? parts[0] : "GET" - let path = parts.count > 1 ? parts[1] : "/" + let guestFD = guestConnection.fileDescriptor + NSLog("HostAPIService: Shell proxy connected to guest (fd=\(guestFD))") - // Parse headers - var headers: [String: String] = [:] - for line in lines.dropFirst() { - if line.isEmpty { break } - if let colonIndex = line.firstIndex(of: ":") { - let key = String(line[line.startIndex.. headerEndIndex { - return (method, path, headers, Data(rawData[headerEndIndex...])) + Self.setReceiveTimeout(fd: guestFD, seconds: 0) + + do { + try await Self.runBlocking { + try HTTPCodec.writeResponseHead( + fd: vmctlFD, + status: upgraded.responseHead.status, + headers: upgraded.responseHead.headers + ) + if !upgraded.prelude.isEmpty { + try HTTPCodec.writeAll(fd: vmctlFD, data: upgraded.prelude) + } } + } catch { + Self.logTransportFailure("Shell proxy failed to relay guest upgrade response to client: \(error)") + guestConnection.close() + Darwin.close(vmctlFD) + return } - return (method, path, headers.isEmpty ? nil : headers, nil) + + // Now bridge bidirectionally: vmctlFD ↔ guestFD + // Both sides are raw byte streams from this point on. + await Self.bridgeBytes(fdA: vmctlFD, fdB: guestFD, label: "shell") + guestConnection.close() + Darwin.close(vmctlFD) + NSLog("HostAPIService: Shell proxy session ended") } - // MARK: - Response Type - - private struct Response { - let statusCode: Int - let statusText: String - let contentType: String - let body: Data - let extraHeaders: [String: String] - - init(statusCode: Int, statusText: String, contentType: String, body: Data, extraHeaders: [String: String] = [:]) { - self.statusCode = statusCode - self.statusText = statusText - self.contentType = contentType - self.body = body - self.extraHeaders = extraHeaders - } - - static func json(_ data: Data, status: Int = 200) -> Response { - let httpStatus = HTTPUtilities.HTTPStatus.from(code: status) - return Response( - statusCode: httpStatus.rawValue, - statusText: httpStatus.reasonPhrase, - contentType: "application/json", - body: data - ) - } + // MARK: - Generic Vsock Proxy + // + // Bridges vmctl's unix-socket connection to a raw vsock connection at a + // requested guest port. After the 101 response, both sides are pure bytes. + // Used by `vmctl vsock connect ` — netcat-style debugging tool. - static func png(_ data: Data) -> Response { - Response(statusCode: 200, statusText: "OK", contentType: "image/png", body: data) + private func handleVsockConnectProxy(vmctlFD: Int32, headers: HTTPHeaders) async { + guard let client = client else { + Self.writeShortError(fd: vmctlFD, status: 500, text: "Internal Server Error") + Darwin.close(vmctlFD) + return } - static func jpeg(_ data: Data) -> Response { - Response(statusCode: 200, statusText: "OK", contentType: "image/jpeg", body: data) + // Required: Vsock-Port header. + let portString = headers["Vsock-Port"] + guard let portString = portString, let port = UInt32(portString) else { + Self.writeShortError(fd: vmctlFD, status: 400, text: "Missing or invalid Vsock-Port header") + Darwin.close(vmctlFD) + return } - static func binary(_ data: Data, headers: [String: String] = [:]) -> Response { - Response(statusCode: 200, statusText: "OK", contentType: "application/octet-stream", body: data, extraHeaders: headers) - } + NSLog("HostAPIService: vsock-connect proxy starting (port=\(port))") - static func noContent() -> Response { - Response(statusCode: 204, statusText: "No Content", contentType: "application/json", body: Data()) + let guestConnection: VZVirtioSocketConnection + do { + guestConnection = try await Self.connectRawWithTimeout(client: client, port: port, seconds: 10) + } catch { + NSLog("HostAPIService: vsock-connect failed to open port \(port): \(error)") + Self.writeShortError(fd: vmctlFD, status: 502, text: "Bad Gateway: \(error.localizedDescription)") + Darwin.close(vmctlFD) + return } - static func error(_ status: Int, message: String) -> Response { - let httpStatus = HTTPUtilities.HTTPStatus.from(code: status) - let body = try! JSONSerialization.data(withJSONObject: ["error": message]) - return Response( - statusCode: httpStatus.rawValue, - statusText: httpStatus.reasonPhrase, - contentType: "application/json", - body: body + let guestFD = guestConnection.fileDescriptor + NSLog("HostAPIService: vsock-connect opened guest fd=\(guestFD) port=\(port)") + + // Tell vmctl the bridge is up. + do { + try Self.writeResponseSync( + fd: vmctlFD, + response: HTTPResponse( + status: .switchingProtocols, + headers: [ + "Upgrade": "vsock", + "Connection": "Upgrade", + ] + ), + headOnly: true ) + } catch { + Self.logTransportFailure("vsock-connect failed to write 101 to vmctl: \(error)") + guestConnection.close() + Darwin.close(vmctlFD) + return + } + + await Self.bridgeBytes(fdA: vmctlFD, fdB: guestFD, label: "vsock-connect(\(port))") + guestConnection.close() + Darwin.close(vmctlFD) + NSLog("HostAPIService: vsock-connect(\(port)) session ended") + } + + /// Bidirectional blocking byte bridge between two fds. Returns when both + /// directions have hit EOF/error. Uses SHUT_WR to propagate half-close so + /// the peer of each direction sees a proper EOF. + nonisolated static func bridgeBytes(fdA: Int32, fdB: Int32, label: String) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let group = DispatchGroup() + + setReceiveTimeout(fd: fdA, seconds: 0) + setReceiveTimeout(fd: fdB, seconds: 0) + + // A → B + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + var buffer = [UInt8](repeating: 0, count: 16384) + var shouldSignalEOF = true + while true { + let n = Darwin.read(fdA, &buffer, buffer.count) + if n < 0 && (errno == EINTR || errno == EAGAIN) { continue } + if n <= 0 { break } + let ok = buffer.withUnsafeBufferPointer { ptr in + guard let base = ptr.baseAddress else { return n == 0 } + do { + try HTTPCodec.writeAll(fd: fdB, ptr: base, count: n) + return true + } catch { + return false + } + } + if !ok { + NSLog("HostAPIService: \(label) write A→B failed: errno \(errno)") + Darwin.shutdown(fdA, SHUT_RD) + Darwin.shutdown(fdB, SHUT_WR) + shouldSignalEOF = false + break + } + } + if shouldSignalEOF { + Darwin.shutdown(fdB, SHUT_WR) + } + group.leave() + } + + // B → A + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + var buffer = [UInt8](repeating: 0, count: 16384) + var shouldSignalEOF = true + while true { + let n = Darwin.read(fdB, &buffer, buffer.count) + if n < 0 && (errno == EINTR || errno == EAGAIN) { continue } + if n <= 0 { break } + let ok = buffer.withUnsafeBufferPointer { ptr in + guard let base = ptr.baseAddress else { return n == 0 } + do { + try HTTPCodec.writeAll(fd: fdA, ptr: base, count: n) + return true + } catch { + return false + } + } + if !ok { + NSLog("HostAPIService: \(label) write B→A failed: errno \(errno)") + Darwin.shutdown(fdB, SHUT_RD) + Darwin.shutdown(fdA, SHUT_WR) + shouldSignalEOF = false + break + } + } + if shouldSignalEOF { + Darwin.shutdown(fdA, SHUT_WR) + } + group.leave() + } + + group.notify(queue: .global()) { + continuation.resume() + } } + } + + nonisolated private static func writeShortError(fd: Int32, status: Int, text: String) { + try? writeResponseSync(fd: fd, response: .error(.from(code: status), message: text)) + } - static func ok() -> Response { - json(try! JSONSerialization.data(withJSONObject: ["ok": true])) + private func clipboardType(explicitType: String?, contentType: String?) -> String { + if let explicitType, !explicitType.isEmpty { + return explicitType + } + guard let contentType else { return "public.utf8-plain-text" } + + let mimeType = contentType.split(separator: ";", maxSplits: 1).first? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + switch mimeType { + case "image/png": return "public.png" + case "image/tiff": return "public.tiff" + case "image/jpeg": return "public.jpeg" + case "text/rtf", "application/rtf": return "public.rtf" + case "text/plain": return "public.utf8-plain-text" + default: return "public.utf8-plain-text" } } // MARK: - Router - private func route(method: String, path: String, headers: [String: String]?, body: Data?) async -> Response { - let cleanPath = path.components(separatedBy: "?").first ?? path + private func route(request: HTTPRequestHead, body: Data?) async -> HTTPResponse { + let cleanPath = request.path.components(separatedBy: "?").first ?? request.path // Health if cleanPath == "/health" { - return Response.json(try! JSONSerialization.data(withJSONObject: ["status": "ok"])) - } - - // Screenshot and batch are now guest-side only. - if cleanPath == "/vm/screenshot" || cleanPath == "/vm/screenshot/annotated" || cleanPath == "/api/v1/batch" { - return await proxyToGuest(method: method, path: path, headers: headers, body: body) + return .json(try! JSONSerialization.data(withJSONObject: ["status": "ok"])) } // Everything else: proxy to guest via GhostClient - return await proxyToGuest(method: method, path: path, headers: headers, body: body) + return await proxyToGuest(request: request, body: body) } // MARK: - Guest Proxy - private func proxyToGuest(method: String, path: String, headers: [String: String]?, body: Data?) async -> Response { + private func proxyToGuest(request: HTTPRequestHead, body: Data?) async -> HTTPResponse { guard let client = client else { - return .error(500, message: "Guest client not available") + return .error(.internalServerError, message: "Guest client not available") } - // Use GhostClient's sendHTTPRequest by building a raw HTTP request through the vsock - // For simplicity, use the specific client methods based on the path + let method = request.method.rawValue + let path = request.path let cleanPath = path.components(separatedBy: "?").first ?? path do { - // Guest-side screenshot endpoints if cleanPath == "/vm/screenshot" && method == "GET" { let format = HTTPUtilities.parseQuery(path, key: "format") ?? "png" let scale = Double(HTTPUtilities.parseQuery(path, key: "scale") ?? "1.0") ?? 1.0 let result = try await client.captureGuestScreenshot(format: format, scale: scale) - return Response(statusCode: 200, statusText: "OK", contentType: result.contentType, body: result.data) + return HTTPResponse(status: .ok, headers: ["Content-Type": result.contentType], body: .bytes(result.data)) } if cleanPath == "/vm/screenshot/annotated" && method == "GET" { let scale = Double(HTTPUtilities.parseQuery(path, key: "scale") ?? "0.5") ?? 0.5 @@ -311,33 +502,32 @@ final class HostAPIService { } if cleanPath == "/api/v1/batch" && method == "POST" { guard let body = body else { - return .error(400, message: "Request body required") + return .error(.badRequest, message: "Request body required") } guard let request = try? JSONDecoder().decode(BatchRequest.self, from: body) else { - return .error(400, message: "Invalid JSON") + return .error(.badRequest, message: "Invalid JSON") } let data = try await client.executeGuestBatch(request) return .json(data) } - // Clipboard if cleanPath == "/api/v1/clipboard" { if method == "GET" { let resp = try await client.getClipboard() if let data = resp.data { let clipType = resp.type ?? "public.utf8-plain-text" - return .binary(data, headers: [ + return HTTPResponse(status: .ok, headers: [ "Content-Type": "application/octet-stream", "X-Clipboard-Type": clipType, - ]) + ], body: .bytes(data)) } - return .noContent() + return HTTPResponse(status: .noContent) } else if method == "POST" { guard let body = body, !body.isEmpty else { - return .error(400, message: "Request body required") + return .error(.badRequest, message: "Request body required") } - let explicitType = headers?["X-Clipboard-Type"] ?? headers?["x-clipboard-type"] - let contentType = (headers?["Content-Type"] ?? headers?["content-type"])?.lowercased() + let explicitType = request.headers["X-Clipboard-Type"] + let contentType = request.headers["Content-Type"]?.lowercased() let (clipboardBody, clipType): (Data, String) = { // Backward compatibility for older UI clients posting JSON: @@ -349,15 +539,14 @@ final class HostAPIService { let parsedType = (object["type"] as? String) ?? "public.utf8-plain-text" return (Data(content.utf8), parsedType) } - return (body, explicitType ?? "public.utf8-plain-text") + return (body, clipboardType(explicitType: explicitType, contentType: contentType)) }() try await client.setClipboard(data: clipboardBody, type: clipType) - return .ok() + return Self.okResponse() } } - // Apps if cleanPath == "/api/v1/apps" && method == "GET" { let resp = try await client.listApps() let data = try JSONEncoder().encode(resp) @@ -367,28 +556,28 @@ final class HostAPIService { guard let body = body, let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], let bundleId = json["bundleId"] as? String else { - return .error(400, message: "Need bundleId") + return .error(.badRequest, message: "Need bundleId") } try await client.launchApp(bundleId: bundleId) - return .ok() + return Self.okResponse() } if cleanPath == "/api/v1/apps/activate" && method == "POST" { guard let body = body, let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], let bundleId = json["bundleId"] as? String else { - return .error(400, message: "Need bundleId") + return .error(.badRequest, message: "Need bundleId") } try await client.activateApp(bundleId: bundleId) - return .ok() + return Self.okResponse() } if cleanPath == "/api/v1/apps/quit" && method == "POST" { guard let body = body, let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], let bundleId = json["bundleId"] as? String else { - return .error(400, message: "Need bundleId") + return .error(.badRequest, message: "Need bundleId") } try await client.quitApp(bundleId: bundleId) - return .ok() + return Self.okResponse() } if cleanPath == "/api/v1/apps/frontmost" && method == "GET" { let bundleId = try await client.getFrontmostApp() @@ -396,7 +585,6 @@ final class HostAPIService { return .json(data) } - // Accessibility if cleanPath == "/api/v1/accessibility" && method == "GET" { let depth = Int(HTTPUtilities.parseQuery(path, key: "depth") ?? "5") ?? 5 let targetStr = HTTPUtilities.parseQuery(path, key: "target") ?? "front" @@ -421,7 +609,7 @@ final class HostAPIService { if cleanPath == "/api/v1/accessibility/action" && method == "POST" { guard let body = body, let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any] else { - return .error(400, message: "Invalid JSON") + return .error(.badRequest, message: "Invalid JSON") } let targetStr = HTTPUtilities.parseQuery(path, key: "target") ?? "front" let target = AXTarget(queryValue: targetStr) ?? .front @@ -431,24 +619,24 @@ final class HostAPIService { action: json["action"] as? String ?? "AXPress", target: target, wait: false ) - return .ok() + return Self.okResponse() } if cleanPath == "/api/v1/accessibility/menu" && method == "POST" { guard let body = body, let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], let menuPath = json["path"] as? [String] else { - return .error(400, message: "Need path array") + return .error(.badRequest, message: "Need path array") } let targetStr = HTTPUtilities.parseQuery(path, key: "target") ?? "front" let target = AXTarget(queryValue: targetStr) ?? .front try await client.triggerMenuItem(path: menuPath, target: target, wait: false) - return .ok() + return Self.okResponse() } if cleanPath == "/api/v1/accessibility/type" && method == "POST" { guard let body = body, let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], let value = json["value"] as? String else { - return .error(400, message: "Need value") + return .error(.badRequest, message: "Need value") } let targetStr = HTTPUtilities.parseQuery(path, key: "target") ?? "front" let target = AXTarget(queryValue: targetStr) ?? .front @@ -456,15 +644,14 @@ final class HostAPIService { value, label: json["label"] as? String, role: json["role"] as? String, target: target ) - return .ok() + return Self.okResponse() } - // Pointer if cleanPath == "/api/v1/pointer" && method == "POST" { guard let body = body, let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], let action = json["action"] as? String else { - return .error(400, message: "Need action") + return .error(.badRequest, message: "Need action") } let responseData = try await client.sendPointerEvent( action: action, @@ -481,14 +668,13 @@ final class HostAPIService { if let data = responseData { return .json(data) } - return .ok() + return Self.okResponse() } - // Keyboard if cleanPath == "/api/v1/input" && method == "POST" { guard let body = body, let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any] else { - return .error(400, message: "Invalid JSON") + return .error(.badRequest, message: "Invalid JSON") } try await client.sendKeyboardInput( text: json["text"] as? String, @@ -497,15 +683,14 @@ final class HostAPIService { rate: json["rate"] as? Int, wait: false ) - return .ok() + return Self.okResponse() } - // Exec if cleanPath == "/api/v1/exec" && method == "POST" { guard let body = body, let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], let command = json["command"] as? String else { - return .error(400, message: "Need command") + return .error(.badRequest, message: "Need command") } let resp = try await client.exec( command: command, @@ -516,31 +701,26 @@ final class HostAPIService { return .json(data) } - // Elements if cleanPath == "/api/v1/elements" && method == "GET" { let data = try await client.getElements() return .json(data) } - // Permissions if cleanPath == "/api/v1/permissions" { - // Proxy to guest - let data = try await client.getElements() // Use elements as a proxy for permission check + let data = try await client.checkPermissions(prompt: method == "POST") return .json(data) } - // Open if cleanPath == "/api/v1/open" && method == "POST" { guard let body = body, let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], let openPath = json["path"] as? String else { - return .error(400, message: "Need path") + return .error(.badRequest, message: "Need path") } try await client.openPath(openPath) - return .ok() + return Self.okResponse() } - // File operations if cleanPath == "/api/v1/files" { if method == "GET" { let files = try await client.listFiles() @@ -549,11 +729,10 @@ final class HostAPIService { } if method == "DELETE" { try await client.clearFileQueue() - return .ok() + return Self.okResponse() } } - // File system if cleanPath == "/api/v1/fs" && method == "GET" { let dirPath = HTTPUtilities.parseQuery(path, key: "path") ?? "~" let resp = try await client.listDirectory(path: dirPath) @@ -561,10 +740,72 @@ final class HostAPIService { return .json(data) } - return .error(404, message: "Not found: \(cleanPath)") + return .error(.notFound, message: "Not found: \(cleanPath)") } catch { let desc = (error as? LocalizedError)?.errorDescription ?? "\(error)" - return .error(500, message: desc) + return .error(.internalServerError, message: desc) + } + } + + nonisolated private static func okResponse() -> HTTPResponse { + .json(try! JSONSerialization.data(withJSONObject: ["ok": true])) + } + + nonisolated private static func errorResponse(for error: Error) -> HTTPResponse { + let status: HTTPStatus + if let httpError = error as? HTTPError { + switch httpError { + case .headerTooLarge: + status = .headerTooLarge + case .bodyTooLarge: + status = .payloadTooLarge + case .unexpectedEOF, .readFailed: + status = .requestTimeout + default: + status = .badRequest + } + } else { + status = .internalServerError + } + return .error(status, message: error.localizedDescription) + } + + nonisolated private static func readRequestBody(fd: Int32, request: HTTPRequestHead, prelude: Data) async throws -> Data? { + let framing = HTTPCodec.requestFraming(for: request) + switch framing { + case .knownLength(0): + return nil + default: + return try await runBlocking { + let reader = HTTPBodyReader(fd: fd, framing: framing, prelude: prelude) + return try reader.readAll(maxSize: 64 * 1024 * 1024) + } + } + } + + nonisolated private static func writeResponse(fd: Int32, response: HTTPResponse) async throws { + try await runBlocking { + try writeResponseSync(fd: fd, response: response) + } + } + + nonisolated private static func writeResponseSync(fd: Int32, response: HTTPResponse, headOnly: Bool = false) throws { + if headOnly { + try HTTPCodec.writeResponseHead(fd: fd, status: response.status, headers: response.headers) + } else { + try HTTPCodec.writeResponse(response, fd: fd) + } + } + + nonisolated private static func runBlocking(_ operation: @escaping @Sendable () throws -> T) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + do { + continuation.resume(returning: try operation()) + } catch { + continuation.resume(throwing: error) + } + } } } diff --git a/macOS/GhostVMHelper/entitlements-debug.plist b/macOS/GhostVMHelper/entitlements-debug.plist new file mode 100644 index 0000000..bffb80e --- /dev/null +++ b/macOS/GhostVMHelper/entitlements-debug.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.virtualization + + com.apple.security.network.client + + com.apple.security.device.audio-input + + + diff --git a/macOS/GhostVMHelper/main.swift b/macOS/GhostVMHelper/main.swift index 2914139..a60fbb4 100644 --- a/macOS/GhostVMHelper/main.swift +++ b/macOS/GhostVMHelper/main.swift @@ -1633,7 +1633,7 @@ final class HelperAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate apiService.start(client: client, vmWindow: self.window) self.hostAPIService = apiService - // 2. Persistent health check (vsock port 5002) + // 2. Guest health polling via the unified HTTP server on vsock port 5000 let hcService = HealthCheckService() hcService.start(client: client) self.healthCheckService = hcService @@ -2084,6 +2084,7 @@ final class HelperAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate // Setup toolbar let toolbar = HelperToolbar() toolbar.delegate = self + toolbar.vmName = vmName toolbar.attach(to: window) helperToolbar = toolbar diff --git a/macOS/GhostVMKit/Configuration/VMConfigurationBuilder.swift b/macOS/GhostVMKit/Configuration/VMConfigurationBuilder.swift index 20cee6e..a4fae99 100644 --- a/macOS/GhostVMKit/Configuration/VMConfigurationBuilder.swift +++ b/macOS/GhostVMKit/Configuration/VMConfigurationBuilder.swift @@ -1,6 +1,7 @@ import Foundation import AppKit import Virtualization +import os /// Builds VZVirtualMachineConfiguration from stored config and layout. public final class VMConfigurationBuilder { @@ -28,17 +29,27 @@ public final class VMConfigurationBuilder { platform.auxiliaryStorage = VZMacAuxiliaryStorage(url: layout.auxiliaryStorageURL) config.platform = platform - // Attach the raw disk image as the primary boot volume. + // Attach the VM root disk image as the primary boot volume. let diskAttachment = try VZDiskImageStorageDeviceAttachment(url: layout.diskURL, readOnly: false) let diskDevice = VZVirtioBlockDeviceConfiguration(attachment: diskAttachment) diskDevice.blockDeviceIdentifier = "macos-root" var storageDevices: [VZStorageDeviceConfiguration] = [diskDevice] - // Always attach GhostTools.dmg (user can eject if not needed) + // Attach GhostTools.dmg if available and readable (non-fatal if format unsupported) if let dmgURL = Self.findGhostToolsDMG() { - let dmgAttachment = try VZDiskImageStorageDeviceAttachment(url: dmgURL, readOnly: true) - let usbDevice = VZUSBMassStorageDeviceConfiguration(attachment: dmgAttachment) - storageDevices.append(usbDevice) + let logger = Logger(subsystem: "org.ghostvm", category: "VMConfigurationBuilder") + logger.info("GhostTools.dmg found at: \(dmgURL.path, privacy: .public)") + do { + let dmgAttachment = try VZDiskImageStorageDeviceAttachment(url: dmgURL, readOnly: true) + let usbDevice = VZUSBMassStorageDeviceConfiguration(attachment: dmgAttachment) + storageDevices.append(usbDevice) + logger.info("GhostTools.dmg attached as USB mass storage") + } catch { + logger.error("GhostTools.dmg skipped: \(error.localizedDescription, privacy: .public)") + } + } else { + let logger = Logger(subsystem: "org.ghostvm", category: "VMConfigurationBuilder") + logger.warning("GhostTools.dmg not found in any search path") } config.storageDevices = storageDevices diff --git a/macOS/GhostVMKit/Core/GhostClientProtocol.swift b/macOS/GhostVMKit/Core/GhostClientProtocol.swift index 6ac5592..56f0f62 100644 --- a/macOS/GhostVMKit/Core/GhostClientProtocol.swift +++ b/macOS/GhostVMKit/Core/GhostClientProtocol.swift @@ -6,7 +6,7 @@ public protocol GhostClientProtocol { func getClipboard() async throws -> ClipboardGetResponse func setClipboard(data: Data, type: String) async throws func sendFile(fileURL: URL, relativePath: String?, batchID: String?, isLastInBatch: Bool, permissions: Int?, progressHandler: ((Double) -> Void)?) async throws -> String - func fetchFile(at path: String) async throws -> (data: Data, filename: String, permissions: Int?) + func fetchFile(at path: String, to destinationURL: URL, progress: @escaping (Double) -> Void) async throws -> (filename: String, permissions: Int?) func listFiles() async throws -> [String] func clearFileQueue() async throws func checkHealth() async -> Bool diff --git a/macOS/GhostVMKit/Core/VMHelperBundleManager.swift b/macOS/GhostVMKit/Core/VMHelperBundleManager.swift index 5664dc7..57711f4 100644 --- a/macOS/GhostVMKit/Core/VMHelperBundleManager.swift +++ b/macOS/GhostVMKit/Core/VMHelperBundleManager.swift @@ -43,6 +43,8 @@ public final class VMHelperBundleManager { // Copy GhostTools.dmg as a sibling of the helper .app (not inside it) // so the signed bundle remains unmodified after copy. copyGhostToolsDMG(into: layout.helperDirectoryURL) + // Copy vmctl.app as a sibling too — used by the Terminal toolbar item. + copyVmctlApp(into: layout.helperDirectoryURL) // Rename to VM-named .app folder for Dock/CMD+TAB label try fileManager.moveItem(at: tempHelperURL, to: finalHelperURL) @@ -119,6 +121,23 @@ public final class VMHelperBundleManager { try? fileManager.copyItem(at: dmgURL, to: destURL) } + /// Copies vmctl.app from the main GhostVM.app bundle into the helper directory + /// so `vmctl shell` is available from the VM bundle. + /// Fails silently if vmctl.app is not found. + private func copyVmctlApp(into helperDirectoryURL: URL) { + guard let mainBundleURL = Bundle.main.bundleURL as URL? else { return } + let vmctlSource = mainBundleURL + .appendingPathComponent("Contents/PlugIns/Helpers/vmctl.app") + guard fileManager.fileExists(atPath: vmctlSource.path) else { return } + + let destURL = helperDirectoryURL.appendingPathComponent("vmctl.app") + if fileManager.fileExists(atPath: destURL.path) { + try? fileManager.removeItem(at: destURL) + } + try? fileManager.copyItem(at: vmctlSource, to: destURL) + stripExtendedAttributes(from: destURL) + } + /// Strips all extended attributes from a bundle recursively. /// This removes com.apple.FinderInfo and similar detritus that copyfile(3) /// carries over, which would otherwise cause strict codesign verification to fail. diff --git a/macOS/GhostVMKit/Operations/InitOptions.swift b/macOS/GhostVMKit/Operations/InitOptions.swift index c1dc422..4701b4f 100644 --- a/macOS/GhostVMKit/Operations/InitOptions.swift +++ b/macOS/GhostVMKit/Operations/InitOptions.swift @@ -6,6 +6,7 @@ public struct InitOptions { public var memoryGiB: UInt64 public var diskGiB: UInt64 public var restoreImagePath: String? + public var diskImageFormat: DiskImageFormat public var sharedFolderPath: String? public var sharedFolderWritable: Bool public var sharedFolders: [SharedFolderConfig] @@ -16,6 +17,7 @@ public struct InitOptions { memoryGiB: UInt64 = 8, diskGiB: UInt64 = 256, restoreImagePath: String? = nil, + diskImageFormat: DiskImageFormat = .defaultForCurrentHost, sharedFolderPath: String? = nil, sharedFolderWritable: Bool = false, sharedFolders: [SharedFolderConfig] = [], @@ -25,6 +27,7 @@ public struct InitOptions { self.memoryGiB = memoryGiB self.diskGiB = diskGiB self.restoreImagePath = restoreImagePath + self.diskImageFormat = diskImageFormat self.sharedFolderPath = sharedFolderPath self.sharedFolderWritable = sharedFolderWritable self.sharedFolders = sharedFolders diff --git a/macOS/GhostVMKit/Operations/VMController.swift b/macOS/GhostVMKit/Operations/VMController.swift index a42d252..c493e60 100644 --- a/macOS/GhostVMKit/Operations/VMController.swift +++ b/macOS/GhostVMKit/Operations/VMController.swift @@ -396,12 +396,7 @@ public final class VMController { throw VMError.message("Failed to create auxiliary storage: \(error.localizedDescription)") } - if !fileManager.createFile(atPath: layout.diskURL.path, contents: nil, attributes: nil) { - throw VMError.message("Failed to create disk image at \(layout.diskURL.path).") - } - let handle = try FileHandle(forWritingTo: layout.diskURL) - try handle.truncate(atOffset: requestedDiskBytes) - try handle.close() + try createDiskImage(at: layout.diskURL, sizeBytes: requestedDiskBytes, format: options.diskImageFormat) // Handle legacy single shared folder var sharedFolderAbsolute: String? @@ -469,6 +464,44 @@ public final class VMController { try initVM(at: bundleURL(for: name), preferredName: name, options: options) } + private func createDiskImage(at url: URL, sizeBytes: UInt64, format: DiskImageFormat) throws { + switch format { + case .sparseFile: + if !fileManager.createFile(atPath: url.path, contents: nil, attributes: nil) { + throw VMError.message("Failed to create disk image at \(url.path).") + } + let handle = try FileHandle(forWritingTo: url) + try handle.truncate(atOffset: sizeBytes) + try handle.close() + + case .asif: + guard DiskImageFormat.isASIFCreationSupported else { + throw VMError.message("ASIF disk images require macOS 26 or later. Choose Sparse File on this Mac.") + } + + let diskSizeGiB = max(1, sizeBytes / (1024 * 1024 * 1024)) + let diskutil = Process() + diskutil.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + diskutil.arguments = ["image", "create", "blank", + "--fs", "none", + "--format", "ASIF", + "--size", "\(diskSizeGiB)GiB", + url.path] + + let stderrPipe = Pipe() + diskutil.standardError = stderrPipe + try diskutil.run() + diskutil.waitUntilExit() + + guard diskutil.terminationStatus == 0 else { + let detail = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let suffix = detail.isEmpty ? "" : ": \(detail)" + throw VMError.message("Failed to create ASIF disk image (exit \(diskutil.terminationStatus))\(suffix)") + } + } + } + // MARK: - Rename VM public func renameVM(bundleURL: URL, newName: String) throws -> URL { @@ -541,10 +574,11 @@ public final class VMController { throw VMError.message("VM '\(sourceName)' is not installed. Only installed VMs can be cloned.") } - // Source must not be running - guard !isVMProcessRunning(layout: sourceLayout) else { - throw VMError.message("VM '\(sourceName)' is running. Stop it before cloning.") - } + // Cloning a running VM is allowed. clonefile() produces a crash-consistent + // copy of the disk (equivalent to sudden power loss), which macOS recovers + // from on boot via its journaled filesystem. The clone also gets a fresh + // machine identifier, MAC address, and cleared per-instance state below, so + // there is no identity collision or state leakage with the running source. // Create new bundle in the same parent directory let parentDir = bundleURL.deletingLastPathComponent() @@ -576,6 +610,27 @@ public final class VMController { } } + // Clone the Helper directory (per-VM helper app + GhostTools.dmg) so the + // clone is self-contained when launched directly via its Dock icon, which + // does not re-provision the helper. clonefile() copies directories + // recursively on APFS. The helper app bundle is named after the source VM, + // so rename it to match the clone (mirrors renameVM()). + let sourceHelperDir = sourceLayout.helperDirectoryURL + if fileManager.fileExists(atPath: sourceHelperDir.path) { + let destHelperDir = newLayout.helperDirectoryURL + if Darwin.clonefile(sourceHelperDir.path, destHelperDir.path, 0) == -1 { + let err = String(cString: strerror(errno)) + throw VMError.message("Clone failed for Helper directory: \(err). This volume may not support copy-on-write. Move your VMs to an APFS volume.") + } + let clonedHelperApp = destHelperDir.appendingPathComponent( + sourceLayout.helperAppURL(vmName: sourceName).lastPathComponent) + let destHelperApp = newLayout.helperAppURL(vmName: trimmed) + if clonedHelperApp.path != destHelperApp.path, + fileManager.fileExists(atPath: clonedHelperApp.path) { + try fileManager.moveItem(at: clonedHelperApp, to: destHelperApp) + } + } + // Generate fresh identifiers let machineIdentifier = VZMacMachineIdentifier() try writeData(machineIdentifier.dataRepresentation, to: newLayout.machineIdentifierURL) @@ -1040,6 +1095,51 @@ public final class VMController { try discardSuspend(bundleURL: bundleURL(for: name)) } + /// Suspend a running helper-backed VM by asking its helper process to save state. + /// Reuses the existing `com.ghostvm.helper.suspend.` notification that the + /// helper already listens for, then blocks until the helper exits and the saved + /// suspend state is verified on disk. + public func suspendVM(bundleURL: URL, timeout: TimeInterval = 300) throws { + let standardized = bundleURL.standardizedFileURL + let layout = try layoutForExistingBundle(at: standardized) + let entry = try loadEntry(for: standardized) + let vmName = displayName(for: standardized) + + guard let pid = entry.runningPID else { + print(entry.isSuspended ? "VM '\(vmName)' is already suspended." : "VM '\(vmName)' does not appear to be running.") + return + } + + let bundlePathHash = standardized.path.stableHash + print("Sending suspend request to VM '\(vmName)' (PID \(pid)).") + DistributedNotificationCenter.default().postNotificationName( + NSNotification.Name("com.ghostvm.helper.suspend.\(bundlePathHash)"), + object: nil, + userInfo: nil, + deliverImmediately: true + ) + + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if kill(pid, 0) != 0 { + let config = try VMConfigStore(layout: layout).load() + guard config.isSuspended, fileManager.fileExists(atPath: layout.suspendStateURL.path) else { + throw VMError.message("VM '\(vmName)' exited before saving suspended state.") + } + removeVMLock(at: layout.pidFileURL) + print("VM '\(vmName)' suspended.") + return + } + Thread.sleep(forTimeInterval: 1) + } + + throw VMError.message("Timed out waiting for VM '\(vmName)' to suspend.") + } + + public func suspendVM(name: String, timeout: TimeInterval = 300) throws { + try suspendVM(bundleURL: bundleURL(for: name), timeout: timeout) + } + // MARK: - CLI Start/Resume (blocking) /// Start a VM from the CLI. Blocks until the VM terminates. @@ -1194,6 +1294,8 @@ public final class VMController { try store.save(config) } + try validateDiskFormat(layout: layout) + if let owner = readVMLockOwner(from: layout.pidFileURL) { if kill(owner.pid, 0) == 0 { if owner.isEmbedded { @@ -1228,6 +1330,8 @@ public final class VMController { try store.save(config) } + try validateDiskFormat(layout: layout) + if let owner = readVMLockOwner(from: layout.pidFileURL) { if kill(owner.pid, 0) == 0 { if owner.isEmbedded { @@ -1254,4 +1358,18 @@ public final class VMController { } return false } + + private func validateDiskFormat(layout: VMFileLayout) throws { + let format = DiskFormat.detect(at: layout.diskURL) + guard format.isSupportedForCurrentHost else { + switch format { + case .asif: + throw VMError.message("This VM uses an ASIF disk image, which requires macOS 26 or later.") + case .unknown: + throw VMError.message("Could not identify the VM disk image format.") + case .raw: + throw VMError.message("This VM disk image format is not supported on this Mac.") + } + } + } } diff --git a/macOS/GhostVMKit/Operations/VMMigrationService.swift b/macOS/GhostVMKit/Operations/VMMigrationService.swift new file mode 100644 index 0000000..6fd2ca6 --- /dev/null +++ b/macOS/GhostVMKit/Operations/VMMigrationService.swift @@ -0,0 +1,238 @@ +import Foundation +import os + +/// Migrates a VM bundle from a sparse disk.img to ASIF format. +/// The source bundle is NEVER modified — the migration creates a new bundle at the destination. +public final class VMMigrationService: NSObject { + + private static let logger = Logger(subsystem: "org.ghostvm", category: "VMMigrationService") + + public enum MigrationError: Error, LocalizedError { + case sourceNotFound + case diskNotFound + case destinationMatchesSource + case destinationAlreadyExists + case asifCreationFailed(Int32, String) + case moveFailed(String) + case cancelled + case unsupportedHost + + public var errorDescription: String? { + switch self { + case .sourceNotFound: return "Source VM bundle not found" + case .diskNotFound: return "Source disk.img not found" + case .destinationMatchesSource: return "Choose a different destination for the migrated VM" + case .destinationAlreadyExists: return "Choose a destination that does not already exist" + case .asifCreationFailed(let code, let detail): + return detail.isEmpty ? "Failed to create ASIF image (exit \(code))" : detail + case .moveFailed(let msg): return "Failed to finalize migration: \(msg)" + case .cancelled: return "Migration cancelled" + case .unsupportedHost: return "ASIF migration requires macOS 26 or later." + } + } + } + + private let fileManager = FileManager.default + private let cancelLock = NSLock() + private var _isCancelled = false + private var diskutilProcess: Process? + + private var isCancelled: Bool { + cancelLock.lock() + defer { cancelLock.unlock() } + return _isCancelled + } + + public override init() { super.init() } + + /// Cancel the in-progress migration. Safe to call from any thread. + @objc public func cancel() { + cancelLock.lock() + _isCancelled = true + let process = diskutilProcess + cancelLock.unlock() + process?.terminate() + } + + /// Migrate a VM bundle to ASIF format. + /// + /// - Parameters: + /// - source: URL of the existing .GhostVM bundle + /// - destination: URL for the new .GhostVM bundle (must not exist) + /// - progressHandler: Called with (fractionCompleted, statusMessage) + /// - outputHandler: Called with each line of diskutil output for terminal display + public func migrate( + source: URL, + destination: URL, + progressHandler: @escaping (Double, String) -> Void, + outputHandler: @escaping (String) -> Void = { _ in } + ) throws { + cancelLock.lock() + _isCancelled = false + diskutilProcess = nil + cancelLock.unlock() + + let srcLayout = VMFileLayout(bundleURL: source) + + guard DiskImageFormat.isASIFCreationSupported else { + throw MigrationError.unsupportedHost + } + + guard fileManager.fileExists(atPath: source.path) else { + throw MigrationError.sourceNotFound + } + guard fileManager.fileExists(atPath: srcLayout.diskURL.path) else { + throw MigrationError.diskNotFound + } + try validateDestination(source: source, destination: destination) + + // Work in a temp directory, then atomic move to destination. + // Partial failures only clean this temp bundle; the chosen destination + // is touched during finalization after conversion succeeds. + let tempDir = fileManager.temporaryDirectory + .appendingPathComponent("ghostvm-migration-\(UUID().uuidString).GhostVM") + let dstLayout = VMFileLayout(bundleURL: tempDir) + try dstLayout.ensureBundleDirectory() + + // Copy non-disk files first (small, fast) + progressHandler(0.0, "Copying VM configuration...") + let filesToCopy: [(String, URL, URL)] = [ + ("config.json", srcLayout.configURL, dstLayout.configURL), + ("HardwareModel.bin", srcLayout.hardwareModelURL, dstLayout.hardwareModelURL), + ("MachineIdentifier.bin", srcLayout.machineIdentifierURL, dstLayout.machineIdentifierURL), + ("AuxiliaryStorage.bin", srcLayout.auxiliaryStorageURL, dstLayout.auxiliaryStorageURL), + ] + + for (_, src, dst) in filesToCopy { + if fileManager.fileExists(atPath: src.path) { + try fileManager.copyItem(at: src, to: dst) + } + if isCancelled { cleanup(tempDir); throw MigrationError.cancelled } + } + + // Copy optional files (icon, etc.) + if fileManager.fileExists(atPath: srcLayout.customIconURL.path) { + try? fileManager.copyItem(at: srcLayout.customIconURL, to: dstLayout.customIconURL) + } + + if isCancelled { cleanup(tempDir); throw MigrationError.cancelled } + + // Convert sparse disk to ASIF using diskutil (handles sparsity natively) + progressHandler(0.1, "Converting disk to ASIF format...") + let diskutil = Process() + diskutil.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + diskutil.arguments = ["image", "create", "from", + "--verbose", + "--format", "ASIF", + srcLayout.diskURL.path, + dstLayout.diskURL.path] + + let stderrPipe = Pipe() + let stdoutPipe = Pipe() + diskutil.standardOutput = stdoutPipe + diskutil.standardError = stderrPipe + + // Collect stderr for error reporting (synchronized) + let stderrLock = NSLock() + var stderrOutput = "" + + stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return } + if self?.isCancelled == true { return } + outputHandler(text) + } + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return } + if self?.isCancelled == true { return } + stderrLock.lock() + stderrOutput += text + stderrLock.unlock() + outputHandler(text) + } + + // Store process reference so cancel() can terminate it + cancelLock.lock() + diskutilProcess = diskutil + cancelLock.unlock() + + try diskutil.run() + diskutil.waitUntilExit() + + cancelLock.lock() + diskutilProcess = nil + cancelLock.unlock() + + // Stop handlers BEFORE draining to avoid concurrent access + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + + // Drain remaining pipe data + if let remaining = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8), !remaining.isEmpty { + outputHandler(remaining) + } + if let remaining = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8), !remaining.isEmpty { + stderrLock.lock() + stderrOutput += remaining + stderrLock.unlock() + outputHandler(remaining) + } + + guard diskutil.terminationStatus == 0 else { + stderrLock.lock() + let detail = stderrOutput.trimmingCharacters(in: .whitespacesAndNewlines) + stderrLock.unlock() + Self.logger.error("diskutil failed (exit \(diskutil.terminationStatus)): \(detail, privacy: .public)") + cleanup(tempDir) + throw MigrationError.asifCreationFailed(diskutil.terminationStatus, detail) + } + + if isCancelled { cleanup(tempDir); throw MigrationError.cancelled } + + // Verify the output is valid ASIF + let outputFormat = DiskFormat.detect(at: dstLayout.diskURL) + guard outputFormat == .asif else { + Self.logger.error("diskutil produced non-ASIF output: \(outputFormat.rawValue, privacy: .public)") + cleanup(tempDir) + throw MigrationError.asifCreationFailed(0, "Conversion completed but output is not ASIF format") + } + + if isCancelled { cleanup(tempDir); throw MigrationError.cancelled } + + // Atomic move into the destination validated before migration started. + progressHandler(0.95, "Finalizing...") + do { + try fileManager.moveItem(at: tempDir, to: destination) + } catch { + cleanup(tempDir) + throw MigrationError.moveFailed(error.localizedDescription) + } + + progressHandler(1.0, "Migration complete") + } + + private func cleanup(_ destination: URL) { + do { + try fileManager.removeItem(at: destination) + } catch { + Self.logger.warning("Failed to clean up partial migration at \(destination.path, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + + func validateDestination(source: URL, destination: URL) throws { + let normalizedSource = source.resolvingSymlinksInPath().standardizedFileURL + let normalizedDestination = destination.resolvingSymlinksInPath().standardizedFileURL + if normalizedSource.path == normalizedDestination.path { + throw MigrationError.destinationMatchesSource + } + + guard fileManager.fileExists(atPath: normalizedDestination.path) else { return } + let sourceID = try? normalizedSource.resourceValues(forKeys: [.fileResourceIdentifierKey]).fileResourceIdentifier as? NSObject + let destinationID = try? normalizedDestination.resourceValues(forKeys: [.fileResourceIdentifierKey]).fileResourceIdentifier as? NSObject + if let sourceID, let destinationID, sourceID.isEqual(destinationID) { + throw MigrationError.destinationMatchesSource + } + throw MigrationError.destinationAlreadyExists + } +} diff --git a/macOS/GhostVMKit/Utilities/DiskFormatUtilities.swift b/macOS/GhostVMKit/Utilities/DiskFormatUtilities.swift new file mode 100644 index 0000000..512eb99 --- /dev/null +++ b/macOS/GhostVMKit/Utilities/DiskFormatUtilities.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Disk image formats GhostVM can create for VM root disks. +public enum DiskImageFormat: String, CaseIterable { + /// Sparse file created with ftruncate(). + case sparseFile = "sparse-file" + /// Apple Sparse Image Format, created by diskutil on macOS 26+. + case asif + + public static var defaultForCurrentHost: DiskImageFormat { + if isASIFCreationSupported { + return .asif + } + return .sparseFile + } + + public static var isASIFCreationSupported: Bool { + if #available(macOS 26.0, *) { + return true + } + return false + } + + public var isSupportedForCreation: Bool { + switch self { + case .sparseFile: + return true + case .asif: + return Self.isASIFCreationSupported + } + } +} + +/// Identifies the format of a VM disk image. +public enum DiskFormat: String { + /// Raw sparse file (created via truncate) + case raw + /// Apple Sparse Image Format (macOS 26+) + case asif + /// Unrecognized format + case unknown + + /// ASIF magic bytes: "shdw" + private static let asifMagic: [UInt8] = [0x73, 0x68, 0x64, 0x77] + + /// Detect the disk format by reading the first 4 bytes. + public static func detect(at url: URL) -> DiskFormat { + guard let handle = FileHandle(forReadingAtPath: url.path) else { + return .unknown + } + defer { try? handle.close() } + + guard let data = try? handle.read(upToCount: 4), data.count == 4 else { + return .unknown + } + + if data.elementsEqual(asifMagic) { + return .asif + } + + // Raw sparse files start with zeros (empty partition table area) + // or with an EFI partition header (after macOS install) + return .raw + } + + public var isASIF: Bool { self == .asif } + + public var isSupportedForCurrentHost: Bool { + switch self { + case .raw: + return true + case .asif: + return DiskImageFormat.isASIFCreationSupported + case .unknown: + return false + } + } +} diff --git a/macOS/GhostVMKit/Utilities/HTTPResponseParser.swift b/macOS/GhostVMKit/Utilities/HTTPResponseParser.swift deleted file mode 100644 index 4c46ac9..0000000 --- a/macOS/GhostVMKit/Utilities/HTTPResponseParser.swift +++ /dev/null @@ -1,142 +0,0 @@ -import Foundation - -/// Parses raw HTTP response data into status code and body. -/// Extracted from GhostClient for testability. -public enum HTTPResponseParser { - /// Parse HTTP response treating body as UTF-8 text - public static func parse(_ data: Data) throws -> (statusCode: Int, body: Data?) { - guard let responseString = String(data: data, encoding: .utf8) else { - throw GhostClientError.decodingError - } - - // Split headers and body - let parts = responseString.components(separatedBy: "\r\n\r\n") - guard parts.count >= 1 else { - throw GhostClientError.decodingError - } - - let headerSection = parts[0] - let bodyString = parts.count > 1 ? parts[1] : nil - - // Parse status line - let headerLines = headerSection.components(separatedBy: "\r\n") - guard let statusLine = headerLines.first else { - throw GhostClientError.decodingError - } - - // Parse status code from "HTTP/1.1 200 OK" - let statusParts = statusLine.components(separatedBy: " ") - guard statusParts.count >= 2, - let statusCode = Int(statusParts[1]) else { - throw GhostClientError.decodingError - } - - let body = bodyString?.data(using: .utf8) - - return (statusCode, body) - } - - /// Parse HTTP response preserving binary body data - public static func parseBinary(_ data: Data) throws -> (statusCode: Int, body: Data?) { - // Find the header/body separator (CRLFCRLF) - let separator = Data([0x0D, 0x0A, 0x0D, 0x0A]) // \r\n\r\n - guard let separatorRange = data.range(of: separator) else { - // No body, try parsing header only - guard let responseString = String(data: data, encoding: .utf8) else { - throw GhostClientError.decodingError - } - - let headerLines = responseString.components(separatedBy: "\r\n") - guard let statusLine = headerLines.first else { - throw GhostClientError.decodingError - } - - let statusParts = statusLine.components(separatedBy: " ") - guard statusParts.count >= 2, - let statusCode = Int(statusParts[1]) else { - throw GhostClientError.decodingError - } - - return (statusCode, nil) - } - - // Parse header section - let headerData = data[..= 2, - let statusCode = Int(statusParts[1]) else { - throw GhostClientError.decodingError - } - - // Extract binary body - let bodyData = data[separatorRange.upperBound...] - return (statusCode, bodyData.isEmpty ? nil : Data(bodyData)) - } - - /// Parse HTTP response preserving binary body data and returning headers - public static func parseBinaryWithHeaders(_ data: Data) throws -> (statusCode: Int, headers: [String: String], body: Data?) { - // Find the header/body separator (CRLFCRLF) - let separator = Data([0x0D, 0x0A, 0x0D, 0x0A]) // \r\n\r\n - guard let separatorRange = data.range(of: separator) else { - guard let responseString = String(data: data, encoding: .utf8) else { - throw GhostClientError.decodingError - } - - let headerLines = responseString.components(separatedBy: "\r\n") - guard let statusLine = headerLines.first else { - throw GhostClientError.decodingError - } - - let statusParts = statusLine.components(separatedBy: " ") - guard statusParts.count >= 2, - let statusCode = Int(statusParts[1]) else { - throw GhostClientError.decodingError - } - - let headers = parseHeaders(headerLines) - return (statusCode, headers, nil) - } - - let headerData = data[..= 2, - let statusCode = Int(statusParts[1]) else { - throw GhostClientError.decodingError - } - - let headers = parseHeaders(headerLines) - let bodyData = data[separatorRange.upperBound...] - return (statusCode, headers, bodyData.isEmpty ? nil : Data(bodyData)) - } - - /// Parse header lines (skipping the status line) into a dictionary - private static func parseHeaders(_ headerLines: [String]) -> [String: String] { - var headers: [String: String] = [:] - for line in headerLines.dropFirst() { - if let colonIndex = line.firstIndex(of: ":") { - let key = String(line[.. Data { - var httpRequest = "\(method) \(path) HTTP/1.1\r\n" - httpRequest += "Host: localhost\r\n" - httpRequest += "Connection: close\r\n" - - // Add all custom headers - for (key, value) in headers { - httpRequest += "\(key): \(value)\r\n" - } - - // Add Content-Length if body present - if let body = body { - httpRequest += "Content-Length: \(body.count)\r\n" - } - - httpRequest += "\r\n" - - // Convert to data and append body (binary-safe) - var requestData = Data(httpRequest.utf8) - if let body = body { - requestData.append(body) - } - - return requestData - } - - // MARK: - HTTP Response Building - - /// Build a complete HTTP/1.1 response as Data (binary-safe) - /// - /// - Parameters: - /// - status: HTTP status (use HTTPStatus.ok, etc.) - /// - contentType: Content-Type header value - /// - body: Response body (binary-safe) - /// - Returns: Complete HTTP response ready to send over a socket - public static func buildResponse( - status: HTTPStatus, - contentType: String, - body: Data - ) -> Data { - var httpResponse = "HTTP/1.1 \(status.rawValue) \(status.reasonPhrase)\r\n" - httpResponse += "Content-Type: \(contentType)\r\n" - httpResponse += "Content-Length: \(body.count)\r\n" - httpResponse += "Connection: close\r\n" - httpResponse += "\r\n" - - var responseData = Data(httpResponse.utf8) - responseData.append(body) - - return responseData - } - - /// Build a JSON response - /// - Parameters: - /// - jsonData: JSON-encoded data - /// - status: HTTP status code (default: 200 OK) - /// - Returns: Complete HTTP response - public static func buildJSONResponse( - _ jsonData: Data, - status: HTTPStatus = .ok - ) -> Data { - return buildResponse( - status: status, - contentType: "application/json", - body: jsonData - ) - } - - /// Build an error response - /// - Parameters: - /// - status: HTTP status code - /// - message: Error message - /// - Returns: Complete HTTP error response with JSON body - public static func buildErrorResponse( - status: HTTPStatus, - message: String - ) -> Data { - let payload = ["error": message] - let body = (try? JSONSerialization.data(withJSONObject: payload)) ?? - Data(#"{"error":"unknown"}"#.utf8) - return buildJSONResponse(body, status: status) - } } diff --git a/macOS/GhostVMKit/Utilities/WebSocketShellClient.swift b/macOS/GhostVMKit/Utilities/WebSocketShellClient.swift new file mode 100644 index 0000000..75d8ed0 --- /dev/null +++ b/macOS/GhostVMKit/Utilities/WebSocketShellClient.swift @@ -0,0 +1,336 @@ +import Foundation +import Dispatch +#if canImport(Darwin) +import Darwin +#endif + +public struct WebSocketClientFrame { + public let opcode: UInt8 + public let payload: [UInt8] + + public init(opcode: UInt8, payload: [UInt8]) { + self.opcode = opcode + self.payload = payload + } +} + +public struct WebSocketClientFrameParser { + private static let maxPayloadBytes = 16 * 1024 * 1024 + private var buffer = [UInt8]() + + public init() {} + + public mutating func feed(_ data: [UInt8]) { + buffer.append(contentsOf: data) + } + + public mutating func nextFrame() -> WebSocketClientFrame? { + guard buffer.count >= 2 else { return nil } + + let fin = (buffer[0] & 0x80) != 0 + let opcode = buffer[0] & 0x0F + guard opcode != 0x00 else { + buffer.removeAll() + return WebSocketClientFrame(opcode: 0x08, payload: []) + } + guard fin else { + buffer.removeAll() + return WebSocketClientFrame(opcode: 0x08, payload: []) + } + let masked = (buffer[1] & 0x80) != 0 + var payloadLen = Int(buffer[1] & 0x7F) + var offset = 2 + + if payloadLen == 126 { + guard buffer.count >= offset + 2 else { return nil } + payloadLen = Int(buffer[offset]) << 8 | Int(buffer[offset + 1]) + offset += 2 + } else if payloadLen == 127 { + guard buffer.count >= offset + 8 else { return nil } + payloadLen = 0 + for i in 0..<8 { + payloadLen = (payloadLen << 8) | Int(buffer[offset + i]) + } + offset += 8 + } + + guard payloadLen >= 0 && payloadLen <= Self.maxPayloadBytes else { + buffer.removeAll() + return WebSocketClientFrame(opcode: 0x08, payload: []) + } + + var maskKey = [UInt8]() + if masked { + guard buffer.count >= offset + 4 else { return nil } + maskKey = Array(buffer[offset..= offset + payloadLen else { return nil } + var payload = Array(buffer[offset.. [UInt8] { + var frame = [UInt8]() + frame.append(0x80 | opcode) + + let len = payload.count + if len < 126 { + frame.append(0x80 | UInt8(len)) + } else if len < 65536 { + frame.append(0x80 | 126) + frame.append(UInt8((len >> 8) & 0xFF)) + frame.append(UInt8(len & 0xFF)) + } else { + frame.append(0x80 | 127) + for i in (0..<8).reversed() { + frame.append(UInt8((len >> (i * 8)) & 0xFF)) + } + } + + var maskKey = [UInt8](repeating: 0, count: 4) + arc4random_buf(&maskKey, 4) + frame.append(contentsOf: maskKey) + for (i, byte) in payload.enumerated() { + frame.append(byte ^ maskKey[i % 4]) + } + return frame + } +} + +public enum WebSocketShellClient { + public struct Configuration { + public let socketFD: Int32 + public let inputFD: Int32 + public let outputFD: Int32 + public let prelude: Data + public let installWindowResizeHandler: Bool + public let installInterruptHandler: Bool + public let onControlMessage: @Sendable (String) -> Void + + public init( + socketFD: Int32, + inputFD: Int32, + outputFD: Int32, + prelude: Data = Data(), + installWindowResizeHandler: Bool = false, + installInterruptHandler: Bool = false, + onControlMessage: @escaping @Sendable (String) -> Void = { _ in } + ) { + self.socketFD = socketFD + self.inputFD = inputFD + self.outputFD = outputFD + self.prelude = prelude + self.installWindowResizeHandler = installWindowResizeHandler + self.installInterruptHandler = installInterruptHandler + self.onControlMessage = onControlMessage + } + } + + public static func run(configuration: Configuration) { + let socketFD = configuration.socketFD + let inputFD = configuration.inputFD + let outputFD = configuration.outputFD + + let ioQueue = DispatchQueue(label: "ghostvm.websocket-shell-client.io") + var wsParser = WebSocketClientFrameParser() + if !configuration.prelude.isEmpty { + wsParser.feed(Array(configuration.prelude)) + } + var socketWriteBuffer = [UInt8]() + var writeSourceResumed = false + var hasShutdown = false + var shouldShutdownAfterWriteDrain = false + let exitGroup = DispatchGroup() + exitGroup.enter() + let cancellationGroup = DispatchGroup() + + let socketWriteSource = DispatchSource.makeWriteSource(fileDescriptor: socketFD, queue: ioQueue) + cancellationGroup.enter() + socketWriteSource.setCancelHandler { + cancellationGroup.leave() + } + let inputSource = DispatchSource.makeReadSource(fileDescriptor: inputFD, queue: ioQueue) + cancellationGroup.enter() + inputSource.setCancelHandler { + cancellationGroup.leave() + } + let socketReadSource = DispatchSource.makeReadSource(fileDescriptor: socketFD, queue: ioQueue) + cancellationGroup.enter() + socketReadSource.setCancelHandler { + cancellationGroup.leave() + } + + let winchSource: DispatchSourceSignal? + if configuration.installWindowResizeHandler { + signal(SIGWINCH, SIG_IGN) + winchSource = DispatchSource.makeSignalSource(signal: SIGWINCH, queue: ioQueue) + cancellationGroup.enter() + winchSource?.setCancelHandler { + cancellationGroup.leave() + } + } else { + winchSource = nil + } + + let intSource: DispatchSourceSignal? + if configuration.installInterruptHandler { + signal(SIGINT, SIG_IGN) + intSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: ioQueue) + cancellationGroup.enter() + intSource?.setCancelHandler { + cancellationGroup.leave() + } + } else { + intSource = nil + } + + func enqueueWrite(_ bytes: [UInt8]) { + socketWriteBuffer.append(contentsOf: bytes) + if !writeSourceResumed { + writeSourceResumed = true + socketWriteSource.resume() + } + } + + func shutdown() { + guard !hasShutdown else { return } + hasShutdown = true + if !inputSource.isCancelled { + inputSource.cancel() + } + socketReadSource.cancel() + if !writeSourceResumed { + socketWriteSource.resume() + } + socketWriteSource.cancel() + winchSource?.cancel() + intSource?.cancel() + exitGroup.leave() + } + + func processBufferedFrames() { + while let frame = wsParser.nextFrame() { + switch frame.opcode { + case 0x02: + frame.payload.withUnsafeBufferPointer { ptr in + guard let base = ptr.baseAddress, ptr.count > 0 else { return } + var off = 0 + while off < ptr.count { + let n = Darwin.write(outputFD, base + off, ptr.count - off) + if n > 0 { + off += n + } else if n < 0 && errno == EINTR { + continue + } else { + shutdown() + return + } + } + } + case 0x01: + if let text = String(bytes: frame.payload, encoding: .utf8) { + configuration.onControlMessage(text) + } + case 0x08: + shutdown() + case 0x09: + enqueueWrite(WebSocketClientFrames.makeMaskedFrame(opcode: 0x0A, payload: frame.payload)) + default: + break + } + if hasShutdown { + return + } + } + } + + socketWriteSource.setEventHandler { + guard !socketWriteBuffer.isEmpty else { + writeSourceResumed = false + socketWriteSource.suspend() + return + } + let n = socketWriteBuffer.withUnsafeBufferPointer { ptr -> Int in + guard let base = ptr.baseAddress else { return 0 } + return Darwin.write(socketFD, base, ptr.count) + } + if n > 0 { + socketWriteBuffer.removeFirst(n) + if socketWriteBuffer.isEmpty { + if shouldShutdownAfterWriteDrain { + shutdown() + return + } + writeSourceResumed = false + socketWriteSource.suspend() + } + } else if n < 0 && errno != EAGAIN && errno != EINTR { + shutdown() + } + } + + inputSource.setEventHandler { + var buf = [UInt8](repeating: 0, count: 4096) + let n = Darwin.read(inputFD, &buf, buf.count) + if n > 0 { + enqueueWrite(WebSocketClientFrames.makeMaskedFrame(opcode: 0x02, payload: Array(buf[0.. 0 { + wsParser.feed(Array(buf[0.. 0 && ws.ws_row > 0 { + let json = "{\"type\":\"resize\",\"cols\":\(ws.ws_col),\"rows\":\(ws.ws_row)}" + enqueueWrite(WebSocketClientFrames.makeMaskedFrame(opcode: 0x01, payload: Array(json.utf8))) + } + } + + intSource?.setEventHandler { + enqueueWrite(WebSocketClientFrames.makeMaskedFrame(opcode: 0x02, payload: [3])) + } + + inputSource.resume() + socketReadSource.resume() + winchSource?.resume() + intSource?.resume() + processBufferedFrames() + + exitGroup.wait() + cancellationGroup.wait() + ioQueue.sync {} + Darwin.close(socketFD) + } +} diff --git a/macOS/GhostVMTests/HTTPResponseParserTests.swift b/macOS/GhostVMTests/HTTPResponseParserTests.swift deleted file mode 100644 index 27b4e58..0000000 --- a/macOS/GhostVMTests/HTTPResponseParserTests.swift +++ /dev/null @@ -1,83 +0,0 @@ -import XCTest -@testable import GhostVMKit - -final class HTTPResponseParserTests: XCTestCase { - func testParse200WithJSONBody() throws { - let raw = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"content\":\"hello\"}" - let data = Data(raw.utf8) - let (statusCode, body) = try HTTPResponseParser.parse(data) - - XCTAssertEqual(statusCode, 200) - XCTAssertNotNil(body) - - let json = try JSONSerialization.jsonObject(with: body!) as? [String: Any] - XCTAssertEqual(json?["content"] as? String, "hello") - } - - func testParse204NoBody() throws { - let raw = "HTTP/1.1 204 No Content\r\n\r\n" - let data = Data(raw.utf8) - let (statusCode, body) = try HTTPResponseParser.parse(data) - - XCTAssertEqual(statusCode, 204) - // Body may be nil or empty data - if let body = body { - XCTAssertTrue(body.isEmpty) - } - } - - func testParse404() throws { - let raw = "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\n\r\nNot Found" - let data = Data(raw.utf8) - let (statusCode, body) = try HTTPResponseParser.parse(data) - - XCTAssertEqual(statusCode, 404) - XCTAssertNotNil(body) - } - - func testMalformedDataThrows() { - let data = Data([0xFF, 0xFE, 0xFD]) // Not valid UTF-8 - XCTAssertThrowsError(try HTTPResponseParser.parse(data)) - } - - func testBinaryBodyPreservesBytes() throws { - var rawData = Data("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n\r\n".utf8) - let binaryBody = Data([0x00, 0x01, 0x02, 0xFF, 0xFE]) - rawData.append(binaryBody) - - let (statusCode, body) = try HTTPResponseParser.parseBinary(rawData) - - XCTAssertEqual(statusCode, 200) - XCTAssertEqual(body, binaryBody) - } - - func testResponseWithMultipleHeaders() throws { - let raw = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-Custom: value\r\nConnection: close\r\n\r\n{\"files\":[]}" - let data = Data(raw.utf8) - let (statusCode, body) = try HTTPResponseParser.parse(data) - - XCTAssertEqual(statusCode, 200) - XCTAssertNotNil(body) - } - - func testParseBinaryHeaderOnly() throws { - let raw = "HTTP/1.1 200 OK\r\nContent-Length: 0" - let data = Data(raw.utf8) - let (statusCode, body) = try HTTPResponseParser.parseBinary(data) - - XCTAssertEqual(statusCode, 200) - XCTAssertNil(body) - } - - func testEmptyBodyAfterSeparator() throws { - let raw = "HTTP/1.1 200 OK\r\n\r\n" - let data = Data(raw.utf8) - let (statusCode, body) = try HTTPResponseParser.parse(data) - - XCTAssertEqual(statusCode, 200) - // Empty body string converts to empty data - if let body = body { - XCTAssertTrue(body.isEmpty) - } - } -} diff --git a/macOS/GhostVMTests/HTTPUtilitiesTests.swift b/macOS/GhostVMTests/HTTPUtilitiesTests.swift index 7462e73..41b3474 100644 --- a/macOS/GhostVMTests/HTTPUtilitiesTests.swift +++ b/macOS/GhostVMTests/HTTPUtilitiesTests.swift @@ -1,320 +1,30 @@ import XCTest @testable import GhostVMKit -/// Comprehensive tests for HTTPUtilities -/// -/// These tests ensure: -/// - Query parameter parsing is correct (including URL encoding) -/// - HTTP status code mapping is accurate -/// - Request/response building is binary-safe -/// - No data corruption with NULL bytes or invalid UTF-8 final class HTTPUtilitiesTests: XCTestCase { - - // MARK: - Query Parsing Tests - func testQueryParsing() { - let path = "/api/v1/test?foo=bar" + let path = "/api/v1/test?foo=bar&message=hello%20world" XCTAssertEqual(HTTPUtilities.parseQuery(path, key: "foo"), "bar") - } - - func testQueryParsingWithSpaces() { - let path = "/api/v1/test?message=hello%20world" XCTAssertEqual(HTTPUtilities.parseQuery(path, key: "message"), "hello world") - } - - func testQueryParsingWithMultipleParams() { - let path = "/api/v1/test?foo=bar&baz=qux&name=value" - XCTAssertEqual(HTTPUtilities.parseQuery(path, key: "foo"), "bar") - XCTAssertEqual(HTTPUtilities.parseQuery(path, key: "baz"), "qux") - XCTAssertEqual(HTTPUtilities.parseQuery(path, key: "name"), "value") - } - - func testQueryParsingWithEncoding() { - let path = "/api/v1/test?name=John%20Doe&email=test%40example.com" - XCTAssertEqual(HTTPUtilities.parseQuery(path, key: "name"), "John Doe") - XCTAssertEqual(HTTPUtilities.parseQuery(path, key: "email"), "test@example.com") - } - - func testQueryParsingNoQueryString() { - let path = "/api/v1/test" - XCTAssertNil(HTTPUtilities.parseQuery(path, key: "foo")) - } - - func testQueryParsingMissingKey() { - let path = "/api/v1/test?foo=bar&baz=qux" - XCTAssertNil(HTTPUtilities.parseQuery(path, key: "missing")) + XCTAssertEqual(HTTPUtilities.parseQuery("/api/v1/test?token=a=b%3D", key: "token"), "a=b=") + XCTAssertNil(HTTPUtilities.parseQuery("/api/v1/test", key: "foo")) } func testBoolQueryParsing() { - let path1 = "/test?flag=1" - XCTAssertEqual(HTTPUtilities.parseBoolQuery(path1, key: "flag"), true) - - let path2 = "/test?flag=true" - XCTAssertEqual(HTTPUtilities.parseBoolQuery(path2, key: "flag"), true) - - let path3 = "/test?flag=yes" - XCTAssertEqual(HTTPUtilities.parseBoolQuery(path3, key: "flag"), true) - - let path4 = "/test?flag=0" - XCTAssertEqual(HTTPUtilities.parseBoolQuery(path4, key: "flag"), false) - - let path5 = "/test?flag=false" - XCTAssertEqual(HTTPUtilities.parseBoolQuery(path5, key: "flag"), false) - - let path6 = "/test?flag=no" - XCTAssertEqual(HTTPUtilities.parseBoolQuery(path6, key: "flag"), false) - - let path7 = "/test?flag=invalid" - XCTAssertNil(HTTPUtilities.parseBoolQuery(path7, key: "flag")) + XCTAssertEqual(HTTPUtilities.parseBoolQuery("/test?flag=1", key: "flag"), true) + XCTAssertEqual(HTTPUtilities.parseBoolQuery("/test?flag=false", key: "flag"), false) + XCTAssertNil(HTTPUtilities.parseBoolQuery("/test?flag=invalid", key: "flag")) } - // MARK: - Status Code Tests - func testHTTPStatusMapping() { XCTAssertEqual(HTTPUtilities.HTTPStatus.from(code: 200), .ok) - XCTAssertEqual(HTTPUtilities.HTTPStatus.from(code: 201), .created) - XCTAssertEqual(HTTPUtilities.HTTPStatus.from(code: 204), .noContent) - XCTAssertEqual(HTTPUtilities.HTTPStatus.from(code: 400), .badRequest) - XCTAssertEqual(HTTPUtilities.HTTPStatus.from(code: 401), .unauthorized) - XCTAssertEqual(HTTPUtilities.HTTPStatus.from(code: 403), .forbidden) XCTAssertEqual(HTTPUtilities.HTTPStatus.from(code: 404), .notFound) - XCTAssertEqual(HTTPUtilities.HTTPStatus.from(code: 405), .methodNotAllowed) - XCTAssertEqual(HTTPUtilities.HTTPStatus.from(code: 408), .requestTimeout) - XCTAssertEqual(HTTPUtilities.HTTPStatus.from(code: 500), .internalServerError) + XCTAssertEqual(HTTPUtilities.HTTPStatus.from(code: 999), .internalServerError) } func testHTTPStatusReasonPhrases() { XCTAssertEqual(HTTPUtilities.HTTPStatus.ok.reasonPhrase, "OK") - XCTAssertEqual(HTTPUtilities.HTTPStatus.created.reasonPhrase, "Created") - XCTAssertEqual(HTTPUtilities.HTTPStatus.noContent.reasonPhrase, "No Content") - XCTAssertEqual(HTTPUtilities.HTTPStatus.badRequest.reasonPhrase, "Bad Request") - XCTAssertEqual(HTTPUtilities.HTTPStatus.unauthorized.reasonPhrase, "Unauthorized") - XCTAssertEqual(HTTPUtilities.HTTPStatus.forbidden.reasonPhrase, "Forbidden") - XCTAssertEqual(HTTPUtilities.HTTPStatus.notFound.reasonPhrase, "Not Found") XCTAssertEqual(HTTPUtilities.HTTPStatus.methodNotAllowed.reasonPhrase, "Method Not Allowed") - XCTAssertEqual(HTTPUtilities.HTTPStatus.requestTimeout.reasonPhrase, "Request Timeout") XCTAssertEqual(HTTPUtilities.HTTPStatus.internalServerError.reasonPhrase, "Internal Server Error") } - - func testHTTPStatusUnknownCode() { - // Unknown codes should fall back to internal server error - XCTAssertEqual(HTTPUtilities.HTTPStatus.from(code: 999), .internalServerError) - XCTAssertEqual(HTTPUtilities.HTTPStatus.from(code: 418), .internalServerError) // I'm a teapot - } - - // MARK: - Request Building Tests - - func testBuildBasicRequest() { - let request = HTTPUtilities.buildRequest(method: "GET", path: "/test") - let requestStr = String(data: request, encoding: .utf8)! - - XCTAssertTrue(requestStr.contains("GET /test HTTP/1.1")) - XCTAssertTrue(requestStr.contains("Host: localhost")) - XCTAssertTrue(requestStr.contains("Connection: close")) - XCTAssertTrue(requestStr.hasSuffix("\r\n\r\n")) - } - - func testBuildRequestWithHeaders() { - let headers = [ - "Content-Type": "application/json", - "Authorization": "Bearer token123" - ] - let request = HTTPUtilities.buildRequest( - method: "POST", - path: "/api/v1/test", - headers: headers - ) - let requestStr = String(data: request, encoding: .utf8)! - - XCTAssertTrue(requestStr.contains("POST /api/v1/test HTTP/1.1")) - XCTAssertTrue(requestStr.contains("Content-Type: application/json")) - XCTAssertTrue(requestStr.contains("Authorization: Bearer token123")) - } - - func testBuildRequestWithBody() { - let body = Data("{\"test\":\"value\"}".utf8) - let request = HTTPUtilities.buildRequest( - method: "POST", - path: "/test", - body: body - ) - let requestStr = String(data: request, encoding: .utf8)! - - XCTAssertTrue(requestStr.contains("Content-Length: \(body.count)")) - - // Verify body is appended correctly - let headerEnd = request.range(of: Data([0x0d, 0x0a, 0x0d, 0x0a]))! // \r\n\r\n - let bodyStart = headerEnd.upperBound - let extractedBody = request[bodyStart...] - XCTAssertEqual(extractedBody, body) - } - - func testBuildRequestBinarySafe() { - // Test with binary data containing NULL bytes and invalid UTF-8 - let binaryData = Data([0xFF, 0x00, 0xFE, 0x00, 0x01, 0xDE, 0xAD, 0xBE, 0xEF]) - let request = HTTPUtilities.buildRequest( - method: "POST", - path: "/test", - body: binaryData - ) - - // Find the body in the request - let headerEnd = request.range(of: Data([0x0d, 0x0a, 0x0d, 0x0a]))! // \r\n\r\n - let bodyStart = headerEnd.upperBound - let extractedBody = request[bodyStart...] - - // Verify binary data is preserved exactly - XCTAssertEqual(extractedBody, binaryData) - } - - // MARK: - Response Building Tests - - func testBuildJSONResponse() { - let json = Data("{\"status\":\"ok\"}".utf8) - let response = HTTPUtilities.buildJSONResponse(json) - let responseStr = String(data: response, encoding: .utf8)! - - XCTAssertTrue(responseStr.contains("HTTP/1.1 200 OK")) - XCTAssertTrue(responseStr.contains("Content-Type: application/json")) - XCTAssertTrue(responseStr.contains("Content-Length: \(json.count)")) - - // Verify body - let headerEnd = response.range(of: Data([0x0d, 0x0a, 0x0d, 0x0a]))! - let bodyStart = headerEnd.upperBound - let extractedBody = response[bodyStart...] - XCTAssertEqual(extractedBody, json) - } - - func testBuildErrorResponse() { - let response = HTTPUtilities.buildErrorResponse( - status: .notFound, - message: "Resource not found" - ) - let responseStr = String(data: response, encoding: .utf8)! - - XCTAssertTrue(responseStr.contains("HTTP/1.1 404 Not Found")) - XCTAssertTrue(responseStr.contains("Content-Type: application/json")) - - // Verify error message in body - let headerEnd = response.range(of: Data([0x0d, 0x0a, 0x0d, 0x0a]))! - let bodyStart = headerEnd.upperBound - let body = response[bodyStart...] - let json = try! JSONSerialization.jsonObject(with: body) as! [String: Any] - XCTAssertEqual(json["error"] as? String, "Resource not found") - } - - func testBuildResponseBinarySafe() { - // Test response with binary body - let binaryBody = Data([0xFF, 0x00, 0xFE, 0x00, 0x01, 0xDE, 0xAD, 0xBE, 0xEF]) - let response = HTTPUtilities.buildResponse( - status: .ok, - contentType: "application/octet-stream", - body: binaryBody - ) - - // Verify status line - let responseStr = String(data: response.prefix(upTo: response.firstIndex(of: 0x0d)!), encoding: .utf8)! - XCTAssertEqual(responseStr, "HTTP/1.1 200 OK") - - // Verify binary body is preserved - let headerEnd = response.range(of: Data([0x0d, 0x0a, 0x0d, 0x0a]))! - let bodyStart = headerEnd.upperBound - let extractedBody = response[bodyStart...] - XCTAssertEqual(extractedBody, binaryBody) - } - - // MARK: - Binary Safety Tests (CRITICAL) - - func testBinaryBodyWithNullBytes() { - let binaryData = Data([0xFF, 0x00, 0xFE, 0x00, 0x01]) - let request = HTTPUtilities.buildRequest( - method: "POST", - path: "/test", - body: binaryData - ) - - // Verify binary data is preserved (especially NULL bytes at positions 1 and 3) - let headerEnd = request.range(of: Data([0x0d, 0x0a, 0x0d, 0x0a]))! - let bodyStart = headerEnd.upperBound - let extractedBody = request[bodyStart...] - XCTAssertEqual(extractedBody, binaryData) - - // Verify specific NULL bytes - XCTAssertEqual(extractedBody[extractedBody.startIndex + 1], 0x00) - XCTAssertEqual(extractedBody[extractedBody.startIndex + 3], 0x00) - } - - func testBinaryBodyWithInvalidUTF8() { - // Create data with invalid UTF-8 sequences - let invalidUTF8 = Data([0xFF, 0xFE, 0xFD, 0xFC, 0xFB, 0xFA]) - let request = HTTPUtilities.buildRequest( - method: "POST", - path: "/test", - body: invalidUTF8 - ) - - // Must not crash or corrupt data - let headerEnd = request.range(of: Data([0x0d, 0x0a, 0x0d, 0x0a]))! - let bodyStart = headerEnd.upperBound - let extractedBody = request[bodyStart...] - XCTAssertEqual(extractedBody, invalidUTF8) - } - - func testLargeBody() { - // Test with a larger binary body (simulate file transfer) - let largeData = Data(repeating: 0xAB, count: 1024 * 1024) // 1 MB - let request = HTTPUtilities.buildRequest( - method: "POST", - path: "/upload", - headers: ["Content-Type": "application/octet-stream"], - body: largeData - ) - - // Verify Content-Length header - let headerEnd = request.range(of: Data([0x0d, 0x0a, 0x0d, 0x0a]))! - let headerData = request[request.startIndex.. (data: Data, filename: String, permissions: Int?) { + func fetchFile(at path: String, to destinationURL: URL, progress: @escaping (Double) -> Void) async throws -> (filename: String, permissions: Int?) { if let error = shouldThrow { throw error } - return (fetchedFileData, fetchedFilename, fetchedPermissions) + try fetchedFileData.write(to: destinationURL) + progress(1.0) + return (fetchedFilename, fetchedPermissions) } func listFiles() async throws -> [String] { diff --git a/macOS/GhostVMTests/VMMigrationServiceTests.swift b/macOS/GhostVMTests/VMMigrationServiceTests.swift new file mode 100644 index 0000000..60d7dd1 --- /dev/null +++ b/macOS/GhostVMTests/VMMigrationServiceTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import GhostVMKit + +final class VMMigrationServiceTests: XCTestCase { + func testRejectsDestinationThatMatchesSourcePath() throws { + let root = makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let source = try makeMinimalBundle(named: "Source.GhostVM", in: root) + let service = VMMigrationService() + + XCTAssertThrowsError(try service.validateDestination(source: source, destination: source)) { error in + guard case VMMigrationService.MigrationError.destinationMatchesSource = error else { + return XCTFail("Expected destinationMatchesSource, got \(error)") + } + } + } + + func testRejectsExistingDestinationWithoutRemovingIt() throws { + let root = makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let source = try makeMinimalBundle(named: "Source.GhostVM", in: root) + let destination = root.appendingPathComponent("Destination.GhostVM") + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + let marker = destination.appendingPathComponent("keep.txt") + try "keep".write(to: marker, atomically: true, encoding: .utf8) + + let service = VMMigrationService() + XCTAssertThrowsError(try service.migrate( + source: source, + destination: destination, + progressHandler: { _, _ in XCTFail("Migration should fail before starting work") } + )) { error in + guard case VMMigrationService.MigrationError.destinationAlreadyExists = error else { + return XCTFail("Expected destinationAlreadyExists, got \(error)") + } + } + + XCTAssertTrue(FileManager.default.fileExists(atPath: destination.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: marker.path)) + } + + private func makeTemporaryDirectory() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("ghostvm-migration-tests-\(UUID().uuidString)", isDirectory: true) + } + + private func makeMinimalBundle(named name: String, in root: URL) throws -> URL { + let bundle = root.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) + let disk = bundle.appendingPathComponent("disk.img") + try Data(repeating: 0, count: 4).write(to: disk) + return bundle + } +} diff --git a/macOS/GhostVMTests/WebSocketShellClientTests.swift b/macOS/GhostVMTests/WebSocketShellClientTests.swift new file mode 100644 index 0000000..715aa20 --- /dev/null +++ b/macOS/GhostVMTests/WebSocketShellClientTests.swift @@ -0,0 +1,475 @@ +import XCTest +@testable import GhostVMKit +#if canImport(Darwin) +import Darwin +#endif + +final class WebSocketShellClientTests: XCTestCase { + + func testClientConsumesServerOutputExactlyUnderBurst() throws { + let output = makeScreenLikePayload(lineCount: 1500) + let harness = try ClientHarness() + let reader = OutputReader(masterFD: harness.outputMasterFD) + reader.start() + + let serverDone = DispatchGroup() + serverDone.enter() + DispatchQueue.global(qos: .userInitiated).async { + defer { + Darwin.close(harness.serverFD) + serverDone.leave() + } + for chunk in Self.chunk(data: output, sizes: [1, 2, 7, 31, 127, 509, 2048]) { + let frame = self.makeServerFrame(opcode: 0x02, payload: Array(chunk)) + _ = Self.writeAll(fd: harness.serverFD, data: Data(frame)) + } + let closeFrame = self.makeServerFrame(opcode: 0x08, payload: []) + _ = Self.writeAll(fd: harness.serverFD, data: Data(closeFrame)) + } + + harness.runClientOrFail() + Darwin.close(harness.outputSlaveFD) + Darwin.close(harness.inputWriteFD) + Darwin.close(harness.inputReadFD) + + XCTAssertEqual(serverDone.wait(timeout: .now() + 5), .success) + let captured = reader.finish() + XCTAssertEqual(captured, output) + } + + func testClientProducesExactBinaryPayloadUnderBurst() throws { + let inputPayload = makeInputPayload(byteCount: 2 * 1024 * 1024 + 173) + let harness = try ClientHarness() + let reader = OutputReader(masterFD: harness.outputMasterFD) + reader.start() + + let receivedBox = LockedDataBox() + let serverDone = DispatchGroup() + serverDone.enter() + DispatchQueue.global(qos: .userInitiated).async { + defer { + Darwin.close(harness.serverFD) + serverDone.leave() + } + var parser = WebSocketClientFrameParser() + var buffer = [UInt8](repeating: 0, count: 8192) + var received = Data() + while true { + let n = Darwin.read(harness.serverFD, &buffer, buffer.count) + if n > 0 { + parser.feed(Array(buffer[0.. 0 { + parser.feed(Array(buffer[0.. 0 { + parser.feed(Array(buffer[0.. [UInt8] { + var frame = [UInt8]() + frame.append(0x80 | opcode) + if payload.count <= 125 { + frame.append(UInt8(payload.count)) + } else if payload.count <= 0xFFFF { + frame.append(126) + frame.append(UInt8((payload.count >> 8) & 0xFF)) + frame.append(UInt8(payload.count & 0xFF)) + } else { + frame.append(127) + let len64 = UInt64(payload.count) + for i in (0..<8).reversed() { + frame.append(UInt8((len64 >> (i * 8)) & 0xFF)) + } + } + frame.append(contentsOf: payload) + return frame + } + + private func makeScreenLikePayload(lineCount: Int) -> Data { + var data = Data() + for index in 0.. Data { + var data = Data(count: byteCount) + data.withUnsafeMutableBytes { bytes in + guard let base = bytes.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return } + for index in 0.. [Data] { + var chunks: [Data] = [] + var offset = 0 + var index = 0 + while offset < data.count { + let size = sizes[index % sizes.count] + let end = min(offset + size, data.count) + chunks.append(data.subdata(in: offset.. Bool { + data.withUnsafeBytes { bytes in + guard let base = bytes.baseAddress else { return true } + var offset = 0 + while offset < bytes.count { + let n = Darwin.write(fd, base + offset, bytes.count - offset) + if n > 0 { + offset += n + } else if n < 0 && errno == EINTR { + continue + } else { + return false + } + } + return true + } + } +} + +private final class ClientHarness { + let serverFD: Int32 + let clientFD: Int32 + let inputReadFD: Int32 + let inputWriteFD: Int32 + let outputMasterFD: Int32 + let outputSlaveFD: Int32 + + init() throws { + var socketFDs = [Int32](repeating: -1, count: 2) + guard socketpair(AF_UNIX, SOCK_STREAM, 0, &socketFDs) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + serverFD = socketFDs[0] + clientFD = socketFDs[1] + + var pipeFDs = [Int32](repeating: -1, count: 2) + guard pipe(&pipeFDs) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + inputReadFD = pipeFDs[0] + inputWriteFD = pipeFDs[1] + + let master = posix_openpt(O_RDWR | O_NOCTTY) + guard master >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + guard grantpt(master) == 0, unlockpt(master) == 0, let slaveName = ptsname(master) else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + let slave = Darwin.open(slaveName, O_RDWR | O_NOCTTY) + guard slave >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + var raw = termios() + guard tcgetattr(slave, &raw) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + cfmakeraw(&raw) + guard tcsetattr(slave, TCSAFLUSH, &raw) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + outputMasterFD = master + outputSlaveFD = slave + + let flags = fcntl(clientFD, F_GETFL, 0) + _ = fcntl(clientFD, F_SETFL, flags | O_NONBLOCK) + } + + func runClientOrFail( + prelude: Data = Data(), + onControlMessage: @escaping @Sendable (String) -> Void = { _ in } + ) { + let group = DispatchGroup() + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + WebSocketShellClient.run( + configuration: .init( + socketFD: self.clientFD, + inputFD: self.inputReadFD, + outputFD: self.outputSlaveFD, + prelude: prelude, + installWindowResizeHandler: false, + installInterruptHandler: false, + onControlMessage: onControlMessage + ) + ) + group.leave() + } + XCTAssertEqual(group.wait(timeout: .now() + 5), .success, "client session timed out") + } + + func setClientSendBuffer(bytes: Int32) { + var value = bytes + _ = withUnsafePointer(to: &value) { + setsockopt(clientFD, SOL_SOCKET, SO_SNDBUF, $0, socklen_t(MemoryLayout.size)) + } + } +} + +private final class OutputReader: @unchecked Sendable { + private let masterFD: Int32 + private let box = LockedDataBox() + private let group = DispatchGroup() + + init(masterFD: Int32) { + self.masterFD = masterFD + } + + func start() { + group.enter() + DispatchQueue.global(qos: .utility).async { + defer { + Darwin.close(self.masterFD) + self.group.leave() + } + var captured = Data() + var buffer = [UInt8](repeating: 0, count: 8192) + while true { + let n = Darwin.read(self.masterFD, &buffer, buffer.count) + if n > 0 { + captured.append(contentsOf: buffer[0.. Data { + XCTAssertEqual(group.wait(timeout: .now() + 5), .success) + return box.get() + } +} + +private final class LockedDataBox: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + + func set(_ data: Data) { + lock.lock() + self.data = data + lock.unlock() + } + + func get() -> Data { + lock.lock() + defer { lock.unlock() } + return data + } +} + +private final class LockedStringBox: @unchecked Sendable { + private let lock = NSLock() + private var string = "" + + func set(_ string: String) { + lock.lock() + self.string = string + lock.unlock() + } + + func get() -> String { + lock.lock() + defer { lock.unlock() } + return string + } +} diff --git a/macOS/project.yml b/macOS/project.yml index a849dc7..d402b51 100644 --- a/macOS/project.yml +++ b/macOS/project.yml @@ -9,6 +9,8 @@ packages: Sparkle: url: https://github.com/sparkle-project/Sparkle.git from: "2.6.0" + GhostHTTP: + path: ../Packages/GhostHTTP settings: base: @@ -23,6 +25,13 @@ schemes: build: targets: GhostVM: all + GhostVMTests: + build: + targets: + GhostVMTests: test + test: + targets: + - GhostVMTests targets: GhostVMKit: @@ -30,6 +39,8 @@ targets: platform: macOS sources: - path: GhostVMKit + dependencies: + - package: GhostHTTP settings: base: PRODUCT_NAME: GhostVMKit @@ -58,6 +69,7 @@ targets: - target: GhostVMKit link: true embed: true + - package: GhostHTTP settings: base: PRODUCT_NAME: vmctl @@ -94,6 +106,7 @@ targets: dependencies: - target: GhostVMKit embed: true + - package: GhostHTTP settings: base: PRODUCT_NAME: GhostVMHelper @@ -124,6 +137,7 @@ targets: dependencies: - target: GhostVMKit embed: true + - package: GhostHTTP - target: vmctl embed: true codeSign: true