mirror of https://github.com/cirruslabs/tart.git
fix: harden drag-and-drop against the edge-case report
Introduces DropHandler to own the drop pipeline and addresses every item from the review: - Folders / .app bundles / packages: DropProgressCopier.copyTree walks directories instead of failing with a generic 'Failed to copy'. - File promises (Photos, Mail, browser image drags) are now accepted and materialized instead of silently no-opping. - Multi-file toast race: per-file DropSession id; stale relocation results for a superseded file are ignored by update/finish/ setFinalDestination. - Partial files: copyTree removes its partial output on any error, and the handler drops the now-empty subdir, so the guest never sees a truncated file. - Teardown race: in-flight guest relocations register with RelocationGate; 'tart run' drains it (<=6s) before deleting the drop zone / exiting. - Path collisions: each file copies into its own dropRoot/<uuid>/ subdir. - Reserved name: a user --dir named 'Dropped Files' is now rejected. - Linux / no agent: relocation is skipped and the toast says 'Copied to the shared folder' instead of a misleading Finder destination. - Agent-down timing: success toast holds on a fallback timer that outlives the 5s RPC deadline so the final destination is always shown. - Concurrency: relocations run one-at-a-time; copy failures are coalesced into a single alert instead of a modal storm. - Zero-byte/unknown size shows just the copied amount, not '0 bytes of ?'. - Removed dead DropFolderBox + stale doc comment; fixed the http://-vs-https:// typo in toRemoteOrLocalURL. Tests: DropProgressCopierTests (replace/cancel/error-cleanup/empty/ directory/size), GuestDropParseTests (stdout parser), and a reserved-name case in DirectoryShareTests. Product and test target both compile.
This commit is contained in:
parent
3bae4c62cf
commit
100fc1e8bc
|
|
@ -570,6 +570,9 @@ struct Run: AsyncParsableCommand {
|
||||||
try vncImpl.stop()
|
try vncImpl.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Let any in-flight guest relocation finish moving its file out of
|
||||||
|
// the share before we delete the drop zone underneath it.
|
||||||
|
await RelocationGate.shared.drain(timeout: 6)
|
||||||
cleanupDropZone()
|
cleanupDropZone()
|
||||||
OTel.shared.flush()
|
OTel.shared.flush()
|
||||||
Foundation.exit(0)
|
Foundation.exit(0)
|
||||||
|
|
@ -579,6 +582,7 @@ struct Run: AsyncParsableCommand {
|
||||||
|
|
||||||
fputs("\(error)\n", stderr)
|
fputs("\(error)\n", stderr)
|
||||||
|
|
||||||
|
await RelocationGate.shared.drain(timeout: 6)
|
||||||
cleanupDropZone()
|
cleanupDropZone()
|
||||||
OTel.shared.flush()
|
OTel.shared.flush()
|
||||||
Foundation.exit(1)
|
Foundation.exit(1)
|
||||||
|
|
@ -1063,7 +1067,7 @@ class TartVirtualMachineView: VZVirtualMachineView {
|
||||||
/// drag-destination table.
|
/// drag-destination table.
|
||||||
class VMContainerView: NSView {
|
class VMContainerView: NSView {
|
||||||
let machineView: TartVirtualMachineView
|
let machineView: TartVirtualMachineView
|
||||||
private let copyQueue = DispatchQueue(label: "org.cirruslabs.tart.dragdrop-copy", qos: .userInitiated)
|
private var dropHandler: DropHandler?
|
||||||
|
|
||||||
init(machineView: TartVirtualMachineView) {
|
init(machineView: TartVirtualMachineView) {
|
||||||
self.machineView = machineView
|
self.machineView = machineView
|
||||||
|
|
@ -1082,14 +1086,26 @@ class VMContainerView: NSView {
|
||||||
|
|
||||||
override func viewDidMoveToWindow() {
|
override func viewDidMoveToWindow() {
|
||||||
super.viewDidMoveToWindow()
|
super.viewDidMoveToWindow()
|
||||||
guard window != nil, machineView.dropZoneURL != nil else { return }
|
guard window != nil, let dropZoneURL = machineView.dropZoneURL else { return }
|
||||||
registerForDraggedTypes([.fileURL])
|
if dropHandler == nil {
|
||||||
|
dropHandler = DropHandler(
|
||||||
|
dropRoot: dropZoneURL,
|
||||||
|
controlSocketURL: MainApp.controlSocketURL,
|
||||||
|
isMacGuest: vm?.config.os == .darwin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Accept both real file URLs and file promises (Photos, Mail attachments,
|
||||||
|
// browser image drags, …) so those drops don't silently no-op.
|
||||||
|
let promiseTypes = NSFilePromiseReceiver.readableDraggedTypes.map { NSPasteboard.PasteboardType($0) }
|
||||||
|
registerForDraggedTypes([.fileURL] + promiseTypes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: NSDraggingDestination
|
// MARK: NSDraggingDestination
|
||||||
|
|
||||||
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||||
guard machineView.dropZoneURL != nil, !fileURLs(from: sender).isEmpty else { return [] }
|
guard machineView.dropZoneURL != nil,
|
||||||
|
!fileURLs(from: sender).isEmpty || !promiseReceivers(from: sender).isEmpty
|
||||||
|
else { return [] }
|
||||||
machineView.highlight.isActive = true
|
machineView.highlight.isActive = true
|
||||||
return .copy
|
return .copy
|
||||||
}
|
}
|
||||||
|
|
@ -1107,14 +1123,15 @@ class VMContainerView: NSView {
|
||||||
override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { true }
|
override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { true }
|
||||||
|
|
||||||
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
||||||
guard let dropZoneURL = machineView.dropZoneURL else { return false }
|
guard let dropHandler = dropHandler else { return false }
|
||||||
let urls = fileURLs(from: sender)
|
let urls = fileURLs(from: sender)
|
||||||
guard !urls.isEmpty else { return false }
|
let promises = promiseReceivers(from: sender)
|
||||||
|
guard !urls.isEmpty || !promises.isEmpty else { return false }
|
||||||
|
|
||||||
// Compute the drop location in normalized view coordinates while we're
|
// Compute the drop location in normalized view coordinates while we're
|
||||||
// still on the main thread (dragging info is only valid here). The
|
// still on the main thread (dragging info is only valid here). The
|
||||||
// forthcoming guest-side drop synthesis uses this to place files where
|
// guest-side drop synthesis uses this to place files where the user
|
||||||
// the user actually pointed instead of in a generic share folder.
|
// actually pointed instead of in a generic share folder.
|
||||||
let localPoint = self.convert(sender.draggingLocation, from: nil)
|
let localPoint = self.convert(sender.draggingLocation, from: nil)
|
||||||
let normalizedPoint = DropGeometry.normalize(
|
let normalizedPoint = DropGeometry.normalize(
|
||||||
point: localPoint,
|
point: localPoint,
|
||||||
|
|
@ -1122,104 +1139,12 @@ class VMContainerView: NSView {
|
||||||
isViewFlipped: self.isFlipped
|
isViewFlipped: self.isFlipped
|
||||||
)
|
)
|
||||||
|
|
||||||
// Snapshot the control socket URL and parent window on the main actor so
|
dropHandler.handle(
|
||||||
// the background Task / dispatch block can use them without crossing
|
fileURLs: urls,
|
||||||
// actor boundaries.
|
promiseReceivers: promises,
|
||||||
let controlSocketURL = MainApp.controlSocketURL
|
normalizedPoint: normalizedPoint,
|
||||||
let parentWindow = self.window
|
parentWindow: self.window
|
||||||
|
)
|
||||||
// One cancellation token covers the whole drop gesture: clicking ⊗ on
|
|
||||||
// the toast aborts the current file AND skips any remaining files in a
|
|
||||||
// multi-file drop.
|
|
||||||
let cancelToken = DropCancellationToken()
|
|
||||||
|
|
||||||
// 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).
|
|
||||||
// The toast (HUD-style progress panel) anchors to `parentWindow` so the
|
|
||||||
// user sees a per-file progress bar — and a cancel button — instead of
|
|
||||||
// a frozen UI.
|
|
||||||
let totalFiles = urls.count
|
|
||||||
copyQueue.async {
|
|
||||||
for (idx, url) in urls.enumerated() {
|
|
||||||
// Honor cancellation before starting the next file in a multi-drop.
|
|
||||||
if cancelToken.isCancelled { break }
|
|
||||||
|
|
||||||
let dest = dropZoneURL.appendingPathComponent(url.lastPathComponent)
|
|
||||||
let totalBytes = ((try? FileManager.default.attributesOfItem(atPath: url.path)[.size]) as? Int64) ?? 0
|
|
||||||
let fileIndex = idx + 1
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
DropProgressToast.shared.begin(
|
|
||||||
parent: parentWindow,
|
|
||||||
filename: url.lastPathComponent,
|
|
||||||
totalBytes: totalBytes,
|
|
||||||
index: fileIndex,
|
|
||||||
count: totalFiles,
|
|
||||||
cancelToken: cancelToken
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
try DropProgressCopier.copy(
|
|
||||||
from: url,
|
|
||||||
to: dest,
|
|
||||||
totalBytes: totalBytes,
|
|
||||||
token: cancelToken
|
|
||||||
) { copied in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
DropProgressToast.shared.update(
|
|
||||||
copied: copied,
|
|
||||||
total: totalBytes,
|
|
||||||
index: fileIndex,
|
|
||||||
count: totalFiles
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
let destPath = dest.path
|
|
||||||
Task {
|
|
||||||
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
|
|
||||||
// folder doesn't accumulate half-copied junk, surface "Cancelled"
|
|
||||||
// on the toast, and skip the remaining files in this drop.
|
|
||||||
try? FileManager.default.removeItem(at: dest)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
DropProgressToast.shared.finish(success: false, cancelled: true)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
} catch {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
DropProgressToast.shared.finish(success: false)
|
|
||||||
}
|
|
||||||
let name = url.lastPathComponent
|
|
||||||
let message = error.localizedDescription
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
let alert = NSAlert()
|
|
||||||
alert.messageText = "Failed to copy \"\(name)\" to the VM"
|
|
||||||
alert.informativeText = message
|
|
||||||
alert.alertStyle = .warning
|
|
||||||
alert.runModal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1229,52 +1154,12 @@ class VMContainerView: NSView {
|
||||||
options: [.urlReadingFileURLsOnly: true]
|
options: [.urlReadingFileURLsOnly: true]
|
||||||
) as? [URL] ?? []
|
) as? [URL] ?? []
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Asks the in-guest tart-guest-agent to place `hostFilePath` somewhere visible
|
private func promiseReceivers(from info: NSDraggingInfo) -> [NSFilePromiseReceiver] {
|
||||||
/// — into the frontmost Finder window if there is one, otherwise revealed in
|
info.draggingPasteboard.readObjects(
|
||||||
/// Finder. The host path is rewritten to the guest's mount of the drop share
|
forClasses: [NSFilePromiseReceiver.self],
|
||||||
/// (`/Volumes/My Shared Files/Dropped Files/<filename>`) before being passed
|
options: nil
|
||||||
/// across the wire.
|
) as? [NSFilePromiseReceiver] ?? []
|
||||||
///
|
|
||||||
/// `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.
|
|
||||||
///
|
|
||||||
/// 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.
|
|
||||||
/// 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 -> String {
|
|
||||||
guard let controlSocketURL = controlSocketURL else { return "Shared Files" }
|
|
||||||
|
|
||||||
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,
|
|
||||||
normalizedDropPoint: normalized
|
|
||||||
)
|
|
||||||
return outcome.destinationFolderName
|
|
||||||
} catch {
|
|
||||||
NSLog("[GuestDrop] relocate failed for \(guestPath): \(error)")
|
|
||||||
return "Shared Files"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1434,6 +1319,13 @@ struct DirectoryShare {
|
||||||
result.append(try DirectoryShare(parseFrom: rawDir))
|
result.append(try DirectoryShare(parseFrom: rawDir))
|
||||||
}
|
}
|
||||||
if let dropZoneURL = dropZoneURL {
|
if let dropZoneURL = dropZoneURL {
|
||||||
|
// "Dropped Files" is reserved for the drag-and-drop share. A user --dir
|
||||||
|
// with that exact name would silently clobber (or be clobbered by) it
|
||||||
|
// in the per-mount-tag dictionary, so reject it with a clear message
|
||||||
|
// instead — mirroring the unnamed-share conflict error.
|
||||||
|
if result.contains(where: { $0.name == "Dropped Files" }) {
|
||||||
|
throw ValidationError("the directory share name \"Dropped Files\" is reserved for drag-and-drop. Rename your --dir share or pass --no-drag-and-drop.")
|
||||||
|
}
|
||||||
result.append(DirectoryShare(
|
result.append(DirectoryShare(
|
||||||
name: "Dropped Files",
|
name: "Dropped Files",
|
||||||
path: dropZoneURL,
|
path: dropZoneURL,
|
||||||
|
|
@ -1570,7 +1462,7 @@ struct DirectoryShare {
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
func toRemoteOrLocalURL() -> URL {
|
func toRemoteOrLocalURL() -> URL {
|
||||||
if (starts(with: "https://") || starts(with: "https://")) {
|
if (starts(with: "http://") || starts(with: "https://")) {
|
||||||
URL(string: self)!
|
URL(string: self)!
|
||||||
} else {
|
} else {
|
||||||
URL(fileURLWithPath: NSString(string: self).expandingTildeInPath)
|
URL(fileURLWithPath: NSString(string: self).expandingTildeInPath)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Monotonic per-file id. Stamped onto every `DropProgressToast` call so a
|
||||||
|
/// slow guest-relocation result for an earlier file can't clobber/hide the
|
||||||
|
/// toast while a later file in the same drop is still copying.
|
||||||
|
enum DropSession {
|
||||||
|
private static let lock = NSLock()
|
||||||
|
private static var counter = 0
|
||||||
|
|
||||||
|
static func next() -> Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
counter += 1
|
||||||
|
return counter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks in-flight guest relocations process-wide so `tart run` can wait for
|
||||||
|
/// them before deleting the drop zone / calling `Foundation.exit`. Without
|
||||||
|
/// this, closing the VM window right after a drop races the guest `mv`
|
||||||
|
/// against drop-zone teardown and the file is lost.
|
||||||
|
final class RelocationGate {
|
||||||
|
static let shared = RelocationGate()
|
||||||
|
private let group = DispatchGroup()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func enter() { group.enter() }
|
||||||
|
func leave() { group.leave() }
|
||||||
|
|
||||||
|
/// Wait up to `timeout` seconds for outstanding relocations. Bridged off
|
||||||
|
/// the caller's actor so it never blocks the main thread.
|
||||||
|
func drain(timeout: TimeInterval) async {
|
||||||
|
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
||||||
|
DispatchQueue.global().async {
|
||||||
|
_ = self.group.wait(timeout: .now() + timeout)
|
||||||
|
cont.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns one VM window's drag-and-drop pipeline: copies dragged files (and
|
||||||
|
/// file promises, and folders/.app bundles) into a per-file subdirectory of
|
||||||
|
/// the shared drop zone, drives the progress toast, then asks the guest agent
|
||||||
|
/// to relocate each file under the cursor.
|
||||||
|
///
|
||||||
|
/// Design notes addressing prior edge cases:
|
||||||
|
/// - Each file gets its own `dropRoot/<uuid>/` subdir, so same-named files in
|
||||||
|
/// one gesture (or rapid re-drops) never collide on the share path.
|
||||||
|
/// - `DropProgressCopier.copyTree` handles directories and removes partial
|
||||||
|
/// output on any failure, so a half-written item is never visible to guest.
|
||||||
|
/// - Relocations run one-at-a-time on a serial queue and register with
|
||||||
|
/// `RelocationGate`, bounding guest RPC/TCC pressure and making teardown
|
||||||
|
/// safe.
|
||||||
|
/// - Per-file `sessionID` keeps multi-file toasts from racing.
|
||||||
|
/// - On non-macOS guests (or when the control socket is unavailable) the
|
||||||
|
/// relocation step is skipped and the toast says so honestly.
|
||||||
|
final class DropHandler {
|
||||||
|
private let dropRoot: URL
|
||||||
|
private let controlSocketURL: URL?
|
||||||
|
private let isMacGuest: Bool
|
||||||
|
private let copyQueue = DispatchQueue(label: "org.cirruslabs.tart.dragdrop-copy", qos: .userInitiated)
|
||||||
|
private let relocationQueue = DispatchQueue(label: "org.cirruslabs.tart.dragdrop-relocate")
|
||||||
|
|
||||||
|
private var relocationPossible: Bool { isMacGuest && controlSocketURL != nil }
|
||||||
|
|
||||||
|
init(dropRoot: URL, controlSocketURL: URL?, isMacGuest: Bool) {
|
||||||
|
self.dropRoot = dropRoot
|
||||||
|
self.controlSocketURL = controlSocketURL
|
||||||
|
self.isMacGuest = isMacGuest
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry point, called on the main thread from `performDragOperation`.
|
||||||
|
/// `parentWindow` is only ever touched on the main thread again.
|
||||||
|
func handle(
|
||||||
|
fileURLs: [URL],
|
||||||
|
promiseReceivers: [NSFilePromiseReceiver],
|
||||||
|
normalizedPoint: CGPoint,
|
||||||
|
parentWindow: NSWindow?
|
||||||
|
) {
|
||||||
|
let cancelToken = DropCancellationToken()
|
||||||
|
let box = WindowBox(parentWindow)
|
||||||
|
|
||||||
|
copyQueue.async { [self] in
|
||||||
|
var sources = fileURLs
|
||||||
|
sources.append(contentsOf: resolvePromisedFiles(promiseReceivers, token: cancelToken))
|
||||||
|
guard !sources.isEmpty else { return }
|
||||||
|
|
||||||
|
var failures: [String] = []
|
||||||
|
let total = sources.count
|
||||||
|
|
||||||
|
for (idx, src) in sources.enumerated() {
|
||||||
|
if cancelToken.isCancelled { break }
|
||||||
|
|
||||||
|
let sessionID = DropSession.next()
|
||||||
|
let name = src.lastPathComponent
|
||||||
|
let subdir = dropRoot.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
let dest = subdir.appendingPathComponent(name)
|
||||||
|
let totalBytes = DropProgressCopier.totalSize(of: src)
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
DropProgressToast.shared.begin(
|
||||||
|
parent: box.window, filename: name, totalBytes: totalBytes,
|
||||||
|
index: idx + 1, count: total, cancelToken: cancelToken, sessionID: sessionID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: subdir, withIntermediateDirectories: true)
|
||||||
|
try DropProgressCopier.copyTree(
|
||||||
|
from: src, to: dest, totalBytes: totalBytes, token: cancelToken
|
||||||
|
) { copied in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
DropProgressToast.shared.update(
|
||||||
|
copied: copied, total: totalBytes,
|
||||||
|
index: idx + 1, count: total, sessionID: sessionID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let waiting = relocationPossible
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
DropProgressToast.shared.finish(
|
||||||
|
success: true,
|
||||||
|
destinationFolder: waiting ? nil : "the shared folder",
|
||||||
|
awaitingRelocation: waiting,
|
||||||
|
sessionID: sessionID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
relocate(subdir: subdir, fileName: name, normalizedPoint: normalizedPoint, sessionID: sessionID)
|
||||||
|
} catch is DropCopyCancelled {
|
||||||
|
try? FileManager.default.removeItem(at: subdir)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
DropProgressToast.shared.finish(success: false, cancelled: true, sessionID: sessionID)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
} catch {
|
||||||
|
// copyTree already removed the partial output; drop the now-empty
|
||||||
|
// subdir too and remember the failure for one combined alert.
|
||||||
|
try? FileManager.default.removeItem(at: subdir)
|
||||||
|
failures.append("\(name): \(error.localizedDescription)")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
DropProgressToast.shared.finish(success: false, sessionID: sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !failures.isEmpty {
|
||||||
|
let summary = failures
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = summary.count == 1
|
||||||
|
? "Failed to copy a file to the VM"
|
||||||
|
: "Failed to copy \(summary.count) files to the VM"
|
||||||
|
alert.informativeText = summary.joined(separator: "\n")
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
alert.runModal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Relocation (serialized, one at a time)
|
||||||
|
|
||||||
|
private func relocate(subdir: URL, fileName: String, normalizedPoint: CGPoint, sessionID: Int) {
|
||||||
|
guard relocationPossible, let socket = controlSocketURL else {
|
||||||
|
// Linux / no agent: the file stays in the shared folder. The toast
|
||||||
|
// already said "Copied to the shared folder"; nothing more to do.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
RelocationGate.shared.enter()
|
||||||
|
relocationQueue.async {
|
||||||
|
defer { RelocationGate.shared.leave() }
|
||||||
|
|
||||||
|
let guestPath = "/Volumes/My Shared Files/Dropped Files/"
|
||||||
|
+ subdir.lastPathComponent + "/" + fileName
|
||||||
|
let sem = DispatchSemaphore(value: 0)
|
||||||
|
var folder = "Shared Files"
|
||||||
|
var moved = false
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let outcome = try await GuestDropSynthesis.perform(
|
||||||
|
controlSocketURL: socket,
|
||||||
|
guestFilePath: guestPath,
|
||||||
|
normalizedDropPoint: normalizedPoint
|
||||||
|
)
|
||||||
|
folder = outcome.destinationFolderName
|
||||||
|
moved = true
|
||||||
|
} catch {
|
||||||
|
NSLog("[GuestDrop] relocate failed for \(guestPath): \(error)")
|
||||||
|
}
|
||||||
|
sem.signal()
|
||||||
|
}
|
||||||
|
sem.wait()
|
||||||
|
|
||||||
|
// Successful relocation `mv`s the file out of the share (a cross-FS
|
||||||
|
// move that unlinks the host-side source), leaving an empty subdir to
|
||||||
|
// reap. On failure the file stays put for the user to find.
|
||||||
|
if moved {
|
||||||
|
try? FileManager.default.removeItem(at: subdir)
|
||||||
|
}
|
||||||
|
let resolved = folder
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
DropProgressToast.shared.setFinalDestination(resolved, sessionID: sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - File promises (drags from Photos, Mail, browsers, …)
|
||||||
|
|
||||||
|
/// Materializes `NSFilePromiseReceiver`s into a host-private staging dir and
|
||||||
|
/// returns the written file URLs so they flow through the same copy path as
|
||||||
|
/// plain file drags. Best-effort: anything that errors or times out is
|
||||||
|
/// skipped (the drop just yields fewer files, never a crash).
|
||||||
|
private func resolvePromisedFiles(
|
||||||
|
_ receivers: [NSFilePromiseReceiver],
|
||||||
|
token: DropCancellationToken
|
||||||
|
) -> [URL] {
|
||||||
|
guard !receivers.isEmpty else { return [] }
|
||||||
|
|
||||||
|
let staging = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("tart-drop-promise-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
guard (try? FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)) != nil else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let opQueue = OperationQueue()
|
||||||
|
let lock = NSLock()
|
||||||
|
var urls: [URL] = []
|
||||||
|
let sem = DispatchSemaphore(value: 0)
|
||||||
|
let expected = receivers.reduce(0) { $0 + max(1, $1.fileNames.count) }
|
||||||
|
|
||||||
|
for receiver in receivers {
|
||||||
|
receiver.receivePromisedFiles(atDestination: staging, options: [:], operationQueue: opQueue) { url, error in
|
||||||
|
if error == nil {
|
||||||
|
lock.lock()
|
||||||
|
urls.append(url)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
sem.signal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 30 s headroom for first-run providers (e.g. Photos exporting originals)
|
||||||
|
// while still failing fast if a provider never calls back.
|
||||||
|
for _ in 0..<expected {
|
||||||
|
if token.isCancelled { break }
|
||||||
|
if sem.wait(timeout: .now() + 30) == .timedOut { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carries an `NSWindow` (main-thread-only) through the background copy
|
||||||
|
/// closure. It is only ever read back on the main thread.
|
||||||
|
private final class WindowBox: @unchecked Sendable {
|
||||||
|
let window: NSWindow?
|
||||||
|
init(_ window: NSWindow?) { self.window = window }
|
||||||
|
}
|
||||||
|
|
@ -1,31 +1,141 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Chunked file copy with throttled progress callbacks and cancellation.
|
/// Chunked file/▸directory copy with throttled progress callbacks and
|
||||||
/// Used by the drag-and-drop handler to feed `DropProgressToast` without
|
/// cancellation. Used by the drag-and-drop handler to feed `DropProgressToast`
|
||||||
/// freezing the VM render view.
|
/// without freezing the VM render view.
|
||||||
///
|
///
|
||||||
/// - Removes any existing file at `dst` first (drops semantically replace).
|
/// - `copyTree` handles both regular files and directories (folders, `.app`
|
||||||
|
/// bundles, packages) — directories are walked depth-first and every
|
||||||
|
/// regular file inside is streamed through the same chunked path.
|
||||||
|
/// - Any pre-existing item at `dst` is removed first (drops semantically
|
||||||
|
/// replace).
|
||||||
/// - Polls `token.isCancelled` between chunks; throws `DropCopyCancelled`
|
/// - Polls `token.isCancelled` between chunks; throws `DropCopyCancelled`
|
||||||
/// immediately on cancel so the caller can clean up the partial file.
|
/// immediately on cancel.
|
||||||
/// - Reports `progress(copied)` at most once every ~50 ms during the copy,
|
/// - `progress(copiedSoFar)` is reported at most once every ~50 ms (cumulative
|
||||||
/// plus a final call at completion so the bar always reaches 100%.
|
/// across the whole tree), plus a guaranteed final call so the bar always
|
||||||
/// - Throws on either side's I/O error; partial output at `dst` is left in
|
/// reaches 100%.
|
||||||
/// place so the caller can decide how to surface the error (delete +
|
/// - On any throw (I/O error or cancellation) the partial output at `dst` is
|
||||||
/// alert, or leave it for the user).
|
/// removed so a truncated file never becomes visible to the guest.
|
||||||
enum DropProgressCopier {
|
enum DropProgressCopier {
|
||||||
static func copy(
|
private static let walkKeys: [URLResourceKey] = [.isDirectoryKey, .isRegularFileKey]
|
||||||
|
private static let walkKeySet: Swift.Set<URLResourceKey> = [.isDirectoryKey, .isRegularFileKey]
|
||||||
|
|
||||||
|
/// Total byte size of `url`: the file size for a regular file, or the
|
||||||
|
/// recursive sum of regular-file sizes for a directory. Best-effort —
|
||||||
|
/// unreadable entries contribute 0 (the bar just runs a touch fast).
|
||||||
|
static func totalSize(of url: URL) -> Int64 {
|
||||||
|
let fm = FileManager.default
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
guard fm.fileExists(atPath: url.path, isDirectory: &isDir) else { return 0 }
|
||||||
|
|
||||||
|
if !isDir.boolValue {
|
||||||
|
return ((try? fm.attributesOfItem(atPath: url.path)[.size]) as? Int64) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var total: Int64 = 0
|
||||||
|
if let en = fm.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey]) {
|
||||||
|
for case let child as URL in en {
|
||||||
|
let v = try? child.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
|
||||||
|
if v?.isRegularFile == true { total += Int64(v?.fileSize ?? 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy `src` (file or directory) to `dst`. On any error the partial `dst`
|
||||||
|
/// is cleaned up before rethrowing, so callers never leave a half-written
|
||||||
|
/// item in the drop zone.
|
||||||
|
static func copyTree(
|
||||||
from src: URL,
|
from src: URL,
|
||||||
to dst: URL,
|
to dst: URL,
|
||||||
totalBytes: Int64,
|
totalBytes: Int64,
|
||||||
token: DropCancellationToken,
|
token: DropCancellationToken,
|
||||||
progress: (Int64) -> Void
|
progress: (Int64) -> Void
|
||||||
) throws {
|
) throws {
|
||||||
_ = totalBytes // accepted for future use (ETA, average rate, etc.)
|
let fm = FileManager.default
|
||||||
|
if fm.fileExists(atPath: dst.path) {
|
||||||
if FileManager.default.fileExists(atPath: dst.path) {
|
try fm.removeItem(at: dst)
|
||||||
try FileManager.default.removeItem(at: dst)
|
|
||||||
}
|
}
|
||||||
guard FileManager.default.createFile(atPath: dst.path, contents: nil) else {
|
|
||||||
|
var copied: Int64 = 0
|
||||||
|
var lastReport = Date(timeIntervalSince1970: 0)
|
||||||
|
let reportInterval: TimeInterval = 0.05
|
||||||
|
|
||||||
|
func report(force: Bool) {
|
||||||
|
let now = Date()
|
||||||
|
if force || now.timeIntervalSince(lastReport) >= reportInterval {
|
||||||
|
progress(copied)
|
||||||
|
lastReport = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
_ = fm.fileExists(atPath: src.path, isDirectory: &isDir)
|
||||||
|
|
||||||
|
if isDir.boolValue {
|
||||||
|
try fm.createDirectory(at: dst, withIntermediateDirectories: true)
|
||||||
|
// Deterministic depth-first walk so the destination tree mirrors the
|
||||||
|
// source and directories are created before their contents.
|
||||||
|
let children = (try? fm.contentsOfDirectory(
|
||||||
|
at: src, includingPropertiesForKeys: walkKeys, options: []
|
||||||
|
)) ?? []
|
||||||
|
for child in children.sorted(by: { $0.path < $1.path }) {
|
||||||
|
if token.isCancelled { throw DropCopyCancelled() }
|
||||||
|
let childDst = dst.appendingPathComponent(child.lastPathComponent)
|
||||||
|
try copyInto(child, childDst, &copied, token, report)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try copyFile(src, dst, &copied, token, report)
|
||||||
|
}
|
||||||
|
|
||||||
|
report(force: true)
|
||||||
|
} catch {
|
||||||
|
try? fm.removeItem(at: dst)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static func copyInto(
|
||||||
|
_ src: URL,
|
||||||
|
_ dst: URL,
|
||||||
|
_ copied: inout Int64,
|
||||||
|
_ token: DropCancellationToken,
|
||||||
|
_ report: (Bool) -> Void
|
||||||
|
) throws {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let values = try? src.resourceValues(forKeys: walkKeySet)
|
||||||
|
if values?.isDirectory == true {
|
||||||
|
try fm.createDirectory(at: dst, withIntermediateDirectories: true)
|
||||||
|
let children = (try? fm.contentsOfDirectory(
|
||||||
|
at: src, includingPropertiesForKeys: walkKeys, options: []
|
||||||
|
)) ?? []
|
||||||
|
for child in children.sorted(by: { $0.path < $1.path }) {
|
||||||
|
if token.isCancelled { throw DropCopyCancelled() }
|
||||||
|
try copyInto(child, dst.appendingPathComponent(child.lastPathComponent), &copied, token, report)
|
||||||
|
}
|
||||||
|
} else if values?.isRegularFile == true {
|
||||||
|
try copyFile(src, dst, &copied, token, report)
|
||||||
|
} else {
|
||||||
|
// Symlink / socket / device node: recreate symlinks, skip the rest
|
||||||
|
// rather than block on a fifo or copy a device.
|
||||||
|
if let dest = try? fm.destinationOfSymbolicLink(atPath: src.path) {
|
||||||
|
try? fm.createSymbolicLink(atPath: dst.path, withDestinationPath: dest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func copyFile(
|
||||||
|
_ src: URL,
|
||||||
|
_ dst: URL,
|
||||||
|
_ copied: inout Int64,
|
||||||
|
_ token: DropCancellationToken,
|
||||||
|
_ report: (Bool) -> Void
|
||||||
|
) throws {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard fm.createFile(atPath: dst.path, contents: nil) else {
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: NSPOSIXErrorDomain,
|
domain: NSPOSIXErrorDomain,
|
||||||
code: Int(EIO),
|
code: Int(EIO),
|
||||||
|
|
@ -42,26 +152,13 @@ enum DropProgressCopier {
|
||||||
|
|
||||||
let chunkSize = 1 * 1024 * 1024 // 1 MiB — amortizes syscalls, still
|
let chunkSize = 1 * 1024 * 1024 // 1 MiB — amortizes syscalls, still
|
||||||
// streams progress and bounds cancellation latency on fast disks.
|
// streams progress and bounds cancellation latency on fast disks.
|
||||||
let reportInterval: TimeInterval = 0.05
|
|
||||||
var lastReport = Date(timeIntervalSince1970: 0)
|
|
||||||
var copied: Int64 = 0
|
|
||||||
|
|
||||||
while true {
|
while true {
|
||||||
if token.isCancelled { throw DropCopyCancelled() }
|
if token.isCancelled { throw DropCopyCancelled() }
|
||||||
|
|
||||||
let chunk = input.readData(ofLength: chunkSize)
|
let chunk = input.readData(ofLength: chunkSize)
|
||||||
if chunk.isEmpty { break }
|
if chunk.isEmpty { break }
|
||||||
try output.write(contentsOf: chunk)
|
try output.write(contentsOf: chunk)
|
||||||
copied += Int64(chunk.count)
|
copied += Int64(chunk.count)
|
||||||
|
report(false)
|
||||||
let now = Date()
|
|
||||||
if now.timeIntervalSince(lastReport) >= reportInterval {
|
|
||||||
progress(copied)
|
|
||||||
lastReport = now
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Always fire a final callback so the UI reaches 100% even when the
|
|
||||||
// file finished inside the throttle window.
|
|
||||||
progress(copied)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -147,8 +147,10 @@ extension DropProgressToast {
|
||||||
let bcf = ByteCountFormatter()
|
let bcf = ByteCountFormatter()
|
||||||
bcf.countStyle = .file
|
bcf.countStyle = .file
|
||||||
let copiedStr = bcf.string(fromByteCount: max(0, copied))
|
let copiedStr = bcf.string(fromByteCount: max(0, copied))
|
||||||
let totalStr = total > 0 ? bcf.string(fromByteCount: total) : "?"
|
|
||||||
let prefix = count > 1 ? "[\(index)/\(count)] " : ""
|
let prefix = count > 1 ? "[\(index)/\(count)] " : ""
|
||||||
return "\(prefix)\(copiedStr) of \(totalStr)"
|
// Unknown total (stat failed / genuinely empty): show just what's copied
|
||||||
|
// rather than the awkward "0 bytes of ?".
|
||||||
|
guard total > 0 else { return "\(prefix)\(copiedStr)" }
|
||||||
|
return "\(prefix)\(copiedStr) of \(bcf.string(fromByteCount: total))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,11 @@ import Foundation
|
||||||
/// All methods MUST be called on the main thread. Callers driving copies
|
/// All methods MUST be called on the main thread. Callers driving copies
|
||||||
/// from a background queue should hop via `DispatchQueue.main.async` first.
|
/// from a background queue should hop via `DispatchQueue.main.async` first.
|
||||||
///
|
///
|
||||||
/// Lifecycle per file:
|
/// Every file gets a monotonically increasing `sessionID` (see
|
||||||
/// begin(...) -> panel appears (or retargets) and slides
|
/// `DropSession.next()`). `update`/`finish`/`setFinalDestination` ignore any
|
||||||
/// in if it wasn't already visible
|
/// call whose `sessionID` is not the one the most recent `begin` installed,
|
||||||
/// update(...) -> bar advances; no-op if not visible
|
/// so a slow guest-relocation result for file 1 can't clobber or hide the
|
||||||
/// finish(success:cancelled:) -> brief "Done"/"Cancelled"/"Copy failed"
|
/// toast while file 2 of the same drop is still copying.
|
||||||
/// state, then auto-hide after ~0.8 s
|
|
||||||
///
|
|
||||||
/// Multiple files dropped in one gesture serially reuse the same panel and
|
|
||||||
/// increment the [i/N] counter without re-animating.
|
|
||||||
///
|
///
|
||||||
/// AppKit panel construction, positioning, and detail-string formatting live
|
/// AppKit panel construction, positioning, and detail-string formatting live
|
||||||
/// in `DropProgressToast+Panel.swift`.
|
/// in `DropProgressToast+Panel.swift`.
|
||||||
|
|
@ -39,6 +35,10 @@ final class DropProgressToast {
|
||||||
private var pendingFinalText: String = ""
|
private var pendingFinalText: String = ""
|
||||||
private var didApplyFinalText: Bool = false
|
private var didApplyFinalText: Bool = false
|
||||||
|
|
||||||
|
/// Identifies the file currently driving the toast. Stale callbacks (from a
|
||||||
|
/// previous file's async relocation) carry an older id and are ignored.
|
||||||
|
private var currentSessionID: Int = -1
|
||||||
|
|
||||||
/// Cancellation token for the copy currently driving the toast. Cleared
|
/// Cancellation token for the copy currently driving the toast. Cleared
|
||||||
/// once the user clicks ⊗ or `finish` is called, so a late click after the
|
/// once the user clicks ⊗ or `finish` is called, so a late click after the
|
||||||
/// copy already completed does nothing.
|
/// copy already completed does nothing.
|
||||||
|
|
@ -56,7 +56,8 @@ final class DropProgressToast {
|
||||||
totalBytes: Int64,
|
totalBytes: Int64,
|
||||||
index: Int,
|
index: Int,
|
||||||
count: Int,
|
count: Int,
|
||||||
cancelToken: DropCancellationToken
|
cancelToken: DropCancellationToken,
|
||||||
|
sessionID: Int
|
||||||
) {
|
) {
|
||||||
ensurePanel()
|
ensurePanel()
|
||||||
hideWorkItem?.cancel()
|
hideWorkItem?.cancel()
|
||||||
|
|
@ -66,6 +67,7 @@ final class DropProgressToast {
|
||||||
|
|
||||||
anchorWindow = parent
|
anchorWindow = parent
|
||||||
currentToken = cancelToken
|
currentToken = cancelToken
|
||||||
|
currentSessionID = sessionID
|
||||||
|
|
||||||
titleLabel.stringValue = filename
|
titleLabel.stringValue = filename
|
||||||
detailLabel.stringValue = formatDetail(copied: 0, total: totalBytes, index: index, count: count)
|
detailLabel.stringValue = formatDetail(copied: 0, total: totalBytes, index: index, count: count)
|
||||||
|
|
@ -98,8 +100,9 @@ final class DropProgressToast {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the progress bar and detail line. No-op if the panel isn't
|
/// Update the progress bar and detail line. No-op if the panel isn't
|
||||||
/// visible (i.e. `begin` was never called or `finish` already hid it).
|
/// visible or the call belongs to a superseded file.
|
||||||
func update(copied: Int64, total: Int64, index: Int, count: Int) {
|
func update(copied: Int64, total: Int64, index: Int, count: Int, sessionID: Int) {
|
||||||
|
guard sessionID == currentSessionID else { return }
|
||||||
guard let panel = panel, panel.isVisible else { return }
|
guard let panel = panel, panel.isVisible else { return }
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
progressBar.isIndeterminate = false
|
progressBar.isIndeterminate = false
|
||||||
|
|
@ -112,9 +115,10 @@ final class DropProgressToast {
|
||||||
/// basename of the folder the file is in. Upgrades the pending final text
|
/// 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
|
/// to "Copied to <folder>" so the delayed apply uses it; if the apply
|
||||||
/// already fired (relocation was slow), patches the label directly. No-op
|
/// 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
|
/// if the panel already hid or this is a stale (superseded) callback.
|
||||||
/// new text gets time to be read.
|
/// Schedules the (short) hide now that the real destination is known.
|
||||||
func setFinalDestination(_ folderName: String) {
|
func setFinalDestination(_ folderName: String, sessionID: Int) {
|
||||||
|
guard sessionID == currentSessionID else { return }
|
||||||
guard let panel = panel, panel.isVisible, !folderName.isEmpty else { return }
|
guard let panel = panel, panel.isVisible, !folderName.isEmpty else { return }
|
||||||
pendingFinalText = "Copied to \(folderName)"
|
pendingFinalText = "Copied to \(folderName)"
|
||||||
if didApplyFinalText {
|
if didApplyFinalText {
|
||||||
|
|
@ -130,11 +134,23 @@ final class DropProgressToast {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flash a final state and schedule the panel to hide. Pass `cancelled:
|
/// Flash a final state and schedule the panel to hide. Pass `cancelled:
|
||||||
/// true` when the copy ended because the user clicked ⊗; that surfaces
|
/// true` when the copy ended because the user clicked ⊗. `destinationFolder`
|
||||||
/// "Cancelled" instead of "Copy failed". `destinationFolder` (only honored
|
/// (only honored on success) is shown as "Copied to <folder>".
|
||||||
/// 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.
|
/// When `awaitingRelocation` is true the file copied locally but the guest
|
||||||
func finish(success: Bool, destinationFolder: String? = nil, cancelled: Bool = false) {
|
/// agent is still being asked where it should land; we keep the toast up on
|
||||||
|
/// a long fallback timer (longer than the RPC's 5 s deadline) and let
|
||||||
|
/// `setFinalDestination` drive the real, short hide. That way the user
|
||||||
|
/// always sees the final destination instead of the toast vanishing at 2 s
|
||||||
|
/// while a slow/again-prompting agent is still working.
|
||||||
|
func finish(
|
||||||
|
success: Bool,
|
||||||
|
destinationFolder: String? = nil,
|
||||||
|
cancelled: Bool = false,
|
||||||
|
awaitingRelocation: Bool = false,
|
||||||
|
sessionID: Int
|
||||||
|
) {
|
||||||
|
guard sessionID == currentSessionID else { return }
|
||||||
guard let panel = panel else { return }
|
guard let panel = panel else { return }
|
||||||
cancelButton.isEnabled = false
|
cancelButton.isEnabled = false
|
||||||
currentToken = nil
|
currentToken = nil
|
||||||
|
|
@ -160,9 +176,10 @@ final class DropProgressToast {
|
||||||
self.detailLabel.stringValue = self.pendingFinalText
|
self.detailLabel.stringValue = self.pendingFinalText
|
||||||
self.didApplyFinalText = true
|
self.didApplyFinalText = true
|
||||||
}
|
}
|
||||||
// 2 s baseline lets the guest-agent relocation (2 s RPC timeout) report
|
// Awaiting the guest agent: hold on a fallback timer that outlives the
|
||||||
// back and `setFinalDestination` upgrade the label before we hide.
|
// 5 s RPC deadline; setFinalDestination cancels it and hides shortly
|
||||||
hideDelay = 2.0
|
// after the real destination is known. Otherwise hide at the usual 2 s.
|
||||||
|
hideDelay = awaitingRelocation ? 6.5 : 2.0
|
||||||
} else if cancelled {
|
} else if cancelled {
|
||||||
detailLabel.stringValue = "Cancelled"
|
detailLabel.stringValue = "Cancelled"
|
||||||
hideDelay = 0.8
|
hideDelay = 0.8
|
||||||
|
|
|
||||||
|
|
@ -227,18 +227,25 @@ enum GuestDropSynthesis {
|
||||||
throw GuestDropError.execFailed(exitCode: exitCode, stderr: stderr)
|
throw GuestDropError.execFailed(exitCode: exitCode, stderr: stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the `tartdrop-dest=<basename>` line the script prints after `mv`.
|
guard let dest = Self.parseDestinationFolder(stdout: stdout) else {
|
||||||
// Tolerate other stdout (a future agent build might add a banner) by
|
throw GuestDropError.unexpectedOutput(stdout)
|
||||||
// scanning lines instead of demanding an exact match.
|
}
|
||||||
|
return GuestDropOutcome(destinationFolderName: dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pulls the `tartdrop-dest=<basename>` line the script prints after `mv`
|
||||||
|
/// out of the agent's stdout. Tolerates other stdout (a future agent build
|
||||||
|
/// might add a banner) by scanning lines instead of demanding an exact
|
||||||
|
/// match, and takes the *last* such line so a trailing real value wins.
|
||||||
|
/// Returns nil when no non-empty value is present. Pure — unit-tested.
|
||||||
|
static func parseDestinationFolder(stdout: String) -> String? {
|
||||||
var folderName: String?
|
var folderName: String?
|
||||||
for line in stdout.split(whereSeparator: { $0 == "\n" || $0 == "\r" }) {
|
for line in stdout.split(whereSeparator: { $0 == "\n" || $0 == "\r" }) {
|
||||||
if line.hasPrefix("tartdrop-dest=") {
|
if line.hasPrefix("tartdrop-dest=") {
|
||||||
folderName = String(line.dropFirst("tartdrop-dest=".count))
|
folderName = String(line.dropFirst("tartdrop-dest=".count))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard let dest = folderName, !dest.isEmpty else {
|
guard let dest = folderName, !dest.isEmpty else { return nil }
|
||||||
throw GuestDropError.unexpectedOutput(stdout)
|
return dest
|
||||||
}
|
|
||||||
return GuestDropOutcome(destinationFolderName: dest)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -144,4 +144,24 @@ final class DirectoryShareTests: XCTestCase {
|
||||||
XCTAssertTrue(archiveWithNameAndOptions.readOnly)
|
XCTAssertTrue(archiveWithNameAndOptions.readOnly)
|
||||||
XCTAssertEqual(archiveWithNameAndOptions.mountTag, "sometag")
|
XCTAssertEqual(archiveWithNameAndOptions.mountTag, "sometag")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Dropped Files" is reserved for the drag-and-drop share: a user --dir
|
||||||
|
// with that exact name would silently clobber it, so collect() rejects it
|
||||||
|
// when the drop zone is active.
|
||||||
|
func testReservedDroppedFilesNameRejectedWithDropZone() throws {
|
||||||
|
let url = URL(filePath: "/tmp/dropzone-test")
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try DirectoryShare.collect(
|
||||||
|
dirArgs: ["Dropped Files:/Users/admin/stuff"],
|
||||||
|
dropZoneURL: url
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without drag-and-drop active the name carries no special meaning.
|
||||||
|
func testDroppedFilesNameAllowedWithoutDropZone() throws {
|
||||||
|
let shares = try DirectoryShare.collect(dirArgs: ["Dropped Files:/Users/admin/stuff"])
|
||||||
|
XCTAssertEqual(shares.count, 1)
|
||||||
|
XCTAssertEqual(shares[0].name, "Dropped Files")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class DropProgressCopierTests: XCTestCase {
|
||||||
|
private var tmp: URL!
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
tmp = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("droptest-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
|
addTeardownBlock { [tmp] in
|
||||||
|
try? FileManager.default.removeItem(at: tmp!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func write(_ name: String, _ bytes: Int) throws -> URL {
|
||||||
|
let url = tmp.appendingPathComponent(name)
|
||||||
|
try Data(repeating: 0x41, count: bytes).write(to: url)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCopiesRegularFileAndReportsFinalProgress() throws {
|
||||||
|
let src = try write("src.bin", 3 * 1024 * 1024 + 7)
|
||||||
|
let dst = tmp.appendingPathComponent("out.bin")
|
||||||
|
var last: Int64 = -1
|
||||||
|
try DropProgressCopier.copyTree(
|
||||||
|
from: src, to: dst, totalBytes: 0, token: DropCancellationToken()
|
||||||
|
) { last = $0 }
|
||||||
|
|
||||||
|
XCTAssertEqual(last, 3 * 1024 * 1024 + 7, "final callback must report the full size")
|
||||||
|
XCTAssertEqual(
|
||||||
|
try Data(contentsOf: dst).count, 3 * 1024 * 1024 + 7
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReplacesExistingDestination() throws {
|
||||||
|
let src = try write("src.bin", 128)
|
||||||
|
let dst = tmp.appendingPathComponent("out.bin")
|
||||||
|
try Data(repeating: 0x42, count: 999).write(to: dst) // stale, larger
|
||||||
|
|
||||||
|
try DropProgressCopier.copyTree(
|
||||||
|
from: src, to: dst, totalBytes: 0, token: DropCancellationToken()
|
||||||
|
) { _ in }
|
||||||
|
|
||||||
|
XCTAssertEqual(try Data(contentsOf: dst), Data(repeating: 0x41, count: 128))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptyFileStillFiresFinalProgress() throws {
|
||||||
|
let src = try write("empty.bin", 0)
|
||||||
|
let dst = tmp.appendingPathComponent("out.bin")
|
||||||
|
var calls = 0
|
||||||
|
var last: Int64 = -1
|
||||||
|
try DropProgressCopier.copyTree(
|
||||||
|
from: src, to: dst, totalBytes: 0, token: DropCancellationToken()
|
||||||
|
) { calls += 1; last = $0 }
|
||||||
|
|
||||||
|
XCTAssertGreaterThanOrEqual(calls, 1)
|
||||||
|
XCTAssertEqual(last, 0)
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: dst.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCancellationThrowsAndRemovesPartial() throws {
|
||||||
|
let src = try write("src.bin", 8 * 1024 * 1024)
|
||||||
|
let dst = tmp.appendingPathComponent("out.bin")
|
||||||
|
let token = DropCancellationToken()
|
||||||
|
token.cancel()
|
||||||
|
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try DropProgressCopier.copyTree(
|
||||||
|
from: src, to: dst, totalBytes: 0, token: token
|
||||||
|
) { _ in }
|
||||||
|
) { error in
|
||||||
|
XCTAssertTrue(error is DropCopyCancelled)
|
||||||
|
}
|
||||||
|
XCTAssertFalse(
|
||||||
|
FileManager.default.fileExists(atPath: dst.path),
|
||||||
|
"a cancelled copy must not leave a partial file in the drop zone"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testErrorRemovesPartialDestination() throws {
|
||||||
|
let missing = tmp.appendingPathComponent("does-not-exist.bin")
|
||||||
|
let dst = tmp.appendingPathComponent("out.bin")
|
||||||
|
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try DropProgressCopier.copyTree(
|
||||||
|
from: missing, to: dst, totalBytes: 0, token: DropCancellationToken()
|
||||||
|
) { _ in }
|
||||||
|
)
|
||||||
|
XCTAssertFalse(
|
||||||
|
FileManager.default.fileExists(atPath: dst.path),
|
||||||
|
"a failed copy must not leave a half-written file visible to the guest"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCopiesDirectoryTreeRecursively() throws {
|
||||||
|
let srcDir = tmp.appendingPathComponent("bundle", isDirectory: true)
|
||||||
|
let sub = srcDir.appendingPathComponent("Contents", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: sub, withIntermediateDirectories: true)
|
||||||
|
try Data(repeating: 0x41, count: 10).write(to: srcDir.appendingPathComponent("a.txt"))
|
||||||
|
try Data(repeating: 0x41, count: 20).write(to: sub.appendingPathComponent("b.txt"))
|
||||||
|
|
||||||
|
XCTAssertEqual(DropProgressCopier.totalSize(of: srcDir), 30)
|
||||||
|
|
||||||
|
let dst = tmp.appendingPathComponent("bundle-copy", isDirectory: true)
|
||||||
|
var last: Int64 = -1
|
||||||
|
try DropProgressCopier.copyTree(
|
||||||
|
from: srcDir, to: dst, totalBytes: 30, token: DropCancellationToken()
|
||||||
|
) { last = $0 }
|
||||||
|
|
||||||
|
XCTAssertEqual(last, 30)
|
||||||
|
XCTAssertEqual(try Data(contentsOf: dst.appendingPathComponent("a.txt")).count, 10)
|
||||||
|
XCTAssertEqual(
|
||||||
|
try Data(contentsOf: dst.appendingPathComponent("Contents/b.txt")).count, 20
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTotalSizeOfRegularFile() throws {
|
||||||
|
let src = try write("sized.bin", 4242)
|
||||||
|
XCTAssertEqual(DropProgressCopier.totalSize(of: src), 4242)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class GuestDropParseTests: XCTestCase {
|
||||||
|
func testParsesSimpleLine() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
GuestDropSynthesis.parseDestinationFolder(stdout: "tartdrop-dest=Desktop\n"),
|
||||||
|
"Desktop"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIgnoresBannerLinesAndTakesTheValue() {
|
||||||
|
let out = "tart-guest-agent v1.2\nsome diagnostic noise\ntartdrop-dest=Documents\n"
|
||||||
|
XCTAssertEqual(GuestDropSynthesis.parseDestinationFolder(stdout: out), "Documents")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLastValueWins() {
|
||||||
|
let out = "tartdrop-dest=Old\ntartdrop-dest=New\n"
|
||||||
|
XCTAssertEqual(GuestDropSynthesis.parseDestinationFolder(stdout: out), "New")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHandlesCRLF() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
GuestDropSynthesis.parseDestinationFolder(stdout: "noise\r\ntartdrop-dest=Downloads\r\n"),
|
||||||
|
"Downloads"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNoLineReturnsNil() {
|
||||||
|
XCTAssertNil(GuestDropSynthesis.parseDestinationFolder(stdout: "just some output\n"))
|
||||||
|
XCTAssertNil(GuestDropSynthesis.parseDestinationFolder(stdout: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptyValueReturnsNil() {
|
||||||
|
XCTAssertNil(GuestDropSynthesis.parseDestinationFolder(stdout: "tartdrop-dest=\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFolderNameWithSpaces() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
GuestDropSynthesis.parseDestinationFolder(stdout: "tartdrop-dest=My Project\n"),
|
||||||
|
"My Project"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue