Skip to content

Commit a3eae1c

Browse files
committed
Drag: suppress overlay for large source images
- Swizzle WryWebView.draggingEntered: to read drag image size via enumerateDraggingItems (macOS only) - Emit drag-image-size Tauri event with dimensions - Frontend skips custom overlay when source provides a preview larger than 32×32px (like Finder) - Falls back to showing overlay if event never fires(non-macOS, class not found, small/blank source) - Add feature docs
1 parent 371746b commit a3eae1c

8 files changed

Lines changed: 566 additions & 54 deletions

File tree

apps/desktop/src-tauri/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,11 @@ objc2-foundation = { version = "0.3", features = [
8787
"NSFileManager",
8888
] }
8989
mdns-sd = { version = "0.17", features = ["logging"] }
90-
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSRunningApplication"] }
90+
objc2-app-kit = { version = "0.3", features = [
91+
"NSApplication", "NSRunningApplication",
92+
"NSDragging", "NSDraggingItem", "NSPasteboard", "NSView", "NSResponder",
93+
] }
94+
block2 = "0.6"
9195
smb = "0.11.1"
9296
smb-rpc = "=0.11.1"
9397
chrono = "0.4"
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
//! Native drag interception for macOS via method swizzling on WryWebView.
2+
//!
3+
//! Swizzles `draggingEntered:` and `draggingUpdated:` to:
4+
//! 1. Read drag image dimensions via `enumerateDraggingItems` (for overlay suppression)
5+
//! 2. Read modifier key state via `[NSEvent modifierFlags]` (for copy/move detection)
6+
//!
7+
//! Events emitted:
8+
//! - `drag-image-size` `{ width, height }` — on drag enter
9+
//! - `drag-modifiers` `{ altHeld }` — on drag enter and every drag update (only when changed)
10+
11+
use std::ptr::NonNull;
12+
use std::sync::OnceLock;
13+
use std::sync::atomic::{AtomicBool, Ordering};
14+
15+
use objc2::runtime::{AnyClass, AnyObject, Bool, Imp, Sel};
16+
use objc2::{msg_send, sel};
17+
use objc2_app_kit::{NSDragOperation, NSDraggingItem, NSDraggingItemEnumerationOptions};
18+
use objc2_foundation::{NSDictionary, NSInteger, NSRect};
19+
use serde::Serialize;
20+
use tauri::{AppHandle, Emitter};
21+
22+
/// NSEventModifierFlagOption = 1 << 19 (Option/Alt key)
23+
const NS_EVENT_MODIFIER_FLAG_OPTION: usize = 1 << 19;
24+
25+
#[derive(Clone, Serialize)]
26+
struct DragImageSize {
27+
width: f64,
28+
height: f64,
29+
}
30+
31+
#[derive(Clone, Serialize)]
32+
struct DragModifiers {
33+
#[serde(rename = "altHeld")]
34+
alt_held: bool,
35+
}
36+
37+
static ORIGINAL_ENTERED_IMP: OnceLock<Imp> = OnceLock::new();
38+
static ORIGINAL_UPDATED_IMP: OnceLock<Imp> = OnceLock::new();
39+
static APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
40+
41+
/// Tracks previous alt state so we only emit `drag-modifiers` when it changes.
42+
static LAST_ALT_HELD: AtomicBool = AtomicBool::new(false);
43+
44+
/// Installs swizzles on WryWebView. Call once during app setup.
45+
pub fn install(app_handle: AppHandle) {
46+
APP_HANDLE.set(app_handle).ok();
47+
48+
unsafe {
49+
let Some(cls) = AnyClass::get(c"WryWebView") else {
50+
log::warn!("drag_image_detection: WryWebView class not found, skipping swizzle");
51+
return;
52+
};
53+
54+
// Swizzle draggingEntered:
55+
if let Some(method) = cls.instance_method(sel!(draggingEntered:)) {
56+
ORIGINAL_ENTERED_IMP.set(method.implementation()).ok();
57+
method.set_implementation(std::mem::transmute::<*const (), Imp>(
58+
swizzled_dragging_entered as *const (),
59+
));
60+
} else {
61+
log::warn!("drag_image_detection: draggingEntered: not found on WryWebView");
62+
}
63+
64+
// Swizzle draggingUpdated:
65+
if let Some(method) = cls.instance_method(sel!(draggingUpdated:)) {
66+
ORIGINAL_UPDATED_IMP.set(method.implementation()).ok();
67+
method.set_implementation(std::mem::transmute::<*const (), Imp>(
68+
swizzled_dragging_updated as *const (),
69+
));
70+
} else {
71+
log::warn!("drag_image_detection: draggingUpdated: not found on WryWebView");
72+
}
73+
74+
log::info!("drag_image_detection: swizzles installed on WryWebView");
75+
}
76+
}
77+
78+
/// Reads the current Option/Alt key state from `[NSEvent modifierFlags]`.
79+
/// This is a class method that reads hardware state — works even when the webview isn't focused.
80+
fn is_option_held() -> bool {
81+
let flags: usize = unsafe { msg_send![AnyClass::get(c"NSEvent").unwrap(), modifierFlags] };
82+
flags & NS_EVENT_MODIFIER_FLAG_OPTION != 0
83+
}
84+
85+
/// Emits `drag-modifiers` if the alt state changed since last emission.
86+
fn emit_modifiers_if_changed() {
87+
let alt_held = is_option_held();
88+
let prev = LAST_ALT_HELD.swap(alt_held, Ordering::Relaxed);
89+
if alt_held != prev
90+
&& let Some(app_handle) = APP_HANDLE.get()
91+
{
92+
let _ = app_handle.emit("drag-modifiers", DragModifiers { alt_held });
93+
}
94+
}
95+
96+
/// Emits `drag-modifiers` unconditionally (used on drag enter to set initial state).
97+
fn emit_modifiers_forced() {
98+
let alt_held = is_option_held();
99+
LAST_ALT_HELD.store(alt_held, Ordering::Relaxed);
100+
if let Some(app_handle) = APP_HANDLE.get() {
101+
let _ = app_handle.emit("drag-modifiers", DragModifiers { alt_held });
102+
}
103+
}
104+
105+
// --- draggingEntered: swizzle ---
106+
107+
unsafe extern "C-unwind" fn swizzled_dragging_entered(this: &AnyObject, cmd: Sel, drag_info: &AnyObject) -> usize {
108+
let size = unsafe { read_drag_image_size(drag_info) };
109+
110+
if let Some(app_handle) = APP_HANDLE.get() {
111+
let _ = app_handle.emit(
112+
"drag-image-size",
113+
DragImageSize {
114+
width: size.0,
115+
height: size.1,
116+
},
117+
);
118+
}
119+
120+
// Always emit modifiers on enter (initial state for this drag session)
121+
emit_modifiers_forced();
122+
123+
if let Some(&original) = ORIGINAL_ENTERED_IMP.get() {
124+
let f = unsafe {
125+
std::mem::transmute::<Imp, unsafe extern "C-unwind" fn(&AnyObject, Sel, &AnyObject) -> usize>(original)
126+
};
127+
unsafe { f(this, cmd, drag_info) }
128+
} else {
129+
NSDragOperation::Copy.0
130+
}
131+
}
132+
133+
// --- draggingUpdated: swizzle ---
134+
135+
unsafe extern "C-unwind" fn swizzled_dragging_updated(this: &AnyObject, cmd: Sel, drag_info: &AnyObject) -> usize {
136+
// Only emit when modifier state changes (avoids flooding on every mouse move)
137+
emit_modifiers_if_changed();
138+
139+
if let Some(&original) = ORIGINAL_UPDATED_IMP.get() {
140+
let f = unsafe {
141+
std::mem::transmute::<Imp, unsafe extern "C-unwind" fn(&AnyObject, Sel, &AnyObject) -> usize>(original)
142+
};
143+
unsafe { f(this, cmd, drag_info) }
144+
} else {
145+
NSDragOperation::Copy.0
146+
}
147+
}
148+
149+
// --- Drag image size reading ---
150+
151+
unsafe fn read_drag_image_size(drag_info: &AnyObject) -> (f64, f64) {
152+
let size = unsafe { enumerate_dragging_frames(drag_info) };
153+
if size.0 > 0.0 || size.1 > 0.0 {
154+
return size;
155+
}
156+
157+
// Fallback: try the deprecated draggedImage() — works for same-process drags
158+
let image: *const AnyObject = unsafe { msg_send![drag_info, draggedImage] };
159+
if !image.is_null() {
160+
let ns_size: objc2_foundation::NSSize = unsafe { msg_send![image, size] };
161+
if ns_size.width > 0.0 || ns_size.height > 0.0 {
162+
return (ns_size.width, ns_size.height);
163+
}
164+
}
165+
166+
(0.0, 0.0)
167+
}
168+
169+
unsafe fn enumerate_dragging_frames(drag_info: &AnyObject) -> (f64, f64) {
170+
// NSURL matches file drags (Finder, etc). Constructed via ObjC msg_send because
171+
// AnyClass (Class in ObjC) is a valid object but doesn't fit objc2's typed NSArray.
172+
let Some(nsurl_cls) = AnyClass::get(c"NSURL") else {
173+
return (0.0, 0.0);
174+
};
175+
let nsarray_cls = AnyClass::get(c"NSArray").expect("NSArray class must exist");
176+
let class_array: *const AnyObject =
177+
unsafe { msg_send![nsarray_cls, arrayWithObject: nsurl_cls as *const AnyClass] };
178+
if class_array.is_null() {
179+
return (0.0, 0.0);
180+
}
181+
182+
let empty_dict_owned = NSDictionary::new();
183+
let empty_dict: &NSDictionary = empty_dict_owned.as_ref();
184+
185+
let min_x = std::cell::Cell::new(f64::MAX);
186+
let min_y = std::cell::Cell::new(f64::MAX);
187+
let max_x = std::cell::Cell::new(f64::MIN);
188+
let max_y = std::cell::Cell::new(f64::MIN);
189+
let found = std::cell::Cell::new(false);
190+
191+
let block = block2::RcBlock::new(|item: NonNull<NSDraggingItem>, _idx: NSInteger, _stop: NonNull<Bool>| {
192+
let frame: NSRect = unsafe { item.as_ref() }.draggingFrame();
193+
194+
found.set(true);
195+
let x = frame.origin.x;
196+
let y = frame.origin.y;
197+
let w = frame.size.width;
198+
let h = frame.size.height;
199+
200+
if x < min_x.get() {
201+
min_x.set(x);
202+
}
203+
if y < min_y.get() {
204+
min_y.set(y);
205+
}
206+
if x + w > max_x.get() {
207+
max_x.set(x + w);
208+
}
209+
if y + h > max_y.get() {
210+
max_y.set(y + h);
211+
}
212+
});
213+
214+
let opts = NSDraggingItemEnumerationOptions(0);
215+
unsafe {
216+
let _: () = msg_send![
217+
drag_info,
218+
enumerateDraggingItemsWithOptions: opts.0,
219+
forView: std::ptr::null::<AnyObject>(),
220+
classes: class_array,
221+
searchOptions: empty_dict,
222+
usingBlock: &*block,
223+
];
224+
}
225+
226+
if found.get() {
227+
let width = max_x.get() - min_x.get();
228+
let height = max_y.get() - min_y.get();
229+
(width.max(0.0), height.max(0.0))
230+
} else {
231+
(0.0, 0.0)
232+
}
233+
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ use nusb as _;
5151
// objc2-app-kit is used in volumes/mod.rs for NSRunningApplication
5252
#[cfg(target_os = "macos")]
5353
use objc2_app_kit as _;
54+
//noinspection ALL
55+
// block2 is used in drag_image_detection.rs for ObjC block closures
56+
#[cfg(target_os = "macos")]
57+
use block2 as _;
5458

5559
mod ignore_poison;
5660
pub use ignore_poison::IgnorePoison;
@@ -59,6 +63,8 @@ mod ai;
5963
pub mod benchmark;
6064
mod commands;
6165
pub mod config;
66+
#[cfg(target_os = "macos")]
67+
mod drag_image_detection;
6268
mod file_system;
6369
pub(crate) mod file_viewer;
6470
mod font_metrics;
@@ -148,6 +154,10 @@ pub fn run() {
148154
#[cfg(target_os = "macos")]
149155
network::known_shares::load_known_shares(app.handle());
150156

157+
// Install drag image detection swizzle (macOS only)
158+
#[cfg(target_os = "macos")]
159+
drag_image_detection::install(app.handle().clone());
160+
151161
// Initialize font metrics for default font (system font at 12px)
152162
font_metrics::init_font_metrics(app.handle(), "system-400-12");
153163

apps/desktop/src/lib/file-explorer/modifier-key-tracker.svelte.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// Tracks Alt/Option modifier key state during drag operations.
22
// Alt held = "Move" operation, no modifier = "Copy" (default).
3-
// Uses Svelte 5 reactive state ($state) for live UI updates.
3+
//
4+
// Two sources feed this state:
5+
// 1. Document keydown/keyup events (works when webview is focused)
6+
// 2. Native `drag-modifiers` Tauri event from the swizzled WryWebView
7+
// (reads [NSEvent modifierFlags] — works during OS-level drags when
8+
// the webview doesn't receive keyboard events)
49

510
let altKeyHeld = $state(false)
611
let listenerAttached = false
@@ -38,3 +43,8 @@ export function stopModifierTracking(): void {
3843
export function getIsAltHeld(): boolean {
3944
return altKeyHeld
4045
}
46+
47+
/** Sets the Alt state from an external source (native drag-modifiers event). */
48+
export function setAltHeld(held: boolean): void {
49+
altKeyHeld = held
50+
}

0 commit comments

Comments
 (0)