feat: show drop destination in toast and unblock guest agent on dev builds

After a host→guest drop completes, the toast now updates from "Done" to
"Copied to <Folder>" (e.g. "Desktop", "Documents") once the guest agent
has finished relocating the file out of the share. When the agent isn't
reachable, the toast falls back to "Copied to Shared Files" so the user
always sees a sensible destination.

Three things had to come together:

1. Toast surfaces the destination

   `GuestDropSynthesis.perform` now returns a `GuestDropOutcome` carrying
   the basename of the destination folder (the in-guest script appends
   a `tartdrop-dest=<basename>` line after `mv`). The relocation runs as
   a fire-and-forget Task off the copy queue and patches the toast via a
   new `setFinalDestination` method — which uses a shared
   `pendingFinalText` slot so a fast relocation result doesn't get
   clobbered by the delayed "Done" placeholder.

2. gRPC timeout shortened so failures fit in the toast window

   The exec-call timeout drops from 8 s to 5 s. Steady-state calls
   finish in well under 500 ms; 5 s leaves headroom for a first-run
   osascript blocked on a Finder Automation TCC prompt inside the
   guest. The baseline hide on success grows to 2 s so the destination
   update has a chance to land before the panel disappears.

3. Dev builds of tart can now host the guest agent

   `CI.version` returns `"SNAPSHOT"` for non-tagged builds, which gave
   the VM a console port named `tart-version-SNAPSHOT`. The guest agent
   parses that suffix as a semver and falls back to `unix.Kill(getppid,
   SIGTERM)` when it can't — which fails with EPERM against launchd and
   prints "operation not permitted" every 10 s forever. A new
   `CI.deviceVersion` always emits a valid semver with major ≥ 2
   (`"99.0.0"` for SNAPSHOT) and VM.swift uses it for the port name.
   `99.0.0` without a `-prerelease` suffix is required because macOS's
   BSD tty layer rejects the dotted+hyphenated form and refuses to
   expose the device under `/dev/cu.*`.
This commit is contained in:
Dal Rupnik 2026-05-18 12:23:05 +02:00
parent 2db423c404
commit 0cc0969652
5 changed files with 106 additions and 27 deletions

View File

@ -5,6 +5,15 @@ struct CI {
rawVersion.expanded() ? rawVersion : "SNAPSHOT"
}
/// Same as `version`, but always a valid semver with major 2 so the guest
/// agent's `/dev/cu.tart-version-<semver>` Tart-detection probe accepts it.
/// For non-tagged dev builds we'd otherwise emit `SNAPSHOT`, which the agent
/// can't parse it then thinks it isn't running on Tart and bails out with
/// "operation not permitted" when it tries to SIGTERM its launchd parent.
static var deviceVersion: String {
rawVersion.expanded() ? rawVersion : "99.0.0"
}
static var release: String? {
rawVersion.expanded() ? "tart@\(rawVersion)" : nil
}

View File

