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()
|
||||
}
|
||||
|
||||
// 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()
|
||||
OTel.shared.flush()
|
||||
Foundation.exit(0)
|
||||
|
|
@ -579,6 +582,7 @@ struct Run: AsyncParsableCommand {
|
|||
|
||||
fputs("\(error)\n", stderr)
|
||||
|
||||
await RelocationGate.shared.drain(timeout: 6)
|
||||
cleanupDropZone()
|
||||
OTel.shared.flush()
|
||||
Foundation.exit(1)
|
||||
|
|
@ -1063,7 +1067,7 @@ class TartVirtualMachineView: VZVirtualMachineView {
|
|||
/// drag-destination table.
|
||||
class VMContainerView: NSView {
|
||||
let machineView: TartVirtualMachineView
|
||||
private let copyQueue = DispatchQueue(label: "org.cirruslabs.tart.dragdrop-copy", qos: .userInitiated)
|
||||
private var dropHandler: DropHandler?
|
||||
|
||||
init(machineView: TartVirtualMachineView) {
|
||||
self.machineView = machineView
|
||||
|
|
@ -1082,14 +1086,26 @@ class VMContainerView: NSView {
|
|||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
guard window != nil, machineView.dropZoneURL != nil else { return }
|
||||
registerForDraggedTypes([.fileURL])
|
||||
guard window != nil, let dropZoneURL = machineView.dropZoneURL else { return }
|
||||
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
|
||||
|
||||
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
|
||||
return .copy
|
||||
}
|
||||
|
|
@ -1107,14 +1123,15 @@ class VMContainerView: NSView {
|
|||
override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { true }
|
||||
|
||||
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)
|
||||
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
|
||||
// still on the main thread (dragging info is only valid here). The
|
||||
// forthcoming guest-side drop synthesis uses this to place files where
|
||||
// the user actually pointed instead of in a generic share folder.
|
||||
// guest-side drop synthesis uses this to place files where the user
|
||||
// actually pointed instead of in a generic share folder.
|
||||
let localPoint = self.convert(sender.draggingLocation, from: nil)
|
||||
let normalizedPoint = DropGeometry.normalize(
|
||||
point: localPoint,
|
||||
|
|
@ -1122,104 +1139,12 @@ class VMContainerView: NSView {
|
|||
isViewFlipped: self.isFlipped
|
||||
)
|
||||
|
||||
// Snapshot the control socket URL and parent window on the main actor so
|
||||
// the background Task / dispatch block can use them without crossing
|
||||
// actor boundaries.
|
||||
let controlSocketURL = MainApp.controlSocketURL
|
||||
let 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dropHandler.handle(
|
||||
fileURLs: urls,
|
||||
promiseReceivers: promises,
|
||||
normalizedPoint: normalizedPoint,
|
||||
parentWindow: self.window
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -1229,52 +1154,12 @@ class VMContainerView: NSView {
|
|||
options: [.urlReadingFileURLsOnly: true]
|
||||
) as? [URL] ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// `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"
|
||||
private func promiseReceivers(from info: NSDraggingInfo) -> [NSFilePromiseReceiver] {
|
||||
info.draggingPasteboard.readObjects(
|
||||
forClasses: [NSFilePromiseReceiver.self],
|
||||
options: nil
|
||||
) as? [NSFilePromiseReceiver] ?? []
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1434,6 +1319,13 @@ struct DirectoryShare {
|
|||
result.append(try DirectoryShare(parseFrom: rawDir))
|
||||
}
|
||||
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(
|
||||
name: "Dropped Files",
|
||||
path: dropZoneURL,
|
||||
|
|
@ -1570,7 +1462,7 @@ struct DirectoryShare {
|
|||
|
||||
extension String {
|
||||
func toRemoteOrLocalURL() -> URL {
|
||||
if (starts(with: "https://") || starts(with: "https://")) {
|
||||
if (starts(with: "http://") || starts(with: "https://")) {
|
||||
URL(string: self)!
|
||||
} else {
|
||||
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
|
||||
|
||||
/// Chunked file copy with throttled progress callbacks and cancellation.
|
||||
/// Used by the drag-and-drop handler to feed `DropProgressToast` without
|
||||
/// freezing the VM render view.
|
||||
/// Chunked file/▸directory copy with throttled progress callbacks and
|
||||
/// cancellation. Used by the drag-and-drop handler to feed `DropProgressToast`
|
||||
/// 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`
|
||||
/// immediately on cancel so the caller can clean up the partial file.
|
||||
/// - Reports `progress(copied)` at most once every ~50 ms during the copy,
|
||||
/// plus a final call at completion so the bar always reaches 100%.
|
||||
/// - Throws on either side's I/O error; partial output at `dst` is left in
|
||||
/// place so the caller can decide how to surface the error (delete +
|
||||
/// alert, or leave it for the user).
|
||||
/// immediately on cancel.
|
||||
/// - `progress(copiedSoFar)` is reported at most once every ~50 ms (cumulative
|
||||
/// across the whole tree), plus a guaranteed final call so the bar always
|
||||
/// reaches 100%.
|
||||
/// - On any throw (I/O error or cancellation) the partial output at `dst` is
|
||||
/// removed so a truncated file never becomes visible to the guest.
|
||||
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,
|
||||
to dst: URL,
|
||||
totalBytes: Int64,
|
||||
token: DropCancellationToken,
|
||||
progress: (Int64) -> Void
|
||||
) throws {
|
||||
_ = totalBytes // accepted for future use (ETA, average rate, etc.)
|
||||
|
||||
if FileManager.default.fileExists(atPath: dst.path) {
|
||||
try FileManager.default.removeItem(at: dst)
|
||||
let fm = FileManager.default
|
||||
if fm.fileExists(atPath: dst.path) {
|
||||
try fm.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(
|
||||
domain: NSPOSIXErrorDomain,
|
||||
code: Int(EIO),
|
||||
|
|
@ -42,26 +152,13 @@ enum DropProgressCopier {
|
|||
|
||||
let chunkSize = 1 * 1024 * 1024 // 1 MiB — amortizes syscalls, still
|
||||
// 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 {
|
||||
if token.isCancelled { throw DropCopyCancelled() }
|
||||
|
||||
let chunk = input.readData(ofLength: chunkSize)
|
||||
if chunk.isEmpty { break }
|
||||
try output.write(contentsOf: chunk)
|
||||
copied += Int64(chunk.count)
|
||||
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastReport) >= reportInterval {
|
||||
progress(copied)
|
||||
lastReport = now
|
||||
}
|
||||
report(false)
|
||||
}
|
||||
// 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()
|
||||
bcf.countStyle = .file
|
||||
let copiedStr = bcf.string(fromByteCount: max(0, copied))
|
||||
let totalStr = total > 0 ? bcf.string(fromByteCount: total) : "?"
|
||||
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
|
||||
/// from a background queue should hop via `DispatchQueue.main.async` first.
|
||||
///
|
||||
/// Lifecycle per file:
|
||||
/// begin(...) -> panel appears (or retargets) and slides
|
||||
/// in if it wasn't already visible
|
||||
/// update(...) -> bar advances; no-op if not visible
|
||||
/// finish(success:cancelled:) -> brief "Done"/"Cancelled"/"Copy failed"
|
||||
/// 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.
|
||||
/// Every file gets a monotonically increasing `sessionID` (see
|
||||
/// `DropSession.next()`). `update`/`finish`/`setFinalDestination` ignore any
|
||||
/// call whose `sessionID` is not the one the most recent `begin` installed,
|
||||
/// so a slow guest-relocation result for file 1 can't clobber or hide the
|
||||
/// toast while file 2 of the same drop is still copying.
|
||||
///
|
||||
/// AppKit panel construction, positioning, and detail-string formatting live
|
||||
/// in `DropProgressToast+Panel.swift`.
|
||||
|
|
@ -39,6 +35,10 @@ final class DropProgressToast {
|
|||
private var pendingFinalText: String = ""
|
||||
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
|
||||
/// once the user clicks ⊗ or `finish` is called, so a late click after the
|
||||
/// copy already completed does nothing.
|
||||
|
|
@ -56,7 +56,8 @@ final class DropProgressToast {
|
|||
totalBytes: Int64,
|
||||
index: Int,
|
||||
count: Int,
|
||||
cancelToken: DropCancellationToken
|
||||
cancelToken: DropCancellationToken,
|
||||
sessionID: Int
|
||||
) {
|
||||
ensurePanel()
|
||||
hideWorkItem?.cancel()
|
||||
|
|
@ -66,6 +67,7 @@ final class DropProgressToast {
|
|||
|
||||
anchorWindow = parent
|
||||
currentToken = cancelToken
|
||||
currentSessionID = sessionID
|
||||
|
||||
titleLabel.stringValue = filename
|
||||
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
|
||||
/// visible (i.e. `begin` was never called or `finish` already hid it).
|
||||
func update(copied: Int64, total: Int64, index: Int, count: Int) {
|
||||
/// visible or the call belongs to a superseded file.
|
||||
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 }
|
||||
if total > 0 {
|
||||
progressBar.isIndeterminate = false
|
||||
|
|
@ -112,9 +115,10 @@ final class DropProgressToast {
|
|||
/// 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) {
|
||||
/// if the panel already hid or this is a stale (superseded) callback.
|
||||
/// Schedules the (short) hide now that the real destination is known.
|
||||
func setFinalDestination(_ folderName: String, sessionID: Int) {
|
||||
guard sessionID == currentSessionID else { return }
|
||||
guard let panel = panel, panel.isVisible, !folderName.isEmpty else { return }
|
||||
pendingFinalText = "Copied to \(folderName)"
|
||||
if didApplyFinalText {
|
||||
|
|
@ -130,11 +134,23 @@ final class DropProgressToast {
|
|||
}
|
||||
|
||||
/// 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". `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) {
|
||||
/// true` when the copy ended because the user clicked ⊗. `destinationFolder`
|
||||
/// (only honored on success) is shown as "Copied to <folder>".
|
||||
///
|
||||
/// When `awaitingRelocation` is true the file copied locally but the guest
|
||||
/// 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 }
|
||||
cancelButton.isEnabled = false
|
||||
currentToken = nil
|
||||
|
|
@ -160,9 +176,10 @@ final class DropProgressToast {
|
|||
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
|
||||
// Awaiting the guest agent: hold on a fallback timer that outlives the
|
||||
// 5 s RPC deadline; setFinalDestination cancels it and hides shortly
|
||||
// after the real destination is known. Otherwise hide at the usual 2 s.
|
||||
hideDelay = awaitingRelocation ? 6.5 : 2.0
|
||||
} else if cancelled {
|
||||
detailLabel.stringValue = "Cancelled"
|
||||
hideDelay = 0.8
|
||||
|
|
|
|||
|
|
@ -227,18 +227,25 @@ enum GuestDropSynthesis {
|
|||
throw GuestDropError.execFailed(exitCode: exitCode, stderr: stderr)
|
||||
}
|
||||
|
||||
// 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.
|
||||
guard let dest = Self.parseDestinationFolder(stdout: stdout) else {
|
||||
throw GuestDropError.unexpectedOutput(stdout)
|
||||
}
|
||||
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?
|
||||
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)
|
||||
guard let dest = folderName, !dest.isEmpty else { return nil }
|
||||
return dest
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,4 +144,24 @@ final class DirectoryShareTests: XCTestCase {
|
|||
XCTAssertTrue(archiveWithNameAndOptions.readOnly)
|
||||
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