feat: relocate dropped files to frontmost Finder folder, not the share

The previous guest-drop synthesis only ran `open -R` on the file's
location in the "Dropped Files" share. That just revealed it in the
share, which was the user-visible bug we were trying to fix: dropped
files appeared in `/Volumes/My Shared Files/Dropped Files/` instead
of somewhere natural.

Now the agent's exec script asks Finder for the frontmost window's
POSIX path via `osascript`, moves the file out of the share into that
folder, and then reveals it. If no Finder window is open, the path
isn't writable, or it points back at the share itself, the script
falls back to `~/Desktop`. Filename collisions get suffixed
(`foo.txt` → `foo 2.txt`, …) instead of clobbering.

osascript here runs in the agent's user-GUI session, so the first
drop triggers an Automation→Finder TCC prompt in the guest the user
approves once.

Verified end-to-end via `tart exec` (same RPC path the drop handler
uses): no Finder window → ~/Desktop, Finder on Downloads → Downloads,
Finder on Dropped Files itself → ~/Desktop fallback.
This commit is contained in:
Dal Rupnik 2026-05-13 15:07:17 +02:00
parent 474fd47b6f
commit 6af39e807f
2 changed files with 238 additions and 25 deletions

View File

@ -647,7 +647,12 @@ struct Run: AsyncParsableCommand {
NSApplication.shared.run()
} else {
runUI(suspendable, captureSystemKeys, dropZoneURL: dropZoneURL)
runUI(
suspendable,
captureSystemKeys,
dropZoneURL: dropZoneURL,
controlSocketURL: dropZoneURL != nil ? vmDir.controlSocketURL : nil
)
}
}
@ -799,10 +804,11 @@ struct Run: AsyncParsableCommand {
#endif
}
private func runUI(_ suspendable: Bool, _ captureSystemKeys: Bool, dropZoneURL: URL?) {
private func runUI(_ suspendable: Bool, _ captureSystemKeys: Bool, dropZoneURL: URL?, controlSocketURL: URL?) {
MainApp.suspendable = suspendable
MainApp.capturesSystemKeys = captureSystemKeys
MainApp.dropZoneURL = dropZoneURL
MainApp.controlSocketURL = controlSocketURL
MainApp.main()
}
}
@ -832,6 +838,7 @@ struct MainApp: App {
static var suspendable: Bool = false
static var capturesSystemKeys: Bool = false
static var dropZoneURL: URL? = nil
static var controlSocketURL: URL? = nil
@NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
@ -1115,6 +1122,10 @@ class VMContainerView: NSView {
isViewFlipped: self.isFlipped
)
// Snapshot the control socket URL on the main actor so the background
// Task can use it without crossing actor boundaries.
let controlSocketURL = MainApp.controlSocketURL
// 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 {
@ -1127,12 +1138,16 @@ class VMContainerView: NSView {
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/".
// to make the drop visible Finder-window duplicate when possible,
// reveal-in-Finder otherwise. If the agent isn't reachable, the
// file in the share folder is still the fallback.
let destPath = dest.path
Task {
_ = await synthesizeGuestDrop(path: destPath, atNormalized: normalizedPoint)
_ = await synthesizeGuestDrop(
hostFilePath: destPath,
atNormalized: normalizedPoint,
controlSocketURL: controlSocketURL
)
}
} catch {
let name = url.lastPathComponent
@ -1158,28 +1173,41 @@ 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.
/// Asks the in-guest tart-guest-agent to place `hostFilePath` somewhere visible
/// into the frontmost Finder window if there is one, otherwise revealed in
/// Finder. The host path is rewritten to the guest's mount of the drop share
/// (`/Volumes/My Shared Files/Dropped Files/<filename>`) before being passed
/// across the wire.
///
/// 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.
/// `normalizedPoint` is captured for a future RPC that actually targets the
/// cursor's position; for now the AppleScript path uses frontmost-app
/// heuristics and the coordinate is unused.
///
/// 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
/// Returns true when the agent reports a visible outcome (file landed in
/// Finder or got revealed). On any failure agent unreachable, no guest
/// agent installed, AppleScript erroring returns false so the caller's
/// existing share-folder behavior remains the user-visible result.
private func synthesizeGuestDrop(
hostFilePath: String,
atNormalized normalized: CGPoint,
controlSocketURL: URL?
) async -> Bool {
_ = normalized
return false
guard let controlSocketURL = controlSocketURL else { return false }
let filename = (hostFilePath as NSString).lastPathComponent
let guestPath = "/Volumes/My Shared Files/Dropped Files/" + filename
do {
let outcome = try await GuestDropSynthesis.perform(
controlSocketURL: controlSocketURL,
guestFilePath: guestPath
)
_ = outcome
return true
} catch {
return false
}
}
struct AdditionalDisk {

View File

@ -0,0 +1,185 @@
import Foundation
import NIOPosix
import GRPC
import Cirruslabs_TartGuestAgent_Apple_Swift
import Cirruslabs_TartGuestAgent_Grpc_Swift
enum GuestDropOutcome {
/// Guest agent revealed the file in Finder. A Finder window opens pointing
/// at the file's containing folder with the file selected, giving the user
/// immediate visual feedback that the drop landed.
case revealed
}
/// Errors that the host treats as "agent path didn't work; fall back to
/// share-folder behavior." We intentionally swallow these in the caller so
/// the existing share-folder copy remains the visible result.
enum GuestDropError: Error {
case agentUnreachable
case execFailed(exitCode: Int32, stderr: String)
case unexpectedOutput(String)
case timedOut
}
/// Asks the in-guest tart-guest-agent to relocate `guestFilePath` from the
/// "Dropped Files" share into a user-visible location, then reveal it in
/// Finder. The result is that a drag-and-drop drop appears either in the
/// folder of the user's frontmost Finder window matching the intuition
/// "I dragged it here, it's here now" or on the Desktop as a fallback.
///
/// Under the hood we run a single `/bin/sh -c` script in the guest via the
/// agent's Exec RPC. The script uses `osascript` to query Finder for the
/// frontmost window's POSIX path; this runs in the agent's user-GUI session
/// (the `--run-agent` invocation), so the first drop will produce a TCC
/// AutomationFinder prompt in the guest that the user must approve once.
/// If Finder has no window open, isn't responding, or returns the same
/// folder the file is already in (e.g. the user is staring at "Dropped
/// Files"), we fall back to `~/Desktop`. The file is finally revealed with
/// `open -R` so Finder pops a window with it selected.
enum GuestDropSynthesis {
/// Shell script body executed inside the guest. The dropped file's guest
/// path is passed as `$1` via the `sh -c CMD -- $1` convention (with
/// `tartdrop` as `$0` so error messages identify us).
///
/// Exit codes: 0 on success, non-zero on failure (caller treats any
/// non-zero as "fall back to the share-folder copy that's already on disk").
private static let relocateAndRevealScript = #"""
set -e
src=$1
if [ -z "$src" ] || [ ! -e "$src" ]; then
echo "tartdrop: missing or nonexistent source: $src" >&2
exit 2
fi
name=$(basename "$src")
src_dir=$(dirname "$src")
# Ask Finder for the frontmost window's folder. Suppress stderr so a TCC
# denial or "no windows" error doesn't pollute the agent log; we detect
# those by an empty result. The agent runs in the user's GUI session, so
# osascript here goes through the user's TCC consent (AutomationFinder).
dest_dir=$(/usr/bin/osascript <<'OSA' 2>/dev/null || true
tell application "Finder"
if (count of Finder windows) is 0 then return ""
try
return POSIX path of (target of front Finder window as alias)
on error
return ""
end try
end tell
OSA
)
# AppleScript appends a trailing slash to folder POSIX paths.
dest_dir=${dest_dir%/}
# Reject empty / nonexistent / non-writable destinations, and reject the
# source's own parent (which would either be a no-op rename or leave us
# putting the file right back into "Dropped Files").
if [ -z "$dest_dir" ] || [ ! -d "$dest_dir" ] || [ ! -w "$dest_dir" ] || [ "$dest_dir" = "$src_dir" ]; then
dest_dir=$HOME/Desktop
fi
# Pick a non-clobbering filename: "foo.txt" "foo 2.txt", "foo 3.txt"
final=$dest_dir/$name
if [ -e "$final" ]; then
stem=${name%.*}
ext=${name##*.}
if [ "$stem" = "$name" ]; then
i=2
while [ -e "$dest_dir/$name $i" ]; do i=$((i+1)); done
final="$dest_dir/$name $i"
else
i=2
while [ -e "$dest_dir/$stem $i.$ext" ]; do i=$((i+1)); done
final="$dest_dir/$stem $i.$ext"
fi
fi
mv -- "$src" "$final"
/usr/bin/open -R "$final"
"""#
static func perform(
controlSocketURL: URL,
guestFilePath: String
) async throws -> GuestDropOutcome {
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer { try? group.syncShutdownGracefully() }
// Work around the 104-byte UDS path limit by chdir'ing to the socket's
// parent directory and connecting via the relative name (same trick as
// Exec.swift). Restore cwd afterwards so we don't surprise the rest of
// the host process.
let originalCWD = FileManager.default.currentDirectoryPath
if let baseURL = controlSocketURL.baseURL {
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
}
defer { FileManager.default.changeCurrentDirectoryPath(originalCWD) }
let channel: GRPCChannel
do {
channel = try GRPCChannelPool.with(
target: .unixDomainSocket(controlSocketURL.relativePath),
transportSecurity: .plaintext,
eventLoopGroup: group
)
} catch {
throw GuestDropError.agentUnreachable
}
defer { try? channel.close().wait() }
let callOptions = CallOptions(timeLimit: .timeout(.seconds(8)))
let client = AgentAsyncClient(channel: channel, defaultCallOptions: callOptions)
let execCall = client.makeExecCall()
// Pass the guest path as $1 (with "tartdrop" as $0 so error messages
// identify us). The script does the Finder-window lookup, picks a
// destination, moves the file out of "Dropped Files", and reveals it.
let command = ExecRequest.with {
$0.type = .command(ExecRequest.Command.with {
$0.name = "/bin/sh"
$0.args = ["-c", relocateAndRevealScript, "tartdrop", guestFilePath]
$0.interactive = false
$0.tty = false
})
}
do {
try await execCall.requestStream.send(command)
execCall.requestStream.finish()
} catch let error as GRPCConnectionPoolError {
_ = error
throw GuestDropError.agentUnreachable
}
var stdout = ""
var stderr = ""
var exitCode: Int32 = -1
do {
for try await response in execCall.responseStream {
switch response.type {
case .standardOutput(let chunk):
stdout += String(data: chunk.data, encoding: .utf8) ?? ""
case .standardError(let chunk):
stderr += String(data: chunk.data, encoding: .utf8) ?? ""
case .exit(let exit):
exitCode = exit.code
default:
continue
}
}
} catch let error as GRPCStatus {
if error.code == .deadlineExceeded {
throw GuestDropError.timedOut
}
throw GuestDropError.execFailed(exitCode: -1, stderr: error.localizedDescription)
}
if exitCode != 0 {
throw GuestDropError.execFailed(exitCode: exitCode, stderr: stderr)
}
_ = stdout
return .revealed
}
}