From 09df6a6df8242d171630cb1a5fda0e98fac46995 Mon Sep 17 00:00:00 2001 From: Sash Zats Date: Tue, 25 Aug 2026 14:16:06 -0400 Subject: [PATCH] Add host-to-guest file drag and drop --- VirtualBuddyGuest/GuestAppDelegate.swift | 4 + .../GuestFileTransferReceiver.swift | 200 ++++++++ .../GuestRemoteFileDragController.swift | 476 ++++++++++++++++++ .../HostFileTransferServer.swift | 251 +++++++++ .../Source/Virtualization/VMController.swift | 60 +++ .../Source/Virtualization/VMInstance.swift | 73 +++ .../Components/HostFileDragCoordinator.swift | 169 +++++++ .../Session/Components/SwiftUIVMView.swift | 44 ++ .../Session/VirtualMachineSessionView.swift | 3 +- .../Services/FileDrag/FileDragMessage.swift | 57 +++ .../FileDrag/FileTransferProtocol.swift | 148 ++++++ .../FileTransferProtocolTests.swift | 61 +++ 12 files changed, 1545 insertions(+), 1 deletion(-) create mode 100644 VirtualBuddyGuest/GuestFileTransferReceiver.swift create mode 100644 VirtualBuddyGuest/GuestRemoteFileDragController.swift create mode 100644 VirtualCore/Source/Virtualization/HostFileTransferServer.swift create mode 100644 VirtualUI/Source/Session/Components/HostFileDragCoordinator.swift create mode 100644 VirtualWormhole/Source/Services/FileDrag/FileDragMessage.swift create mode 100644 VirtualWormhole/Source/Services/FileDrag/FileTransferProtocol.swift create mode 100644 VirtualWormholeTests/FileTransferProtocolTests.swift diff --git a/VirtualBuddyGuest/GuestAppDelegate.swift b/VirtualBuddyGuest/GuestAppDelegate.swift index b59a6b4d..9fc737e1 100644 --- a/VirtualBuddyGuest/GuestAppDelegate.swift +++ b/VirtualBuddyGuest/GuestAppDelegate.swift @@ -4,6 +4,7 @@ import VirtualUI import VirtualWormhole import OSLog +@MainActor @NSApplicationMain final class GuestAppDelegate: NSObject, NSApplicationDelegate { @@ -13,6 +14,8 @@ final class GuestAppDelegate: NSObject, NSApplicationDelegate { private lazy var sharedFolders = GuestSharedFoldersManager() + private lazy var fileDragController = GuestRemoteFileDragController() + private lazy var dashboardItem: StatusItemManager = { StatusItemManager( configuration: .default.id("dashboard"), @@ -50,6 +53,7 @@ final class GuestAppDelegate: NSObject, NSApplicationDelegate { launchAtLoginManager.autoEnableIfNeeded() WormholeManager.sharedGuest.activate() + fileDragController.activate() Task { try? await sharedFolders.mount() diff --git a/VirtualBuddyGuest/GuestFileTransferReceiver.swift b/VirtualBuddyGuest/GuestFileTransferReceiver.swift new file mode 100644 index 00000000..5f1027a6 --- /dev/null +++ b/VirtualBuddyGuest/GuestFileTransferReceiver.swift @@ -0,0 +1,200 @@ +import Darwin +import Foundation +import OSLog +import VirtualWormhole + +struct StagedFileTransfer: Sendable { + var sessionID: UUID + var fileURLs: [URL] + var directoryURL: URL +} + +final class GuestFileTransferReceiver { + typealias ReceiveHandler = @MainActor @Sendable (StagedFileTransfer) -> Void + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Guest", category: "FileTransferReceiver") + private let receiveHandler: ReceiveHandler + private var receiveTask: Task? + + init(receiveHandler: @escaping ReceiveHandler) { + self.receiveHandler = receiveHandler + } + + func activate() { + guard receiveTask == nil else { return } + + receiveTask = Task.detached(priority: .userInitiated) { [weak self] in + await self?.receiveContinuously() + } + } + + func invalidate() { + receiveTask?.cancel() + receiveTask = nil + } + + private func receiveContinuously() async { + while !Task.isCancelled { + do { + let handle = try openConnection() + logger.notice("Connected to the host file transfer service") + + do { + while !Task.isCancelled { + let transfer = try receiveTransfer(from: handle) + await receiveHandler(transfer) + } + } catch { + try? handle.close() + throw error + } + } catch is CancellationError { + return + } catch { + logger.error("File transfer connection failed: \(error, privacy: .public)") + try? await Task.sleep(for: .seconds(2)) + } + } + } + + private func openConnection() throws -> FileHandle { + let descriptor = socket(AF_VSOCK, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) } + + var noSigPipe: Int32 = 1 + setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &noSigPipe, + socklen_t(MemoryLayout.size(ofValue: noSigPipe)) + ) + + var address = sockaddr_vm() + address.svm_len = UInt8(MemoryLayout.size) + address.svm_family = sa_family_t(AF_VSOCK) + address.svm_port = FileTransferProtocol.port + address.svm_cid = UInt32(VMADDR_CID_HOST) + + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in + Darwin.connect(descriptor, socketAddress, socklen_t(MemoryLayout.size)) + } + } + + guard result == 0 else { + let error = POSIXError(.init(rawValue: errno) ?? .ECONNREFUSED) + Darwin.close(descriptor) + throw error + } + + return FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + } + + private func receiveTransfer(from handle: FileHandle) throws -> StagedFileTransfer { + guard let manifest = try FileTransferProtocol.readFrame(FileTransferManifest.self, from: handle) else { + throw CocoaError(.fileReadCorruptFile) + } + guard manifest.version == FileTransferProtocol.version else { + throw CocoaError( + .fileReadUnsupportedScheme, + userInfo: [NSLocalizedDescriptionKey: "Unsupported file transfer version: \(manifest.version)"] + ) + } + + let directoryURL = FileManager.default.temporaryDirectory + .appending(path: "VirtualBuddyFileDrops", directoryHint: .isDirectory) + .appending(path: manifest.sessionID.uuidString, directoryHint: .isDirectory) + + try? FileManager.default.removeItem(at: directoryURL) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + + do { + var attributesToApply = [(url: URL, attributes: [FileAttributeKey: Any])]() + + while let entry = try FileTransferProtocol.readFrame(FileTransferEntry.self, from: handle) { + try Task.checkCancellation() + + let destinationURL = try FileTransferPath.destination( + for: entry.relativePath, + under: directoryURL + ) + + try create(entry, at: destinationURL, readingFrom: handle) + + if entry.kind != .symbolicLink { + var attributes = [FileAttributeKey: Any]() + if let permissions = entry.posixPermissions { + attributes[.posixPermissions] = NSNumber(value: permissions) + } + if let modificationDate = entry.modificationDate { + attributes[.modificationDate] = modificationDate + } + if !attributes.isEmpty { + attributesToApply.append((destinationURL, attributes)) + } + } + } + + for item in attributesToApply.reversed() { + try FileManager.default.setAttributes(item.attributes, ofItemAtPath: item.url.path) + } + + let fileURLs = try manifest.rootPaths.map { + try FileTransferPath.destination(for: $0, under: directoryURL) + } + + return StagedFileTransfer( + sessionID: manifest.sessionID, + fileURLs: fileURLs, + directoryURL: directoryURL + ) + } catch { + try? FileManager.default.removeItem(at: directoryURL) + throw error + } + } + + private func create(_ entry: FileTransferEntry, at destinationURL: URL, readingFrom handle: FileHandle) throws { + switch entry.kind { + case .directory: + try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true) + + case .regularFile: + try FileManager.default.createDirectory( + at: destinationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + guard FileManager.default.createFile(atPath: destinationURL.path, contents: nil) else { + throw CocoaError(.fileWriteUnknown) + } + + let output = try FileHandle(forWritingTo: destinationURL) + defer { try? output.close() } + + var remaining = entry.byteCount + let chunkSize: UInt64 = 1_024 * 1_024 + + while remaining > 0 { + try Task.checkCancellation() + + let data = try FileTransferProtocol.readExactly(Int(min(remaining, chunkSize)), from: handle) + try output.write(contentsOf: data) + remaining -= UInt64(data.count) + } + + case .symbolicLink: + guard let symbolicLinkDestination = entry.symbolicLinkDestination else { + throw CocoaError(.fileReadCorruptFile) + } + try FileManager.default.createDirectory( + at: destinationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink( + atPath: destinationURL.path, + withDestinationPath: symbolicLinkDestination + ) + } + } +} diff --git a/VirtualBuddyGuest/GuestRemoteFileDragController.swift b/VirtualBuddyGuest/GuestRemoteFileDragController.swift new file mode 100644 index 00000000..0a1b4df1 --- /dev/null +++ b/VirtualBuddyGuest/GuestRemoteFileDragController.swift @@ -0,0 +1,476 @@ +import AppKit +import ApplicationServices +import OSLog +import VirtualWormhole + +@MainActor +final class GuestRemoteFileDragController: NSObject, NSDraggingSource { + private final class ActiveDrag { + let transfer: StagedFileTransfer + let sourceWindow: RemoteDragSourceWindow + let sourceView: RemoteDragSourceView + let eventSource: CGEventSource + var draggingSession: NSDraggingSession? + var latestScreenPoint: NSPoint + var hasPostedMouseDown = false + var dropRequested = false + var didReportMovement = false + + init( + transfer: StagedFileTransfer, + sourceWindow: RemoteDragSourceWindow, + sourceView: RemoteDragSourceView, + eventSource: CGEventSource, + latestScreenPoint: NSPoint + ) { + self.transfer = transfer + self.sourceWindow = sourceWindow + self.sourceView = sourceView + self.eventSource = eventSource + self.latestScreenPoint = latestScreenPoint + } + } + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Guest", category: "RemoteFileDrag") + private lazy var receiver = GuestFileTransferReceiver { [weak self] transfer in + self?.didStage(transfer) + } + + private var messageTask: Task? + private var stagedTransfers = [UUID: StagedFileTransfer]() + private var pendingMessages = [UUID: [FileDragMessage]]() + private var activeDrag: ActiveDrag? + + func activate() { + guard messageTask == nil else { return } + + removeAbandonedTransfers() + receiver.activate() + + messageTask = Task { [weak self] in + do { + for try await message in WormholeManager.sharedGuest.stream(for: FileDragMessage.self) { + guard message.senderID == .host else { continue } + self?.handle(message.payload) + } + } catch { + self?.logger.error("File drag message stream ended: \(error, privacy: .public)") + } + } + } + + func invalidate() { + receiver.invalidate() + messageTask?.cancel() + messageTask = nil + cancelActiveDrag() + } + + func draggingSession( + _ session: NSDraggingSession, + sourceOperationMaskFor context: NSDraggingContext + ) -> NSDragOperation { + .copy + } + + func draggingSession(_ session: NSDraggingSession, willBeginAt screenPoint: NSPoint) { + guard let activeDrag, activeDrag.draggingSession === session else { return } + report(.sessionWillBegin, for: activeDrag.transfer.sessionID) + } + + func draggingSession(_ session: NSDraggingSession, movedTo screenPoint: NSPoint) { + guard let activeDrag, + activeDrag.draggingSession === session, + !activeDrag.didReportMovement + else { return } + + activeDrag.didReportMovement = true + report(.sessionMoved, for: activeDrag.transfer.sessionID) + } + + func draggingSession( + _ session: NSDraggingSession, + endedAt screenPoint: NSPoint, + operation: NSDragOperation + ) { + guard let activeDrag else { return } + + let sessionID = activeDrag.transfer.sessionID + activeDrag.sourceWindow.orderOut(nil) + self.activeDrag = nil + + Task { + await WormholeManager.sharedGuest.send( + FileDragMessage( + action: .result, + sessionID: sessionID, + operation: operation.rawValue + ), + to: nil + ) + } + + if operation == [] { + removeTransfer(activeDrag.transfer) + } else { + scheduleRemoval(of: activeDrag.transfer) + } + } + + private func didStage(_ transfer: StagedFileTransfer) { + stagedTransfers[transfer.sessionID] = transfer + report(.staged, for: transfer.sessionID) + + let messages = pendingMessages.removeValue(forKey: transfer.sessionID) ?? [] + for message in messages { + handle(message) + } + } + + private func handle(_ message: FileDragMessage) { + if message.action == .begin, stagedTransfers[message.sessionID] == nil { + pendingMessages[message.sessionID, default: []].append(message) + return + } + + switch message.action { + case .begin: + guard let transfer = stagedTransfers.removeValue(forKey: message.sessionID), + let location = message.location + else { return } + report(.beginReceived, for: message.sessionID) + beginDrag(with: transfer, at: screenPoint(for: location)) + + case .update: + guard let activeDrag, + activeDrag.transfer.sessionID == message.sessionID, + let location = message.location + else { return } + let screenPoint = screenPoint(for: location) + activeDrag.latestScreenPoint = screenPoint + + guard activeDrag.draggingSession != nil else { + if !activeDrag.hasPostedMouseDown { + moveSourceWindow(activeDrag.sourceWindow, to: screenPoint) + } + return + } + + postMouseEvent(.leftMouseDragged, at: screenPoint) + + case .drop: + guard let activeDrag, activeDrag.transfer.sessionID == message.sessionID else { return } + let screenPoint = message.location.map(screenPoint(for:)) ?? activeDrag.latestScreenPoint + activeDrag.latestScreenPoint = screenPoint + activeDrag.dropRequested = true + report(.dropReceived, for: message.sessionID) + if activeDrag.draggingSession != nil { + postMouseEvent(.leftMouseUp, at: screenPoint) + report(.mouseUpPosted, for: message.sessionID) + } else if !activeDrag.hasPostedMouseDown { + moveSourceWindow(activeDrag.sourceWindow, to: screenPoint) + } + + case .cancel: + if let transfer = stagedTransfers.removeValue(forKey: message.sessionID) { + removeTransfer(transfer) + } + pendingMessages[message.sessionID] = nil + if activeDrag?.transfer.sessionID == message.sessionID { + cancelActiveDrag() + } + + case .status, .result: + break + } + } + + private func beginDrag(with transfer: StagedFileTransfer, at screenPoint: NSPoint) { + cancelActiveDrag() + + guard let eventSource = CGEventSource(stateID: .combinedSessionState) else { + logger.error("Could not create a Core Graphics event source") + removeTransfer(transfer) + return + } + + let sourceWindow = RemoteDragSourceWindow( + contentRect: NSRect(x: screenPoint.x - 24, y: screenPoint.y - 24, width: 48, height: 48), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + sourceWindow.isOpaque = false + sourceWindow.backgroundColor = .clear + sourceWindow.hasShadow = false + sourceWindow.ignoresMouseEvents = false + sourceWindow.becomesKeyOnlyIfNeeded = true + sourceWindow.hidesOnDeactivate = false + sourceWindow.level = .popUpMenu + sourceWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + let sourceView = RemoteDragSourceView( + frame: sourceWindow.contentView?.bounds ?? NSRect(x: 0, y: 0, width: 48, height: 48) + ) + sourceWindow.contentView = sourceView + sourceWindow.orderFrontRegardless() + + let windowPoint = sourceWindow.convertPoint(fromScreen: screenPoint) + let viewPoint = sourceView.convert(windowPoint, from: nil) + + let draggingItems = transfer.fileURLs.map { fileURL in + let item = NSDraggingItem(pasteboardWriter: fileURL as NSURL) + let image = NSWorkspace.shared.icon(forFile: fileURL.path) + item.setDraggingFrame( + NSRect(x: viewPoint.x - 16, y: viewPoint.y - 16, width: 32, height: 32), + contents: image + ) + return item + } + + let activeDrag = ActiveDrag( + transfer: transfer, + sourceWindow: sourceWindow, + sourceView: sourceView, + eventSource: eventSource, + latestScreenPoint: screenPoint + ) + self.activeDrag = activeDrag + + sourceView.mouseDownHandler = { [weak self, weak activeDrag] event in + guard let self, + let activeDrag, + self.activeDrag === activeDrag + else { return } + + activeDrag.sourceView.mouseDownHandler = nil + + let session = activeDrag.sourceView.beginDraggingSession( + with: draggingItems, + event: event, + source: self + ) + session.animatesToStartingPositionsOnCancelOrFail = false + activeDrag.draggingSession = session + + activeDrag.sourceWindow.ignoresMouseEvents = true + self.moveOffscreen(activeDrag.sourceWindow) + self.report(.sessionCreated, for: activeDrag.transfer.sessionID) + DispatchQueue.main.async { [weak self, weak activeDrag] in + guard let self, + let activeDrag, + self.activeDrag === activeDrag + else { return } + + self.postMouseEvent(.leftMouseDragged, at: activeDrag.latestScreenPoint) + + if activeDrag.dropRequested { + self.postMouseEvent(.leftMouseUp, at: activeDrag.latestScreenPoint) + self.report(.mouseUpPosted, for: activeDrag.transfer.sessionID) + } + } + } + + report(.sourceReady, for: transfer.sessionID, at: screenPoint) + + guard AXIsProcessTrusted() else { + report(.inputPermissionMissing, for: transfer.sessionID) + requestAccessibilityPermission() + finishDragBeforeSessionStarts(activeDrag) + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self, weak activeDrag] in + guard let self, + let activeDrag, + self.activeDrag === activeDrag + else { return } + + let mouseDownPoint = activeDrag.latestScreenPoint + self.moveSourceWindow(activeDrag.sourceWindow, to: mouseDownPoint) + activeDrag.sourceWindow.orderFrontRegardless() + activeDrag.hasPostedMouseDown = true + self.postMouseEvent(.leftMouseDown, at: mouseDownPoint) + self.report(.mouseDownPosted, for: activeDrag.transfer.sessionID, at: mouseDownPoint) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak activeDrag] in + guard let self, + let activeDrag, + self.activeDrag === activeDrag + else { return } + + self.report(.pointerObserved, for: activeDrag.transfer.sessionID, at: NSEvent.mouseLocation) + } + + Task { [weak self, weak activeDrag] in + try? await Task.sleep(for: .seconds(1)) + guard let self, + let activeDrag, + self.activeDrag === activeDrag, + activeDrag.draggingSession == nil + else { return } + + self.report(.sessionStartTimedOut, for: activeDrag.transfer.sessionID) + self.finishDragBeforeSessionStarts(activeDrag) + } + } + } + + private func moveSourceWindow(_ window: NSWindow, to screenPoint: NSPoint) { + window.setFrameOrigin( + NSPoint( + x: screenPoint.x - window.frame.width / 2, + y: screenPoint.y - window.frame.height / 2 + ) + ) + } + + private func moveOffscreen(_ window: NSWindow) { + let screenFrame = NSScreen.screens.reduce(NSRect.null) { partialResult, screen in + partialResult.union(screen.frame) + } + let visibleFrame = screenFrame.isNull ? .zero : screenFrame + + window.setFrameOrigin( + NSPoint( + x: visibleFrame.minX - window.frame.width - 1, + y: visibleFrame.minY - window.frame.height - 1 + ) + ) + } + + private func cancelActiveDrag() { + guard let activeDrag else { return } + + if activeDrag.draggingSession != nil { + postKeyboardEvent(virtualKey: 53, keyDown: true, source: activeDrag.eventSource) + postKeyboardEvent(virtualKey: 53, keyDown: false, source: activeDrag.eventSource) + } + + activeDrag.sourceWindow.orderOut(nil) + removeTransfer(activeDrag.transfer) + self.activeDrag = nil + } + + private func postMouseEvent(_ type: CGEventType, at screenPoint: NSPoint) { + guard let activeDrag, + let event = CGEvent( + mouseEventSource: activeDrag.eventSource, + mouseType: type, + mouseCursorPosition: quartzPoint(for: screenPoint), + mouseButton: .left + ) + else { return } + + event.setIntegerValueField(.mouseEventClickState, value: 1) + event.post(tap: .cghidEventTap) + } + + private func postKeyboardEvent(virtualKey: CGKeyCode, keyDown: Bool, source: CGEventSource) { + guard let event = CGEvent(keyboardEventSource: source, virtualKey: virtualKey, keyDown: keyDown) else { + return + } + + event.post(tap: .cghidEventTap) + } + + private func requestAccessibilityPermission() { + let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String + AXIsProcessTrustedWithOptions([promptKey: true] as CFDictionary) + } + + private func finishDragBeforeSessionStarts(_ activeDrag: ActiveDrag) { + guard self.activeDrag === activeDrag else { return } + + let sessionID = activeDrag.transfer.sessionID + activeDrag.sourceWindow.orderOut(nil) + removeTransfer(activeDrag.transfer) + self.activeDrag = nil + + Task { + await WormholeManager.sharedGuest.send( + FileDragMessage(action: .result, sessionID: sessionID, operation: 0), + to: nil + ) + } + } + + private func report( + _ status: FileDragMessage.Status, + for sessionID: UUID, + at screenPoint: NSPoint? = nil + ) { + let location = screenPoint.map(fileDragLocation(for:)) + Task { + await WormholeManager.sharedGuest.send( + FileDragMessage( + action: .status, + sessionID: sessionID, + location: location, + status: status + ), + to: nil + ) + } + } + + private func screenPoint(for location: FileDragLocation) -> NSPoint { + let frame = (NSScreen.main ?? NSScreen.screens.first)?.frame ?? .zero + return NSPoint( + x: frame.minX + frame.width * location.x, + y: frame.minY + frame.height * location.y + ) + } + + private func quartzPoint(for screenPoint: NSPoint) -> CGPoint { + let mainDisplayBounds = CGDisplayBounds(CGMainDisplayID()) + return CGPoint( + x: screenPoint.x, + y: mainDisplayBounds.maxY - screenPoint.y + ) + } + + private func fileDragLocation(for screenPoint: NSPoint) -> FileDragLocation { + let frame = (NSScreen.main ?? NSScreen.screens.first)?.frame ?? .zero + guard frame.width > 0, frame.height > 0 else { + return FileDragLocation(x: 0.5, y: 0.5) + } + + return FileDragLocation( + x: (screenPoint.x - frame.minX) / frame.width, + y: (screenPoint.y - frame.minY) / frame.height + ) + } + + private func scheduleRemoval(of transfer: StagedFileTransfer) { + Task { [weak self] in + try? await Task.sleep(for: .seconds(3_600)) + self?.removeTransfer(transfer) + } + } + + private func removeTransfer(_ transfer: StagedFileTransfer) { + try? FileManager.default.removeItem(at: transfer.directoryURL) + } + + private func removeAbandonedTransfers() { + let baseURL = FileManager.default.temporaryDirectory + .appending(path: "VirtualBuddyFileDrops", directoryHint: .isDirectory) + try? FileManager.default.removeItem(at: baseURL) + } +} + +private final class RemoteDragSourceWindow: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} + +private final class RemoteDragSourceView: NSView { + var mouseDownHandler: ((NSEvent) -> Void)? + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + override func mouseDown(with event: NSEvent) { + mouseDownHandler?(event) + } +} diff --git a/VirtualCore/Source/Virtualization/HostFileTransferServer.swift b/VirtualCore/Source/Virtualization/HostFileTransferServer.swift new file mode 100644 index 00000000..e1725374 --- /dev/null +++ b/VirtualCore/Source/Virtualization/HostFileTransferServer.swift @@ -0,0 +1,251 @@ +import Foundation +import OSLog +import Virtualization +import VirtualWormhole + +final class HostFileTransferServer: NSObject, VZVirtioSocketListenerDelegate { + private let logger = Logger(for: HostFileTransferServer.self) + private let listener = VZVirtioSocketListener() + private let connectionLock = NSLock() + private let transferLock = NSLock() + + private var connection: VZVirtioSocketConnection? + + override init() { + super.init() + listener.delegate = self + } + + var isConnected: Bool { + connectionLock.withLock { + guard let connection else { return false } + return connection.fileDescriptor >= 0 + } + } + + func listen(on socketDevice: VZVirtioSocketDevice) { + socketDevice.setSocketListener(listener, forPort: FileTransferProtocol.port) + } + + func invalidate() { + connectionLock.withLock { + connection?.close() + connection = nil + } + } + + func send(sessionID: UUID, sourceURLs: [URL]) async throws { + try transferLock.withLock { + let connection = try currentConnection() + let handle = FileHandle(fileDescriptor: connection.fileDescriptor, closeOnDealloc: false) + + do { + logger.notice( + "Host file drag \(sessionID, privacy: .public) transfer started with \(sourceURLs.count, privacy: .public) root item(s)" + ) + try Self.writeTransfer( + sessionID: sessionID, + sourceURLs: sourceURLs, + to: handle + ) + logger.notice("Host file drag \(sessionID, privacy: .public) transfer completed") + } catch { + logger.error( + "Host file drag \(sessionID, privacy: .public) transfer failed: \(error, privacy: .public)" + ) + discard(connection) + throw error + } + } + } + + func listener( + _ listener: VZVirtioSocketListener, + shouldAcceptNewConnection connection: VZVirtioSocketConnection, + from socketDevice: VZVirtioSocketDevice + ) -> Bool { + connectionLock.withLock { + self.connection?.close() + self.connection = connection + } + + logger.notice("Guest file transfer connection is ready") + return true + } + + private func currentConnection() throws -> VZVirtioSocketConnection { + try connectionLock.withLock { + guard let connection, connection.fileDescriptor >= 0 else { + throw CocoaError( + .serviceApplicationNotFound, + userInfo: [NSLocalizedDescriptionKey: "VirtualBuddyGuest is not ready to receive files."] + ) + } + return connection + } + } + + private func discard(_ failedConnection: VZVirtioSocketConnection) { + connectionLock.withLock { + guard connection === failedConnection else { return } + connection?.close() + connection = nil + } + } +} + +private extension HostFileTransferServer { + struct TransferSource { + var url: URL + var relativePath: String + var entry: FileTransferEntry + } + + static func writeTransfer(sessionID: UUID, sourceURLs: [URL], to handle: FileHandle) throws { + guard !sourceURLs.isEmpty else { + throw CocoaError(.fileNoSuchFile) + } + + var accessedURLs = [URL]() + for url in sourceURLs where url.startAccessingSecurityScopedResource() { + accessedURLs.append(url) + } + defer { + accessedURLs.forEach { $0.stopAccessingSecurityScopedResource() } + } + + let rootNames = uniqueRootNames(for: sourceURLs) + let manifest = FileTransferManifest(sessionID: sessionID, rootPaths: rootNames) + try FileTransferProtocol.writeFrame(manifest, to: handle) + + for (sourceURL, rootName) in zip(sourceURLs, rootNames) { + for source in try transferSources(at: sourceURL, relativePath: rootName) { + try Task.checkCancellation() + try FileTransferProtocol.writeFrame(source.entry, to: handle) + + guard source.entry.kind == .regularFile else { continue } + try writeFile(at: source.url, byteCount: source.entry.byteCount, to: handle) + } + } + + try FileTransferProtocol.writeEnd(to: handle) + } + + static func transferSources(at url: URL, relativePath: String) throws -> [TransferSource] { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + let type = attributes[.type] as? FileAttributeType + let permissions = (attributes[.posixPermissions] as? NSNumber).map { UInt16(truncating: $0) } + let modificationDate = attributes[.modificationDate] as? Date + + switch type { + case .typeDirectory: + let directory = TransferSource( + url: url, + relativePath: relativePath, + entry: FileTransferEntry( + relativePath: relativePath, + kind: .directory, + posixPermissions: permissions, + modificationDate: modificationDate + ) + ) + + let children = try FileManager.default.contentsOfDirectory( + at: url, + includingPropertiesForKeys: nil, + options: [] + ).sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } + + return try children.reduce(into: [directory]) { result, child in + let childPath = relativePath + "/" + child.lastPathComponent + result.append(contentsOf: try transferSources(at: child, relativePath: childPath)) + } + + case .typeRegular: + let byteCount = (attributes[.size] as? NSNumber)?.uint64Value ?? 0 + return [ + TransferSource( + url: url, + relativePath: relativePath, + entry: FileTransferEntry( + relativePath: relativePath, + kind: .regularFile, + byteCount: byteCount, + posixPermissions: permissions, + modificationDate: modificationDate + ) + ) + ] + + case .typeSymbolicLink: + return [ + TransferSource( + url: url, + relativePath: relativePath, + entry: FileTransferEntry( + relativePath: relativePath, + kind: .symbolicLink, + posixPermissions: permissions, + modificationDate: modificationDate, + symbolicLinkDestination: try FileManager.default.destinationOfSymbolicLink(atPath: url.path) + ) + ) + ] + + default: + throw CocoaError( + .fileReadUnsupportedScheme, + userInfo: [NSLocalizedDescriptionKey: "Unsupported file type: \(url.lastPathComponent)"] + ) + } + } + + static func writeFile(at url: URL, byteCount: UInt64, to output: FileHandle) throws { + let input = try FileHandle(forReadingFrom: url) + defer { try? input.close() } + + var remaining = byteCount + let chunkSize: UInt64 = 1_024 * 1_024 + + while remaining > 0 { + try Task.checkCancellation() + + let requestedCount = Int(min(remaining, chunkSize)) + guard let data = try input.read(upToCount: requestedCount), !data.isEmpty else { + throw CocoaError( + .fileReadCorruptFile, + userInfo: [NSLocalizedDescriptionKey: "The source file changed while it was being transferred: \(url.lastPathComponent)"] + ) + } + + try output.write(contentsOf: data) + remaining -= UInt64(data.count) + } + } + + static func uniqueRootNames(for urls: [URL]) -> [String] { + var usedNames = Set() + + return urls.map { url in + let originalName = url.lastPathComponent + var candidate = originalName + var suffix = 2 + + while usedNames.contains(candidate) { + candidate = duplicateName(for: originalName, suffix: suffix) + suffix += 1 + } + + usedNames.insert(candidate) + return candidate + } + } + + static func duplicateName(for name: String, suffix: Int) -> String { + let url = URL(filePath: name) + let pathExtension = url.pathExtension + guard !pathExtension.isEmpty else { return "\(name) \(suffix)" } + + return "\(url.deletingPathExtension().lastPathComponent) \(suffix).\(pathExtension)" + } +} diff --git a/VirtualCore/Source/Virtualization/VMController.swift b/VirtualCore/Source/Virtualization/VMController.swift index e3ae799e..89e46bc6 100644 --- a/VirtualCore/Source/Virtualization/VMController.swift +++ b/VirtualCore/Source/Virtualization/VMController.swift @@ -10,6 +10,17 @@ import Foundation import Virtualization import Combine import OSLog +import VirtualWormhole + +public struct VMHostFileDragLocation: Hashable, Sendable { + public var x: Double + public var y: Double + + public init(x: Double, y: Double) { + self.x = min(max(x, 0), 1) + self.y = min(max(y, 0), 1) + } +} public struct VMSessionOptions: Hashable, Codable { @DecodableDefault.False @@ -428,6 +439,55 @@ public final class VMController: ObservableObject { instance?.activeBridgeInterfaceIdentifiers ?? [] } + public var acceptsHostFileDrops: Bool { + instance?.acceptsHostFileDrops == true + } + + public func stageHostFiles(_ sourceURLs: [URL], for sessionID: UUID) async throws { + try await ensureInstance().stageHostFiles(sourceURLs, for: sessionID) + } + + public func beginHostFileDrag(_ sessionID: UUID, at location: VMHostFileDragLocation) async throws { + try await sendFileDragMessage( + FileDragMessage( + action: .begin, + sessionID: sessionID, + location: FileDragLocation(x: location.x, y: location.y) + ) + ) + } + + public func updateHostFileDrag(_ sessionID: UUID, location: VMHostFileDragLocation) async throws { + try await sendFileDragMessage( + FileDragMessage( + action: .update, + sessionID: sessionID, + location: FileDragLocation(x: location.x, y: location.y) + ) + ) + } + + public func dropHostFiles(_ sessionID: UUID, at location: VMHostFileDragLocation) async throws { + try await sendFileDragMessage( + FileDragMessage( + action: .drop, + sessionID: sessionID, + location: FileDragLocation(x: location.x, y: location.y) + ) + ) + } + + public func cancelHostFileDrag(_ sessionID: UUID) async throws { + try await sendFileDragMessage( + FileDragMessage(action: .cancel, sessionID: sessionID) + ) + } + + private func sendFileDragMessage(_ message: FileDragMessage) async throws { + let instance = try ensureInstance() + await instance.sendFileDragMessage(message) + } + public func changeBridgeInterface(to interfaceIdentifier: String) throws { let instance = try ensureInstance() objectWillChange.send() diff --git a/VirtualCore/Source/Virtualization/VMInstance.swift b/VirtualCore/Source/Virtualization/VMInstance.swift index 43bd58f7..d03c365e 100644 --- a/VirtualCore/Source/Virtualization/VMInstance.swift +++ b/VirtualCore/Source/Virtualization/VMInstance.swift @@ -24,6 +24,8 @@ public final class VMInstance: NSObject, ObservableObject { private var _virtualMachine: VZVirtualMachine? private var networkAttachmentHelper: VMNetworkAttachmentHelper? + + private var fileTransferServer: HostFileTransferServer? var virtualMachine: VZVirtualMachine { get throws { @@ -126,6 +128,9 @@ public final class VMInstance: NSObject, ObservableObject { c.entropyDevices = helper.createEntropyDevices() c.audioDevices = model.configuration.vzAudioDevices c.directorySharingDevices = try model.configuration.vzSharedFoldersFileSystemDevices + if model.configuration.systemType == .mac { + c.socketDevices = [VZVirtioSocketDeviceConfiguration()] + } if let spiceAgent = helper.createSpiceAgentConsoleDeviceConfiguration() { c.consoleDevices = [spiceAgent] } @@ -168,6 +173,7 @@ public final class VMInstance: NSObject, ObservableObject { let virtualMachine = VZVirtualMachine(configuration: config) _virtualMachine = virtualMachine + configureFileTransfer(for: virtualMachine) networkAttachmentHelper = VMNetworkAttachmentHelper( virtualMachine: virtualMachine, configuration: config, @@ -175,6 +181,17 @@ public final class VMInstance: NSObject, ObservableObject { ) } + private func configureFileTransfer(for virtualMachine: VZVirtualMachine) { + guard let socketDevice = virtualMachine.socketDevices.compactMap({ $0 as? VZVirtioSocketDevice }).first else { + fileTransferServer = nil + return + } + + let server = HostFileTransferServer() + server.listen(on: socketDevice) + fileTransferServer = server + } + private func setupWormhole(for config: VZVirtualMachineConfiguration) async { guard virtualMachineModel.configuration.systemType == .mac else { return } @@ -203,10 +220,64 @@ public final class VMInstance: NSObject, ObservableObject { streamGuestNotifications() streamGuestDesktopPictureMessages() + streamGuestFileDragMessages() } private lazy var guestIOTasks = [Task]() + var acceptsHostFileDrops: Bool { + fileTransferServer?.isConnected == true + } + + func stageHostFiles(_ sourceURLs: [URL], for sessionID: UUID) async throws { + guard let fileTransferServer else { + throw CocoaError(.serviceApplicationNotFound) + } + + try await fileTransferServer.send(sessionID: sessionID, sourceURLs: sourceURLs) + } + + func sendFileDragMessage(_ message: FileDragMessage) async { + await wormhole.send(message, to: virtualMachineModel.wormholeID) + } + + private func streamGuestFileDragMessages() { + let guestID = virtualMachineModel.wormholeID + let task = Task { + do { + for try await message in wormhole.stream(for: FileDragMessage.self) { + guard message.senderID == guestID else { continue } + + switch message.payload.action { + case .status: + guard let status = message.payload.status else { continue } + if let location = message.payload.location { + logger.notice( + "Guest file drag \(message.payload.sessionID, privacy: .public) reached \(status.rawValue, privacy: .public) at \(location.x, privacy: .public), \(location.y, privacy: .public)" + ) + } else { + logger.notice( + "Guest file drag \(message.payload.sessionID, privacy: .public) reached \(status.rawValue, privacy: .public)" + ) + } + + case .result: + guard let operation = message.payload.operation else { continue } + logger.notice( + "Guest file drag \(message.payload.sessionID, privacy: .public) ended with operation \(operation, privacy: .public)" + ) + + case .begin, .update, .drop, .cancel: + continue + } + } + } catch { + logger.error("File drag message stream ended: \(error, privacy: .public)") + } + } + guestIOTasks.append(task) + } + public func streamGuestNotifications() { logger.debug(#function) @@ -338,6 +409,8 @@ public final class VMInstance: NSObject, ObservableObject { try await vm.stop() networkAttachmentHelper?.stop() + fileTransferServer?.invalidate() + fileTransferServer = nil library.unregisterBootedVM(self) } diff --git a/VirtualUI/Source/Session/Components/HostFileDragCoordinator.swift b/VirtualUI/Source/Session/Components/HostFileDragCoordinator.swift new file mode 100644 index 00000000..cd5215bb --- /dev/null +++ b/VirtualUI/Source/Session/Components/HostFileDragCoordinator.swift @@ -0,0 +1,169 @@ +import AppKit +import OSLog +import VirtualCore + +@MainActor +final class HostFileDragCoordinator { + private final class Session { + let id = UUID() + let sourceURLs: [URL] + var latestLocation: VMHostFileDragLocation + var hasBegunInGuest = false + var dropRequested = false + var isCancelled = false + var lastUpdateDate = Date.distantPast + var transferTask: Task? + + init(sourceURLs: [URL], location: VMHostFileDragLocation) { + self.sourceURLs = sourceURLs + self.latestLocation = location + } + } + + private let logger = Logger(for: HostFileDragCoordinator.self) + weak var controller: VMController? + private var session: Session? + + func draggingEntered(_ draggingInfo: NSDraggingInfo, in view: NSView) -> NSDragOperation { + cancelCurrentSession() + + guard let controller, controller.acceptsHostFileDrops else { return [] } + guard let sourceURLs = fileURLs(from: draggingInfo), !sourceURLs.isEmpty else { return [] } + + let session = Session( + sourceURLs: sourceURLs, + location: normalizedLocation(of: draggingInfo, in: view) + ) + self.session = session + logger.notice( + "Host file drag \(session.id, privacy: .public) entered with \(sourceURLs.count, privacy: .public) item(s) at \(session.latestLocation.x, privacy: .public), \(session.latestLocation.y, privacy: .public)" + ) + + session.transferTask = Task { [weak self, weak controller, weak session] in + guard let self, let controller, let session else { return } + + do { + try await controller.stageHostFiles(session.sourceURLs, for: session.id) + guard self.session === session, !session.isCancelled else { return } + + session.hasBegunInGuest = true + try await controller.beginHostFileDrag(session.id, at: session.latestLocation) + self.logger.notice( + "Host file drag \(session.id, privacy: .public) begin sent at \(session.latestLocation.x, privacy: .public), \(session.latestLocation.y, privacy: .public)" + ) + + if session.dropRequested { + try await self.sendDrop(for: session, through: controller) + } + } catch is CancellationError { + return + } catch { + self.logger.error("Host file transfer failed: \(error, privacy: .public)") + guard self.session === session else { return } + self.session = nil + NSSound.beep() + } + } + + return .copy + } + + func draggingUpdated(_ draggingInfo: NSDraggingInfo, in view: NSView) -> NSDragOperation { + guard let session, !session.isCancelled else { return [] } + + session.latestLocation = normalizedLocation(of: draggingInfo, in: view) + + guard session.hasBegunInGuest, + Date.now.timeIntervalSince(session.lastUpdateDate) >= 1.0 / 30.0, + let controller + else { return .copy } + + session.lastUpdateDate = .now + let location = session.latestLocation + Task { + do { + try await controller.updateHostFileDrag(session.id, location: location) + } catch { + self.logger.error( + "Host file drag \(session.id, privacy: .public) update failed: \(error, privacy: .public)" + ) + } + } + + return .copy + } + + func draggingExited() { + guard session?.dropRequested != true else { return } + cancelCurrentSession() + } + + func performDragOperation(_ draggingInfo: NSDraggingInfo, in view: NSView) -> Bool { + guard let session, !session.isCancelled else { return false } + + session.latestLocation = normalizedLocation(of: draggingInfo, in: view) + session.dropRequested = true + logger.notice( + "Host file drag \(session.id, privacy: .public) drop requested at \(session.latestLocation.x, privacy: .public), \(session.latestLocation.y, privacy: .public)" + ) + + if session.hasBegunInGuest, let controller { + Task { [weak self, weak controller, weak session] in + guard let self, let controller, let session else { return } + do { + try await self.sendDrop(for: session, through: controller) + } catch { + self.logger.error( + "Host file drag \(session.id, privacy: .public) drop failed: \(error, privacy: .public)" + ) + } + } + } + + return true + } + + private func sendDrop(for session: Session, through controller: VMController) async throws { + guard self.session === session, !session.isCancelled else { return } + + try await controller.dropHostFiles(session.id, at: session.latestLocation) + logger.notice("Host file drag \(session.id, privacy: .public) drop sent") + self.session = nil + } + + private func cancelCurrentSession() { + guard let session else { return } + + session.isCancelled = true + session.transferTask?.cancel() + self.session = nil + logger.notice("Host file drag \(session.id, privacy: .public) cancelled") + + guard session.hasBegunInGuest, let controller else { return } + Task { + try? await controller.cancelHostFileDrag(session.id) + } + } + + private func fileURLs(from draggingInfo: NSDraggingInfo) -> [URL]? { + let options: [NSPasteboard.ReadingOptionKey: Any] = [ + .urlReadingFileURLsOnly: true + ] + + return draggingInfo.draggingPasteboard + .readObjects(forClasses: [NSURL.self], options: options)? + .compactMap { ($0 as? NSURL) as URL? } + } + + private func normalizedLocation(of draggingInfo: NSDraggingInfo, in view: NSView) -> VMHostFileDragLocation { + let point = view.convert(draggingInfo.draggingLocation, from: nil) + guard view.bounds.width > 0, view.bounds.height > 0 else { + return VMHostFileDragLocation(x: 0.5, y: 0.5) + } + + return VMHostFileDragLocation( + x: point.x / view.bounds.width, + y: point.y / view.bounds.height + ) + } +} diff --git a/VirtualUI/Source/Session/Components/SwiftUIVMView.swift b/VirtualUI/Source/Session/Components/SwiftUIVMView.swift index 41c8987f..740160e9 100644 --- a/VirtualUI/Source/Session/Components/SwiftUIVMView.swift +++ b/VirtualUI/Source/Session/Components/SwiftUIVMView.swift @@ -29,6 +29,7 @@ struct SwiftUIVMView: NSViewControllerRepresentable { var isDFUModeVM: Bool var vmECID: UInt64? @Binding var automaticallyReconfiguresDisplay: Bool + var fileDropController: VMController? = nil func makeNSViewController(context: Context) -> VMViewController { let controller = VMViewController() @@ -36,6 +37,7 @@ struct SwiftUIVMView: NSViewControllerRepresentable { controller.isDFUModeVM = isDFUModeVM controller.captureSystemKeys = captureSystemKeys controller.automaticallyReconfiguresDisplay = automaticallyReconfiguresDisplay + controller.fileDropController = fileDropController return controller } @@ -45,6 +47,7 @@ struct SwiftUIVMView: NSViewControllerRepresentable { nsViewController.vmECID = vmECID nsViewController.isDFUModeVM = isDFUModeVM nsViewController.interactionDisabled = context.environment.virtualMachineInteractionDisabled + nsViewController.fileDropController = fileDropController if case .running(let vm) = controllerState { nsViewController.virtualMachine = vm @@ -94,6 +97,12 @@ final class VMViewController: NSViewController { } } + weak var fileDropController: VMController? { + didSet { + vmView.fileDragCoordinator.controller = fileDropController + } + } + private var canShowDFUView: Bool { #if DEBUG return ProcessInfo.isSwiftUIPreview || virtualMachine != nil @@ -232,8 +241,43 @@ struct DFUStatusView: View { } final class VirtualBuddyVMView: VZVirtualMachineView { + let fileDragCoordinator = HostFileDragCoordinator() + var isViewOnly = false + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + registerForDraggedTypes([.fileURL]) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + registerForDraggedTypes([.fileURL]) + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + guard !isViewOnly else { return [] } + return fileDragCoordinator.draggingEntered(sender, in: self) + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + guard !isViewOnly else { return [] } + return fileDragCoordinator.draggingUpdated(sender, in: self) + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + fileDragCoordinator.draggingExited() + } + + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { + !isViewOnly + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + guard !isViewOnly else { return false } + return fileDragCoordinator.performDragOperation(sender, in: self) + } + override func hitTest(_ point: NSPoint) -> NSView? { guard !isViewOnly else { return nil } return super.hitTest(point) diff --git a/VirtualUI/Source/Session/VirtualMachineSessionView.swift b/VirtualUI/Source/Session/VirtualMachineSessionView.swift index c5eddabf..7990f230 100644 --- a/VirtualUI/Source/Session/VirtualMachineSessionView.swift +++ b/VirtualUI/Source/Session/VirtualMachineSessionView.swift @@ -126,7 +126,8 @@ public struct VirtualMachineSessionView: View { captureSystemKeys: controller.virtualMachineModel.configuration.captureSystemKeys, isDFUModeVM: controller.options.bootInDFUMode, vmECID: controller.virtualMachineModel.ECID, - automaticallyReconfiguresDisplay: .constant(controller.virtualMachineModel.configuration.hardware.displayDevices.count > 0 ? controller.virtualMachineModel.configuration.hardware.displayDevices[0].automaticallyReconfiguresDisplay : false) + automaticallyReconfiguresDisplay: .constant(controller.virtualMachineModel.configuration.hardware.displayDevices.count > 0 ? controller.virtualMachineModel.configuration.hardware.displayDevices[0].automaticallyReconfiguresDisplay : false), + fileDropController: controller ) } diff --git a/VirtualWormhole/Source/Services/FileDrag/FileDragMessage.swift b/VirtualWormhole/Source/Services/FileDrag/FileDragMessage.swift new file mode 100644 index 00000000..3c9592a4 --- /dev/null +++ b/VirtualWormhole/Source/Services/FileDrag/FileDragMessage.swift @@ -0,0 +1,57 @@ +import Foundation + +public struct FileDragLocation: Codable, Hashable, Sendable { + public var x: Double + public var y: Double + + public init(x: Double, y: Double) { + self.x = min(max(x, 0), 1) + self.y = min(max(y, 0), 1) + } +} + +public struct FileDragMessage: WHPayload, Hashable { + public enum Action: String, Codable, Hashable, Sendable { + case begin + case update + case drop + case cancel + case status + case result + } + + public enum Status: String, Codable, Hashable, Sendable { + case staged + case beginReceived + case sourceReady + case inputPermissionMissing + case mouseDownPosted + case pointerObserved + case sessionCreated + case sessionWillBegin + case sessionMoved + case dropReceived + case mouseUpPosted + case sessionStartTimedOut + } + + public var action: Action + public var sessionID: UUID + public var location: FileDragLocation? + public var operation: UInt? + public var status: Status? + + public init( + action: Action, + sessionID: UUID, + location: FileDragLocation? = nil, + operation: UInt? = nil, + status: Status? = nil + ) { + self.action = action + self.sessionID = sessionID + self.location = location + self.operation = operation + self.status = status + } +} diff --git a/VirtualWormhole/Source/Services/FileDrag/FileTransferProtocol.swift b/VirtualWormhole/Source/Services/FileDrag/FileTransferProtocol.swift new file mode 100644 index 00000000..0e320314 --- /dev/null +++ b/VirtualWormhole/Source/Services/FileDrag/FileTransferProtocol.swift @@ -0,0 +1,148 @@ +import Foundation + +public enum FileTransferProtocol { + public static let port: UInt32 = 51_050 + public static let version = 1 + + private static let maximumFrameLength = 16 * 1_024 * 1_024 + + public static func writeFrame(_ value: T, to handle: FileHandle) throws { + let data = try JSONEncoder.wormhole.encode(value) + try writeFrame(data, to: handle) + } + + public static func writeFrame(_ data: Data, to handle: FileHandle) throws { + var encodedLength = UInt64(data.count).littleEndian + let lengthData = withUnsafeBytes(of: &encodedLength) { Data($0) } + try handle.write(contentsOf: lengthData) + try handle.write(contentsOf: data) + } + + public static func writeEnd(to handle: FileHandle) throws { + var encodedLength = UInt64.zero + try handle.write(contentsOf: withUnsafeBytes(of: &encodedLength) { Data($0) }) + } + + public static func readFrame(_ type: T.Type, from handle: FileHandle) throws -> T? { + guard let data = try readFrame(from: handle) else { return nil } + return try JSONDecoder.wormhole.decode(type, from: data) + } + + public static func readFrame(from handle: FileHandle) throws -> Data? { + let lengthData = try readExactly(MemoryLayout.size, from: handle) + let length = lengthData.withUnsafeBytes { buffer in + UInt64(littleEndian: buffer.loadUnaligned(as: UInt64.self)) + } + + guard length > 0 else { return nil } + guard length <= maximumFrameLength else { + throw CocoaError( + .fileReadCorruptFile, + userInfo: [NSLocalizedDescriptionKey: "File transfer frame is too large: \(length) bytes."] + ) + } + + return try readExactly(Int(length), from: handle) + } + + public static func readExactly(_ count: Int, from handle: FileHandle) throws -> Data { + guard count >= 0 else { + throw CocoaError(.fileReadCorruptFile) + } + + var data = Data(capacity: count) + + while data.count < count { + try Task.checkCancellation() + + guard let chunk = try handle.read(upToCount: count - data.count), !chunk.isEmpty else { + throw CocoaError( + .fileReadCorruptFile, + userInfo: [NSLocalizedDescriptionKey: "The file transfer connection closed before all data arrived."] + ) + } + + data.append(chunk) + } + + return data + } +} + +public struct FileTransferManifest: Codable, Hashable, Sendable { + public var version: Int + public var sessionID: UUID + public var rootPaths: [String] + + public init(sessionID: UUID, rootPaths: [String]) { + self.version = FileTransferProtocol.version + self.sessionID = sessionID + self.rootPaths = rootPaths + } +} + +public struct FileTransferEntry: Codable, Hashable, Sendable { + public enum Kind: String, Codable, Hashable, Sendable { + case directory + case regularFile + case symbolicLink + } + + public var relativePath: String + public var kind: Kind + public var byteCount: UInt64 + public var posixPermissions: UInt16? + public var modificationDate: Date? + public var symbolicLinkDestination: String? + + public init( + relativePath: String, + kind: Kind, + byteCount: UInt64 = 0, + posixPermissions: UInt16? = nil, + modificationDate: Date? = nil, + symbolicLinkDestination: String? = nil + ) { + self.relativePath = relativePath + self.kind = kind + self.byteCount = byteCount + self.posixPermissions = posixPermissions + self.modificationDate = modificationDate + self.symbolicLinkDestination = symbolicLinkDestination + } +} + +public enum FileTransferPath { + public static func destination(for relativePath: String, under root: URL) throws -> URL { + guard isValid(relativePath) else { + throw CocoaError( + .fileReadInvalidFileName, + userInfo: [NSLocalizedDescriptionKey: "Invalid file transfer path: \(relativePath)"] + ) + } + + let standardizedRoot = root.standardizedFileURL + let destination = standardizedRoot.appending(path: relativePath).standardizedFileURL + let rootPrefix = standardizedRoot.path.hasSuffix("/") ? standardizedRoot.path : standardizedRoot.path + "/" + + guard destination.path.hasPrefix(rootPrefix) else { + throw CocoaError( + .fileReadInvalidFileName, + userInfo: [NSLocalizedDescriptionKey: "File transfer path escapes its destination: \(relativePath)"] + ) + } + + return destination + } + + public static func isValid(_ relativePath: String) -> Bool { + guard !relativePath.isEmpty, + !relativePath.contains("\0"), + !(relativePath as NSString).isAbsolutePath + else { return false } + + return relativePath.split(separator: "/", omittingEmptySubsequences: false).allSatisfy { component in + !component.isEmpty && component != "." && component != ".." + } + } +} diff --git a/VirtualWormholeTests/FileTransferProtocolTests.swift b/VirtualWormholeTests/FileTransferProtocolTests.swift new file mode 100644 index 00000000..ac1a8684 --- /dev/null +++ b/VirtualWormholeTests/FileTransferProtocolTests.swift @@ -0,0 +1,61 @@ +import XCTest +@testable import VirtualWormhole + +final class FileTransferProtocolTests: XCTestCase { + func testManifestAndEntryFramesRoundTrip() throws { + let sessionID = UUID() + let manifest = FileTransferManifest(sessionID: sessionID, rootPaths: ["Example.txt"]) + let entry = FileTransferEntry( + relativePath: "Example.txt", + kind: .regularFile, + byteCount: 12, + posixPermissions: 0o644, + modificationDate: Date(timeIntervalSinceReferenceDate: 123) + ) + let handle = try temporaryFileHandle() + defer { try? handle.close() } + + try FileTransferProtocol.writeFrame(manifest, to: handle) + try FileTransferProtocol.writeFrame(entry, to: handle) + try FileTransferProtocol.writeEnd(to: handle) + try handle.seek(toOffset: 0) + + XCTAssertEqual( + try FileTransferProtocol.readFrame(FileTransferManifest.self, from: handle), + manifest + ) + XCTAssertEqual( + try FileTransferProtocol.readFrame(FileTransferEntry.self, from: handle), + entry + ) + XCTAssertNil(try FileTransferProtocol.readFrame(FileTransferEntry.self, from: handle)) + } + + func testRelativePathValidationRejectsEscapes() { + XCTAssertFalse(FileTransferPath.isValid("")) + XCTAssertFalse(FileTransferPath.isValid("/tmp/file")) + XCTAssertFalse(FileTransferPath.isValid("../file")) + XCTAssertFalse(FileTransferPath.isValid("folder/../file")) + XCTAssertFalse(FileTransferPath.isValid("folder//file")) + XCTAssertFalse(FileTransferPath.isValid("folder/./file")) + XCTAssertFalse(FileTransferPath.isValid("folder\0file")) + } + + func testRelativePathValidationAcceptsNestedPaths() throws { + let root = URL(filePath: "/tmp/VirtualBuddyFileTransferTest", directoryHint: .isDirectory) + let destination = try FileTransferPath.destination( + for: "Folder/Nested File.txt", + under: root + ) + + XCTAssertEqual(destination.path, "/tmp/VirtualBuddyFileTransferTest/Folder/Nested File.txt") + } + + private func temporaryFileHandle() throws -> FileHandle { + let fileURL = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + FileManager.default.createFile(atPath: fileURL.path, contents: nil) + addTeardownBlock { try? FileManager.default.removeItem(at: fileURL) } + return try FileHandle(forUpdating: fileURL) + } +}