feat: scaffold guest-side drop synthesis (host plumbing only)

Adds the host-side hook for the forthcoming tart-guest-agent DragAndDrop RPC
that will perform a real drag-and-drop at the cursor's position inside the
guest instead of dumping files into a generic share folder.

- DropGeometry.normalize: pure helper turning a view-local drop point into
  top-left (0..1) coordinates the guest agent can map onto its screen.
- VMContainerView.performDragOperation: capture draggingLocation on the
  main thread (only valid here), compute normalized point, then dispatch
  the copy off-main as before.
- synthesizeGuestDrop: documented no-op stub called from the copy completion
  path. When the agent RPC lands, this becomes the real call site; until
  then it returns false and the existing share-folder copy is the
  user-visible result.

No behavior change for users today.
This commit is contained in:
Dal Rupnik 2026-05-13 12:48:29 +02:00
parent 2c5037ea50
commit 474fd47b6f
2 changed files with 156 additions and 0 deletions

View File

@ -960,6 +960,27 @@ struct VMView: NSViewRepresentable {
// MARK: - Drag and Drop
/// Pure coordinate-conversion helpers. Kept as a stand-alone enum so it can be
/// unit-tested without dragging in AppKit/SwiftUI types.
enum DropGeometry {
/// Normalizes a drop point given in a view's coordinate space to (0..1, 0..1)
/// with (0, 0) at top-left and (1, 1) at bottom-right. AppKit views default
/// to a bottom-left origin; we flip Y so the guest agent receives a top-down
/// coordinate that matches CGEvent's global screen space without needing to
/// know the host view's orientation.
static func normalize(point: CGPoint, inViewBoundsSize size: CGSize, isViewFlipped: Bool) -> CGPoint {
let width = max(size.width, 1)
let height = max(size.height, 1)
let xNorm = point.x / width
let yLocal = point.y / height
let yNorm = isViewFlipped ? yLocal : (1 - yLocal)
return CGPoint(
x: max(0, min(1, xNorm)),
y: max(0, min(1, yNorm))
)
}
}
/// Layer-backed highlight overlay. Added as a subview of TartVirtualMachineView
/// so its CALayer composites on top of Metal rendering. hitTest returns nil so
/// it is completely transparent to pointer events.
@ -1082,6 +1103,18 @@ class VMContainerView: NSView {
guard let dropZoneURL = machineView.dropZoneURL else { return false }
let urls = fileURLs(from: sender)
guard !urls.isEmpty else { return false }
// Compute the drop location in normalized view coordinates while we're
// still on the main thread (dragging info is only valid here). The
// forthcoming guest-side drop synthesis uses this to place files where
// the user actually pointed instead of in a generic share folder.
let localPoint = self.convert(sender.draggingLocation, from: nil)
let normalizedPoint = DropGeometry.normalize(
point: localPoint,
inViewBoundsSize: self.bounds.size,
isViewFlipped: self.isFlipped
)
// Copy off the main thread: large files would otherwise freeze the VM
// window (which is the same view that's rendering the VM's framebuffer).
copyQueue.async {
@ -1092,6 +1125,15 @@ class VMContainerView: NSView {
try FileManager.default.removeItem(at: dest)
}
try FileManager.default.copyItem(at: url, to: dest)
// After the file lands in the shared drop zone, ask the guest agent
// to perform a real drop at the cursor's normalized position. Today
// this is a no-op (returns false) so the user-visible result remains
// "file appears in /Volumes/My Shared Files/Dropped Files/".
let destPath = dest.path
Task {
_ = await synthesizeGuestDrop(path: destPath, atNormalized: normalizedPoint)
}
} catch {
let name = url.lastPathComponent
let message = error.localizedDescription
@ -1116,6 +1158,30 @@ class VMContainerView: NSView {
}
}
/// Asks the in-guest tart-guest-agent to perform a drag-and-drop of `path` at
/// the given normalized coordinates (origin top-left, (1, 1) bottom-right) so
/// that whatever window the cursor is over receives the file as a real drop.
///
/// Returns true if the guest agent reports a successful drop; in that case the
/// caller treats the share-folder copy as a side effect rather than the
/// user-visible result. Returns false when the agent is unreachable, returns
/// an error, or the corresponding RPC isn't available the caller's existing
/// fallback (file accessible at /Volumes/My Shared Files/Dropped Files/) then
/// remains the user-visible outcome.
///
/// Today this is a no-op stub. Wiring it up requires:
/// 1. A new `DragAndDrop` RPC in tart-guest-agent-proto.
/// 2. A handler in tart-guest-agent (likely NSView.beginDraggingSession from
/// a hidden 1x1 NSWindow at the cursor's screen position, with an
/// AppleScript-into-Finder fallback).
/// 3. Pulling the running VM's controlSocketURL into the call site (cf.
/// Exec.swift) and reusing the existing AgentAsyncClient pattern.
private func synthesizeGuestDrop(path: String, atNormalized normalized: CGPoint) async -> Bool {
_ = path
_ = normalized
return false
}
struct AdditionalDisk {
let configuration: VZStorageDeviceConfiguration

View File

@ -0,0 +1,90 @@
import XCTest
@testable import tart
final class DropGeometryTests: XCTestCase {
func testTopLeftOfUnflippedView() throws {
// Bottom-up view: a point at (0, viewHeight) is the top-left corner.
let point = DropGeometry.normalize(
point: CGPoint(x: 0, y: 1000),
inViewBoundsSize: CGSize(width: 1280, height: 1000),
isViewFlipped: false
)
XCTAssertEqual(point.x, 0, accuracy: 0.0001)
XCTAssertEqual(point.y, 0, accuracy: 0.0001)
}
func testBottomRightOfUnflippedView() throws {
let point = DropGeometry.normalize(
point: CGPoint(x: 1280, y: 0),
inViewBoundsSize: CGSize(width: 1280, height: 1000),
isViewFlipped: false
)
XCTAssertEqual(point.x, 1, accuracy: 0.0001)
XCTAssertEqual(point.y, 1, accuracy: 0.0001)
}
func testCenterOfUnflippedView() throws {
let point = DropGeometry.normalize(
point: CGPoint(x: 640, y: 500),
inViewBoundsSize: CGSize(width: 1280, height: 1000),
isViewFlipped: false
)
XCTAssertEqual(point.x, 0.5, accuracy: 0.0001)
XCTAssertEqual(point.y, 0.5, accuracy: 0.0001)
}
func testTopLeftOfFlippedView() throws {
// Flipped view: y increases downward, so (0, 0) is already top-left.
let point = DropGeometry.normalize(
point: CGPoint(x: 0, y: 0),
inViewBoundsSize: CGSize(width: 1280, height: 1000),
isViewFlipped: true
)
XCTAssertEqual(point.x, 0, accuracy: 0.0001)
XCTAssertEqual(point.y, 0, accuracy: 0.0001)
}
func testBottomRightOfFlippedView() throws {
let point = DropGeometry.normalize(
point: CGPoint(x: 1280, y: 1000),
inViewBoundsSize: CGSize(width: 1280, height: 1000),
isViewFlipped: true
)
XCTAssertEqual(point.x, 1, accuracy: 0.0001)
XCTAssertEqual(point.y, 1, accuracy: 0.0001)
}
// The guest agent expects coordinates in [0, 1]; out-of-bounds points (which
// AppKit can occasionally deliver right at the view edge) get clamped so the
// agent never receives a negative or >1 value.
func testClampsBelowZero() throws {
let point = DropGeometry.normalize(
point: CGPoint(x: -5, y: 1010),
inViewBoundsSize: CGSize(width: 1280, height: 1000),
isViewFlipped: false
)
XCTAssertEqual(point.x, 0)
XCTAssertEqual(point.y, 0)
}
func testClampsAboveOne() throws {
let point = DropGeometry.normalize(
point: CGPoint(x: 1500, y: -100),
inViewBoundsSize: CGSize(width: 1280, height: 1000),
isViewFlipped: false
)
XCTAssertEqual(point.x, 1)
XCTAssertEqual(point.y, 1)
}
// Zero-sized view shouldn't divide by zero; max(size, 1) protects us.
func testZeroSizedViewDoesNotCrash() throws {
let point = DropGeometry.normalize(
point: CGPoint(x: 0, y: 0),
inViewBoundsSize: .zero,
isViewFlipped: false
)
XCTAssertEqual(point.x, 0)
XCTAssertEqual(point.y, 1) // y flipped: (1 - 0/1) = 1, clamped
}
}