|
| 1 | +# Native drag (macOS) |
| 2 | + |
| 3 | +macOS-only native drag-and-drop OUT of Cmdr. Builds the `NSDraggingSession` that carries dragged |
| 4 | +files to other apps (Finder, terminals, editors). Driven by the `start_selection_drag` / |
| 5 | +`start_drag_paths` commands in `commands/file_system/drag.rs`, which hop to the AppKit main thread |
| 6 | +and call `start_drag`. |
| 7 | + |
| 8 | +The whole module is `#[cfg(target_os = "macos")]`. |
| 9 | + |
| 10 | +## Files |
| 11 | + |
| 12 | +| File | Role | |
| 13 | +|------|------| |
| 14 | +| `mod.rs` | `start_drag`: builds the `NSDraggingItem`s + drag image, attaches per-item pasteboard writers, begins the session. Local sessions get plain `NSPasteboardItem`s; virtual sessions get `NSFilePromiseProvider`s. | |
| 15 | +| `type_plan.rs` | Pure, locality-aware pasteboard composition (`plan_pasteboard_items`). Local = file-url + text + filenames; virtual = empty (the textClipping fix). Unit-tested policy. | |
| 16 | +| `source.rs` | `CmdrDragSource` (`define_class!`, `MainThreadOnly`): the `NSDraggingSource`. Returns the permissive operation mask and, on `draggingSession:endedAtPoint:operation:`, tells the promise machinery the gesture ended so a virtual session's objects can be freed. | |
| 17 | +| `promises.rs` | The file-promise providers + delegate (`CmdrPromiseDelegate`), the shared serial queue, the session-lifetime storage, and the `NSError` mapping. | |
| 18 | +| `fulfillment.rs` | The plain-Rust fulfillment service: downloads a virtual file to the Finder-chosen destination. NO AppKit; unit-testable. | |
| 19 | +| `uti.rs` | Pure filename-extension → UTI mapping for promise providers (`public.jpeg`, …, fallback `public.data`; folders `public.folder`). | |
| 20 | + |
| 21 | +## How drag-out works |
| 22 | + |
| 23 | +1. The FE starts a drag; the command resolves the session's **locality** (`locality_for_volume`, |
| 24 | + keyed on `Volume::supports_local_fs_access()`) and the source volume id, and calls `start_drag`. |
| 25 | +2. `start_drag` (on the main thread) builds one `NSDraggingItem` per file: |
| 26 | + - **Local session**: writer is an `NSPasteboardItem` filled from the pure type plan (file-url + |
| 27 | + shell-escaped text + legacy filenames). Source carries `NO_PROMISE_SESSION`. |
| 28 | + - **Virtual session** (MTP, direct SMB, search-results): writer is an `NSFilePromiseProvider` |
| 29 | + per item, carrying NO legacy types. The providers register their delegates under a fresh |
| 30 | + `session_key`; the source carries that key. |
| 31 | +3. Dropping on **Finder** invokes the promise delegate, which streams the real bytes off the device |
| 32 | + into the Finder-chosen destination via the fulfillment service. Dropping back **into Cmdr** |
| 33 | + still fires wry's drop event (empty paths, no panic) and the recorded-identity self-drag path |
| 34 | + handles it. Dropping on a **terminal** from a virtual pane is a clean no-op (no text to insert). |
| 35 | + |
| 36 | +## File promises (the drag-out-to-Finder feature) |
| 37 | + |
| 38 | +When Finder accepts a promise drop, it calls the delegate per item: |
| 39 | + |
| 40 | +- `filePromiseProvider:fileNameForType:` (MAIN thread) — returns the leaf name we already know, |
| 41 | + zero I/O. |
| 42 | +- `filePromiseProvider:writePromiseToURL:completionHandler:` (operation-queue thread) — `block_on`s |
| 43 | + the async `fulfillment::fulfill` and calls the completion block with `null` (success) or a mapped |
| 44 | + `NSError` (failure). |
| 45 | +- `operationQueueForFilePromiseProvider:` — returns ONE shared serial queue per drag session. |
| 46 | + |
| 47 | +### Fulfillment service (`fulfillment.rs`) |
| 48 | + |
| 49 | +`fulfill(source_volume_id, source_path, dest_path)`: resolve the volume → busy-register the source |
| 50 | +(eject guard) → `note_pending_write_for_cmdr(dest)` (suppress the downloads toast) → stream to the |
| 51 | +EXACT Finder leaf. A **file** goes `open_read_stream` → `write_from_stream(dest, …)`; a **folder** |
| 52 | +is a hand-rolled recursive walk (`create_dir` → list → mkdir → per-file stream), because the |
| 53 | +cross-volume copy engine derives landed names from source basenames and can't target a |
| 54 | +Finder-renamed root. |
| 55 | + |
| 56 | +**Cleanup contract (load-bearing)**: on ANY `Err`, the destination this fulfillment created is |
| 57 | +removed before returning. `LocalPosixVolume::write_from_stream` self-cleans its partial ONLY on the |
| 58 | +cancel branch, NOT on a propagated source-read error (device unplugged mid-stream) — exactly the |
| 59 | +promise failure mode. So the service removes the partial file (single file) or the whole created |
| 60 | +tree (`remove_dir_all`, safe because the dest is a fresh Finder-created directory) itself. Pinned by |
| 61 | +`read_failure_midstream_leaves_no_file_at_dest…` and `folder_error_midstream_removes_the_created_tree`. |
| 62 | + |
| 63 | +**Main-thread invariant**: the service never performs synchronous main-thread work from the queue |
| 64 | +thread (volume I/O + a cheap downloads-watcher mutex, no `run_on_main_thread`), so `block_on`-ing it |
| 65 | +on the queue thread can't deadlock against a busy main thread. |
| 66 | + |
| 67 | +### Delegate-lifetime model (the M0-spike gotcha) |
| 68 | + |
| 69 | +**`NSFilePromiseProvider.delegate` is WEAK** — the provider doesn't retain its delegate. A delegate |
| 70 | +that's a drag-start local would drop when `start_drag` returns, zeroing the provider's weak ref, and |
| 71 | +Finder would query a nil delegate and silently produce no file. So each session's delegates + |
| 72 | +providers live in process-global storage in `promises.rs`, freed only when BOTH the gesture has |
| 73 | +ended AND every in-flight fulfillment has completed. |
| 74 | + |
| 75 | +Two stores, because `Retained<…>` AppKit objects aren't `Send` but the in-flight counter is touched |
| 76 | +from the queue thread: |
| 77 | + |
| 78 | +- **`COUNTERS`** (`Send`, any-thread `Mutex<HashMap>`): `{ in_flight, gesture_ended }`. Decides |
| 79 | + *when* cleanup fires. |
| 80 | +- **The retained store** (`thread_local!`, main-thread-confined): the `Retained` delegates + |
| 81 | + providers. Registered on main at drag-start; freed via a main-thread dispatch (`run_on_main`) when |
| 82 | + the counters say "ended and drained." The shared queue rides in the delegate's ivar as a |
| 83 | + `SendQueue` (NSOperationQueue is documented thread-safe), so returning it from the queue thread |
| 84 | + needs no main-thread hop. |
| 85 | + |
| 86 | +**Ordering defended**: AppKit ends the session at the DROP, but Finder pumps the fulfillment queue |
| 87 | +AFTER. Freeing on session-end alone would yank a delegate mid-write. Gating on "ended AND |
| 88 | +in_flight == 0" keeps everything alive across both. A fulfillment finishing after session-end frees |
| 89 | +the session itself (its `leave_fulfillment` sees the drained, ended state). Pinned by |
| 90 | +`session_counters_wait_for_in_flight_to_drain`. |
| 91 | + |
| 92 | +### Busy-volume seam |
| 93 | + |
| 94 | +The fulfillment service marks the source volume busy for the eject guard via the `pub(crate)` |
| 95 | +`write_operations::{register_external_volume_op, release_external_volume_op}` seam (an RAII |
| 96 | +`BusyGuard` releases on every exit). This is the smallest honest seam: a drag-out download isn't a |
| 97 | +real write op (no `WRITE_OPERATION_STATE`, no progress events), but it must guard the device the |
| 98 | +same way, so it touches only the `OPERATION_STATUS_CACHE` half that `recompute_and_emit_busy_volumes` |
| 99 | +reads. Pinned by `source_volume_is_busy_during_fulfillment_and_released_after`. |
| 100 | + |
| 101 | +### App-quit / device-disconnect abort |
| 102 | + |
| 103 | +There's no user-initiated cancel of an in-flight fulfillment in v1 (Finder owns the gesture, no |
| 104 | +progress UI). In-flight fulfillments end via stream `Drop` semantics: app quit drops the tokio |
| 105 | +runtime and the source `VolumeReadStream` (MTP's `Drop` cancels the USB transfer, SMB's signals its |
| 106 | +producer); a device disconnect surfaces as a `next_chunk` read error. Either way the cleanup contract |
| 107 | +removes the partial. No explicit teardown hook is needed beyond the existing runtime shutdown. |
| 108 | + |
| 109 | +## NSError mapping |
| 110 | + |
| 111 | +A `FulfillError` carries a rendered `FriendlyError`. The delegate maps it to an `NSError` in domain |
| 112 | +`com.veszelovszki.cmdr.drag-out` with the friendly title as `localizedDescription` (Finder shows its |
| 113 | +own alert). A cancelled fulfillment uses the `NSUserCancelledError` code (3072) so Finder stays |
| 114 | +quiet; a real failure uses code 1 and shows the title. |
| 115 | + |
| 116 | +## Testing |
| 117 | + |
| 118 | +- `fulfillment.rs`: headless against `InMemoryVolume` + tempdir. Happy path asserts the landed |
| 119 | + filename EQUALS the Finder-chosen leaf (the regression guard against the rejected copy-engine |
| 120 | + mismatch). Plus read-failure cleanup, unwritable dest, missing source, recursive folder, mid-folder |
| 121 | + cleanup, and the busy-volume seam (drive a blocking stream, assert busy during + released after). |
| 122 | +- `uti.rs`: extension → UTI mapping units. |
| 123 | +- `promises.rs`: delegate smoke (construct a provider, `fileNameForType` returns the leaf), NSError |
| 124 | + domain/title/code mapping, and the COUNTERS session-lifetime state machine. The AppKit-touching |
| 125 | + tests guard on `MainThreadMarker::new()` and skip off-main (nextest runs tests on worker threads). |
| 126 | +- `type_plan.rs`: the pure pasteboard policy (local byte-identical, virtual empty across every item). |
| 127 | + |
| 128 | +The Finder leg itself can't be automated honestly (Finder owns the drop gesture); the manual |
| 129 | +protocol in `docs/specs/drag-out-file-promises-plan.md` § M4 covers it with the virtual-MTP rig. |
| 130 | + |
| 131 | +## Gotchas |
| 132 | + |
| 133 | +**Gotcha**: The promise delegate is NOT `MainThreadOnly`. |
| 134 | +**Why**: `writePromiseToURL:completionHandler:` runs on the operation-queue thread, so the delegate |
| 135 | +object must be usable off-main. The one main-thread-only method (`fileNameForType:`) gets its |
| 136 | +`MainThreadMarker` from the protocol signature, so the class-level marker isn't needed. The ivars are |
| 137 | +all `Send + Sync` (the queue via the `SendQueue` wrapper). The drag SOURCE (`source.rs`) IS |
| 138 | +`MainThreadOnly` because `NSDraggingSource` requires it. |
| 139 | + |
| 140 | +**Gotcha**: `session_key` is a monotonic counter, NOT the drag sequence number. |
| 141 | +**Why**: The promise delegates must register BEFORE the drag begins (their weak refs must be alive |
| 142 | +the instant Finder might query them), but `NSDraggingSession.draggingSequenceNumber` is only known |
| 143 | +AFTER `beginDraggingSessionWithItems:…` returns. A monotonic key generated up front and stashed on |
| 144 | +the source object sidesteps the chicken-and-egg, and the source reads its own key back in the end |
| 145 | +callback — no session→key mapping needed. |
0 commit comments