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