Skip to content

Commit c97a032

Browse files
committed
Drag out: download phone/network files to Finder (M2)
Implements file promises so dropping a file from an MTP or smb2-native pane onto Finder/the Desktop downloads it there, under the exact filename Finder chose. - `native_drag/fulfillment.rs`: plain-Rust fulfillment service (no AppKit, unit-testable). `fulfill(volume_id, source, dest)` resolves the source volume, busy-registers it (eject guard), notes the dest as a Cmdr-own write, then streams to the EXACT Finder leaf: a file via `open_read_stream` → `write_from_stream`, a folder via a hand-rolled recursive walk. On ANY `Err`, removes the partial file / created tree (the local writer doesn't self-clean on a source-read error — the disconnect-mid-stream failure mode). Carries a rendered `FriendlyError`. - `native_drag/promises.rs`: the `define_class!` `NSFilePromiseProviderDelegate`. `fileNameForType:` (main thread) returns the known leaf; `writePromiseToURL:completionHandler:` (queue thread) `block_on`s the service and calls the `block2` completion (null = ok, `NSError` in domain `com.veszelovszki.cmdr.drag-out` = fail). One shared serial `NSOperationQueue` per session. Session-scoped storage with a two-store lifetime model (Send counters decide *when* to free; the `!Send` retained delegates/providers live main-thread-confined and are freed via a main-thread dispatch only once the gesture ended AND in-flight fulfillments drained — defends the fulfillment-after-session-end ordering). - `native_drag/source.rs`: `CmdrDragSource` moved to `define_class!` with a session-key ivar; its `draggingSession:endedAtPoint:operation:` marks the gesture ended so the session frees once fulfillments drain. - `native_drag/uti.rs`: pure extension→UTI mapping (fallback `public.data`, folders `public.folder`). - `native_drag/mod.rs`: virtual sessions attach one `NSFilePromiseProvider` per item as the dragging-item writer; local sessions unchanged. Threads `source_volume_id` through `start_drag` and the two drag commands. - `write_operations`: `pub(crate)` busy-volume seam (`register_external_volume_op` / `release_external_volume_op`) so the fulfillment service marks the source busy without being a real write op. - Main-thread invariant: the service never does sync main-thread work from the queue thread (documented). App-quit abort rides stream `Drop` semantics + the cleanup contract; no new teardown hook needed. No new Tauri commands (bindings unchanged). Tests: fulfillment headless vs the in-memory volume + tempdir (exact-leaf happy path, read-failure cleanup, unwritable dest, missing source, recursive folder, mid-folder cleanup, busy-volume seam), UTI mapping units, delegate smoke + NSError mapping + session-lifetime state machine.
1 parent 6e8ac5a commit c97a032

11 files changed

Lines changed: 1831 additions & 68 deletions

File tree

apps/desktop/src-tauri/src/commands/file_system/drag.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ pub fn start_drag_paths(
5151
return Err("No valid files to drag".to_string());
5252
}
5353
let locality = locality_for_volume(source_volume_id.as_deref());
54-
run_drag_on_main_thread(&app, path_bufs, PathBuf::from(icon_path), locality)
54+
run_drag_on_main_thread(&app, path_bufs, PathBuf::from(icon_path), locality, source_volume_id)
5555
}
5656

5757
/// Stub for non-macOS platforms. Returns an error since drag is not yet implemented.
@@ -90,7 +90,7 @@ pub fn start_selection_drag(
9090
let volume_id = crate::file_system::listing::get_listing_volume_id_and_path(&listing_id).map(|(vid, _)| vid);
9191
let locality = locality_for_volume(volume_id.as_deref());
9292

93-
run_drag_on_main_thread(&app, paths, PathBuf::from(icon_path), locality)
93+
run_drag_on_main_thread(&app, paths, PathBuf::from(icon_path), locality, volume_id)
9494
}
9595

