From 0cc0969652f0cb75840d671bd55524330d4b51bf Mon Sep 17 00:00:00 2001 From: Dal Rupnik Date: Mon, 18 May 2026 12:23:05 +0200 Subject: [PATCH] feat: show drop destination in toast and unblock guest agent on dev builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a host→guest drop completes, the toast now updates from "Done" to "Copied to " (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=` 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.*`. --- Sources/tart/CI/CI.swift | 9 +++++ Sources/tart/Commands/Run.swift | 32 ++++++++++----- Sources/tart/DropProgressToast.swift | 56 +++++++++++++++++++++++---- Sources/tart/GuestDropSynthesis.swift | 34 ++++++++++++---- Sources/tart/VM.swift | 2 +- 5 files changed, 106 insertions(+), 27 deletions(-) diff --git a/Sources/tart/CI/CI.swift b/Sources/tart/CI/CI.swift index f0cf9f5..34b1753 100644 --- a/Sources/tart/CI/CI.swift +++ b/Sources/tart/CI/CI.swift @@ -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-` 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 } diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 29f576a..fb6d6b5 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -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" } } diff --git a/Sources/tart/DropProgressToast.swift b/Sources/tart/DropProgressToast.swift index a5b42e0..adc727b 100644 --- a/Sources/tart/DropProgressToast.swift +++ b/Sources/tart/DropProgressToast.swift @@ -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 " — 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 " 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 " 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 " 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 diff --git a/Sources/tart/GuestDropSynthesis.swift b/Sources/tart/GuestDropSynthesis.swift index cc57321..da99021 100644 --- a/Sources/tart/GuestDropSynthesis.swift +++ b/Sources/tart/GuestDropSynthesis.swift @@ -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=` 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) } } diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift index fc8fc7a..b1d4475 100644 --- a/Sources/tart/VM.swift +++ b/Sources/tart/VM.swift @@ -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