@ -1176,21 +1176,24 @@ class VMContainerView: NSView {
}
}
// Show the final state immediately so the toast doesn't sit there
// waiting on the guest agent. The relocation runs concurrently and
// patches the destination text into the toast if it returns while
// the panel is still visible otherwise the user just sees "Done".
DispatchQueue.main.async {
DropProgressToast.shared.finish(success: true)
}
// After the file lands in the shared drop zone, ask the guest agent
// 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(
let folder = await synthesizeGuestDrop(
hostFilePath: destPath,
atNormalized: normalizedPoint,
controlSocketURL: controlSocketURL
)
await MainActor.run {
DropProgressToast.shared.setFinalDestination(folder)
}
}
} catch is DropCopyCancelled {
// User clicked : remove the partial destination so the share
@ -1242,13 +1245,23 @@ class VMContainerView: NSView {
/// 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.
/// Box that lets a sync copyQueue thread receive a value written by an async
/// Task. The semaphore round-trip provides happens-before ordering.
final class DropFolderBox: @unchecked Sendable {
var value: String = "Shared Files"
}
/// Asks the in-guest agent to relocate the dropped file, returning the basename
/// of the destination folder so the toast can show "Copied to Desktop" etc.
/// On agent failure the file remains in the share folder and we return
/// `"Shared Files"` so the user still sees a sensible destination.
private func synthesizeGuestDrop(
hostFilePath: String,
atNormalized normalized: CGPoint,
controlSocketURL: URL?
) async -> Bool {
) async -> String {
_ = normalized
guard let controlSocketURL = controlSocketURL else { return false }
guard let controlSocketURL = controlSocketURL else { return "Shared Files" }
let filename = (hostFilePath as NSString).lastPathComponent
let guestPath = "/Volumes/My Shared Files/Dropped Files/" + filename
@ -1258,10 +1271,9 @@ private func synthesizeGuestDrop(
controlSocketURL: controlSocketURL,
guestFilePath: guestPath
)
_ = outcome
return true
return outcome.destinationFolderName
} catch {
return false
return "Shared Files"
}
}

View File

@ -54,6 +54,12 @@ final class DropProgressToast {
private var cancelButton: NSButton!
private var hideWorkItem: DispatchWorkItem?
private weak var anchorWindow: NSWindow?
/// Text that the delayed-final-text work item should apply when it fires.
/// finish() sets this to "Done", setFinalDestination() upgrades it to
/// "Copied to <folder>" whichever value is current at +0.35 s wins, so a
/// fast relocation result doesn't get clobbered by the placeholder.
private var pendingFinalText: String = ""
private var didApplyFinalText: Bool = false
/// Cancellation token for the copy currently driving the toast. Cleared
/// once the user clicks or `finish` is called, so a late click after the
@ -124,10 +130,33 @@ final class DropProgressToast {
detailLabel.stringValue = formatDetail(copied: copied, total: total, index: index, count: count)
}
/// Called once the guest-agent relocation completes (or fails) with the
/// basename of the folder the file is in. Upgrades the pending final text
/// to "Copied to <folder>" so the delayed apply uses it; if the apply
/// already fired (relocation was slow), patches the label directly. No-op
/// if the panel already hid. Pushes the hide schedule out a bit so the
/// new text gets time to be read.
func setFinalDestination(_ folderName: String) {
guard let panel = panel, panel.isVisible, !folderName.isEmpty else { return }
pendingFinalText = "Copied to \(folderName)"
if didApplyFinalText {
detailLabel.stringValue = pendingFinalText
}
hideWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.hide()
}
hideWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 1.1, execute: work)
_ = panel
}
/// Flash a final state and schedule the panel to hide. Pass `cancelled:
/// true` when the copy ended because the user clicked ; that surfaces
/// "Cancelled" instead of "Copy failed".
func finish(success: Bool, cancelled: Bool = false) {
/// "Cancelled" instead of "Copy failed". `destinationFolder` (only honored
/// on success) is the basename of the folder the file ended up in shown
/// as "Copied to <folder>" so the user knows where their file is.
func finish(success: Bool, destinationFolder: String? = nil, cancelled: Bool = false) {
guard let panel = panel else { return }
cancelButton.isEnabled = false
currentToken = nil
@ -138,13 +167,24 @@ final class DropProgressToast {
progressBar.isIndeterminate = false
progressBar.doubleValue = progressBar.maxValue
// NSProgressIndicator has an undocumented ~0.3 s smooth-fill animation
// when doubleValue jumps. Delay "Done" until the bar visibly catches up
// so the text doesn't lead the fill. Extend the hide so "Done" still
// gets ~0.7 s of visibility once it appears.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
self?.detailLabel.stringValue = "Done"
// when doubleValue jumps. Delay the final text until the bar visibly
// catches up so the text doesn't lead the fill. setFinalDestination
// may upgrade `pendingFinalText` to "Copied to <folder>" in that
// window; the work item picks up whatever value is current at +0.35 s.
if let folder = destinationFolder, !folder.isEmpty {
pendingFinalText = "Copied to \(folder)"
} else {
pendingFinalText = "Done"
}
hideDelay = 1.05
didApplyFinalText = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
guard let self = self else { return }
self.detailLabel.stringValue = self.pendingFinalText
self.didApplyFinalText = true
}
// 2 s baseline lets the guest-agent relocation (2 s RPC timeout) report
// back and `setFinalDestination` upgrade the label before we hide.
hideDelay = 2.0
} else if cancelled {
detailLabel.stringValue = "Cancelled"
hideDelay = 0.8

View File

@ -4,11 +4,11 @@ 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
struct GuestDropOutcome {
/// Basename of the folder the file was moved into, e.g. "Desktop" or
/// "Documents". The host shows this in the toast so the user knows where
/// the dropped file ended up.
let destinationFolderName: String
}
/// Errors that the host treats as "agent path didn't work; fall back to
@ -96,6 +96,9 @@ enum GuestDropSynthesis {
mv -- "$src" "$final"
/usr/bin/open -R "$final"
# Last line of stdout is the destination folder's basename so the host
# can show "Copied to Desktop" / "Copied to Documents" in the toast.
printf 'tartdrop-dest=%s\n' "$(basename "$dest_dir")"
"""#
static func perform(
@ -127,7 +130,11 @@ enum GuestDropSynthesis {
}
defer { try? channel.close().wait() }
let callOptions = CallOptions(timeLimit: .timeout(.seconds(8)))
// 5 s: enough headroom for a first-run osascript that's blocked on a
// user TCC Automation prompt inside the guest, while still failing fast
// when the agent isn't running so the toast can show "Copied to Shared
// Files" before hiding. Steady-state calls finish in well under 500 ms.
let callOptions = CallOptions(timeLimit: .timeout(.seconds(5)))
let client = AgentAsyncClient(channel: channel, defaultCallOptions: callOptions)
let execCall = client.makeExecCall()
@ -179,7 +186,18 @@ enum GuestDropSynthesis {
throw GuestDropError.execFailed(exitCode: exitCode, stderr: stderr)
}
_ = stdout
return .revealed
// Parse the `tartdrop-dest=<basename>` line the script prints after `mv`.
// Tolerate other stdout (a future agent build might add a banner) by
// scanning lines instead of demanding an exact match.
var folderName: String?
for line in stdout.split(whereSeparator: { $0 == "\n" || $0 == "\r" }) {
if line.hasPrefix("tartdrop-dest=") {
folderName = String(line.dropFirst("tartdrop-dest=".count))
}
}
guard let dest = folderName, !dest.isEmpty else {
throw GuestDropError.unexpectedOutput(stdout)
}
return GuestDropOutcome(destinationFolderName: dest)
}
}

View File

@ -429,7 +429,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// A dummy console device useful for implementing
// host feature checks in the guest agent software.
let consolePort = VZVirtioConsolePortConfiguration()
consolePort.name = "tart-version-\(CI.version)"
consolePort.name = "tart-version-\(CI.deviceVersion)"
let consoleDevice = VZVirtioConsoleDeviceConfiguration()
consoleDevice.ports[0] = consolePort