9696
/// Stub for non-macOS platforms. Returns an error since drag is not yet implemented.
@@ -117,12 +117,13 @@ fn run_drag_on_main_thread(
117117
paths: Vec<PathBuf>,
118118
icon_path: PathBuf,
119119
locality: DragSessionLocality,
120+
source_volume_id: Option<String>,
120121
) -> Result<(), String> {
121122
let window = app.get_webview_window("main").ok_or("Main window not found")?;
122123
let (tx, rx) = channel();
123124

124125
app.run_on_main_thread(move || {
125-
let result = native_drag::start_drag(&window, paths, &icon_path, locality);
126+
let result = native_drag::start_drag(&window, paths, &icon_path, locality, source_volume_id.as_deref());
126127
let _ = tx.send(result);
127128
})
128129
.map_err(|e| format!("Failed to run on main thread: {}", e))?;

apps/desktop/src-tauri/src/file_system/write_operations/CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ Drives "disable Eject while an op reads from / writes to this device" so a disco
175175
- The busy set is the union of every active op's `volume_ids`, minus `root` (never ejectable). `recompute_and_emit_busy_volumes` fires `volumes-busy-changed` only when membership changes — progress ticks don't churn it. Membership-by-union means two concurrent transfers to one device keep it busy until both finish, with no manual refcount.
176176
- **Where `volume_ids` come from**: the cross-volume transfer entry points (`copy_between_volumes`, `move_between_volumes`, `move_within_same_volume`) and the volume-aware `delete_files_start` carry the IDs; `copy_files_start` / `move_files_start` take a `volume_ids` param so the both-local branch of `copy_between_volumes` (which is how a local→USB / DMG copy lands) still marks the ejectable destination. The plain `copy_files` / `move_files` / `trash` commands pass `vec![]` — the unified transfer dialog only routes through them for same-`root` ops, where no ejectable volume is involved.
177177
- **Consumers**: `busy_volume_ids()` backs the `get_busy_volume_ids` bootstrap command, the `eject_volume` server-side guard (refuses a busy volume — the real safety net, since the picker's disable is only UX), and the native breadcrumb-menu builder (renders the Eject item disabled with a ` (busy)` suffix). The frontend `volume-busy-store.svelte.ts` subscribes to `volumes-busy-changed` and exposes `isVolumeBusy(id)` to disable the picker's eject controls. `init_busy_volume_emitter(app)` wires the emitter at startup (`lib.rs`).
178+
- **External (non-write-op) seam**: the drag-out file-promise fulfillment service (`native_drag::fulfillment`) marks the source volume busy while it streams a promise to a Finder destination, but it isn't a real write op (no `WRITE_OPERATION_STATE`, no progress events, no settle). The `pub(crate)` `register_external_volume_op(op_id, volume_ids)` / `release_external_volume_op(op_id)` pair (in `state.rs`, re-exported from `mod.rs`) is the seam: it touches only the `OPERATION_STATUS_CACHE` half that `recompute_and_emit_busy_volumes` reads, registering under `WriteOperationType::Copy` (the type only affects `list_active_operations` diagnostics; the busy set is type-agnostic). The fulfillment side wraps it in an RAII guard so release fires on every exit path.
178179

179180
## Settle contract
180181

apps/desktop/src-tauri/src/file_system/write_operations/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ pub use state::{
6565
busy_volume_ids, cancel_all_write_operations, cancel_write_operation, get_operation_status,
6666
init_busy_volume_emitter, list_active_operations, resolve_write_conflict,
6767
};
68+
// External busy-volume seam for the drag-out fulfillment service (see
69+
// `state.rs` § "External busy-volume seam"). `pub(crate)` so only in-crate
70+
// callers (`native_drag::fulfillment`) reach it.
71+
pub(crate) use state::{register_external_volume_op, release_external_volume_op};
6872
#[allow(unused_imports, reason = "Public API re-exports for consumers of this module")]
6973
pub use types::{
7074
ConflictInfo, ConflictResolution, DryRunResult, OperationStatus, OperationSummary, ScanPreviewCancelledEvent,

apps/desktop/src-tauri/src/file_system/write_operations/state.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,41 @@ pub(super) fn unregister_operation_status(operation_id: &str) {
352352
recompute_and_emit_busy_volumes();
353353
}
354354

355+
// ============================================================================
356+
// External busy-volume seam (drag-out file promises)
357+
// ============================================================================
358+
//
359+
// `register_operation_status` / `unregister_operation_status` are `pub(super)`,
360+
// reachable only inside `write_operations`. The drag-out fulfillment service
361+
// (`crate::native_drag::fulfillment`) lives outside this module but needs the
362+
// same eject guard: while it streams bytes off an MTP/SMB device into a
363+
// Finder-chosen destination, the source volume must register as busy so the
364+
// user can't eject the phone mid-download (the server-side `eject_volume` guard
365+
// reads `busy_volume_ids()`). It is NOT a real write operation — no
366+
// `WRITE_OPERATION_STATE` entry, no progress events, no settle — so it can't go
367+
// through `start_write_operation`. This thin `pub(crate)` pair is the smallest
368+
// honest seam: it touches only the `OPERATION_STATUS_CACHE` half (which is what
369+
// `recompute_and_emit_busy_volumes` reads), keeping the busy set and the
370+
// `volumes-busy-changed` event firing exactly as a real op would.
371+
372+
/// Marks `volume_ids` busy for the duration of an external (non-write-op)
373+
/// operation, keyed by `op_id`. Used by the drag-out fulfillment service to
374+
/// guard the source volume against eject while a promise is streaming. Pair
375+
/// every call with [`release_external_volume_op`] (the fulfillment service uses
376+
/// an RAII guard so release fires on every exit path).
377+
///
378+
/// Registers under `WriteOperationType::Copy` because a drag-out download IS a
379+
/// copy from the device to local disk — the type only affects diagnostics
380+
/// (`list_active_operations`), and the busy set itself is type-agnostic.
381+
pub(crate) fn register_external_volume_op(op_id: &str, volume_ids: Vec<String>) {
382+
register_operation_status(op_id, WriteOperationType::Copy, volume_ids);
383+
}
384+
385+
/// Clears the busy mark registered by [`register_external_volume_op`].
386+
pub(crate) fn release_external_volume_op(op_id: &str) {
387+
unregister_operation_status(op_id);
388+
}
389+
355390
// ============================================================================
356391
// Busy-volumes set (drives "disable Eject while an op touches this device")
357392
// ============================================================================

apps/desktop/src-tauri/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,12 @@ pub fn run() {
397397
#[cfg(any(target_os = "macos", target_os = "linux"))]
398398
file_system::volume::smb::set_app_handle(app.handle().clone());
399399

400+
// Stash the AppHandle so the drag-out file-promise machinery can
401+
// dispatch session cleanup (freeing the retained promise delegates)
402+
// back to the AppKit main thread once a fulfillment drains.
403+
#[cfg(target_os = "macos")]
404+
native_drag::set_app_handle(app.handle().clone());
405+
400406
// Network discovery (mDNS) startup is deferred. See the post-`load_settings`
401407
// block below. Starting mDNS here would trigger macOS's "Cmdr wants to find devices
402408
// on local networks" prompt at app launch even on first install before the user has
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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

Comments
 (0)