I recently shipped SnapShelf, a transparent always-on-top "staging shelf" for Windows; you fling files at the right edge of your screen, it slides in, you drop stuff on it, and later you drag that stuff back out into whatever app needs it. Think of it as a temporary holding tray for files, images, text, and URLs while you move things around your desktop.
It's built with Tauri v2 (Rust backend + React/TS frontend). On paper it's a small app. In practice, almost every "small" feature collided with a deep Windows/WebView2 constraint that wasn't in any quickstart. This post is the engineering autopsy: the problems that actually cost me days, and the non-obvious fixes.
If you're building anything that touches native drag-and-drop, transparent windows, or MSIX packaging with Tauri, this'll save you some hair.
The architecture, briefly
- One main WebviewWindow. Transparent, undecorated, always-on-top, skipTaskbar.
- A Rust polling thread that reads the cursor position every 50ms via the Win32 API and decides when to open/close the shelf.
- A small set of atomics as the single source of truth for window state, read lock-free by the poll thread and the IPC commands.
- React frontend for the UI; Rust owns all the window-positioning and OS integration.
That sounds clean. Getting there was not.
Challenge 1: WebView2 silently refuses drag-and-drop unless the window was composited before the drag started
This was the big one.
The whole point of the app is dragging files in from Explorer. So naturally my first design was: keep the window hidden (visible: false), and show() it the moment a drag approaches the edge. Clean, no startup flash, minimal resource use.
It did not work. The shelf would slide in, I'd release the file over it, and nothing. No drop event. Ever.
The cause: WebView2 registers its OLE drop target (RegisterDragDrop / IDropTarget) only when the window is first shown and composited by the DWM. If the first show() happens mid-drag, which is exactly my trigger flow, the drop target isn't registered yet when the file is released. The drag silently does nothing.
The fix is counterintuitive: start the window "visible" but parked far off-screen.
// tauri.conf.json
{
"windows": [
{
"label": "main",
"visible": true, // MUST be true so WebView2 composites + registers IDropTarget
"x": -10000, // but park it off every monitor so nothing flashes
"transparent": true,
"decorations": false,
"alwaysOnTop": true,
"skipTaskbar": true,
"dragDropEnabled": true
}
]
}
The window is technically "visible" from the OS's perspective, so WebView2 composites it and registers the drop target at startup, but it's at x: -10000, off every physical monitor, so the user never sees a flash. By the time any drag happens, the drop target has been live for seconds.
Takeaway: "visible" in Tauri config means "composited," not "on a monitor the user can see." For OS drag-drop to work, you need composited-before-drag. Park off-screen instead of hiding.
Challenge 2: dragDropEnabled is ignored if you set it in the Rust builder
Closely related, and a great way to lose an afternoon. Tauri lets you build windows two ways: declaratively in tauri.conf.json, or imperatively with WebviewWindowBuilder in Rust.
There's an open Tauri bug (#13761) where dragDropEnabled only takes effect when the window is declared in tauri.conf.json. Set it on the Rust builder and it's silently dropped; drag events never fire.
So: declare the window (and dragDropEnabled: true) in the config file, not in Rust. This conflicts with a lot of "spawn your window dynamically" advice you'll find, but it's non-negotiable if you need OS drops.
Challenge 3: Never call .hide(), it kills the Acrylic backdrop
I wanted that native Windows 11 Acrylic glass look, applied once at startup:
#[cfg(target_os = "windows")]
{
use window_vibrancy::{apply_acrylic, apply_mica};
if apply_acrylic(&win, Some((18, 18, 18, 90))).is_err() {
let _ = apply_mica(&win, Some(true)); // fallback
}
}
This works great, until you hide() and show() the window. On show, the Acrylic effect is gone; you're left with a flat or black backdrop, and re-applying it on every show causes visible flicker and DWM recomposition jank.
The fix ties back to Challenge 1: I never call hide() / show() at all. Showing and hiding the shelf is purely set_position; slide it on-screen to reveal, park it off-screen to "hide":
const PARK_X: i32 = -32000; // guaranteed off all monitors
fn do_show_shelf(app: &tauri::AppHandle) {
let win = app.get_webview_window("main").unwrap();
let _ = win.set_position(tauri::PhysicalPosition::new(shelf_x(), shelf_y()));
// emit event so the frontend can play its slide-in transition
}
fn do_hide_shelf(app: &tauri::AppHandle) {
let win = app.get_webview_window("main").unwrap();
// emit hide event, then after the CSS transition, park it:
let _ = win.set_position(tauri::PhysicalPosition::new(PARK_X, 0));
}
The window is always composited and Acrylic is applied once. The user just sees it slide in and out. Bonus: positioning is cheaper than show/hide and never re-triggers vibrancy.
Takeaway: with transparent/vibrancy windows, treat the window as permanently alive. Move it, don't hide it.
Challenge 4: set_focus() breaks the OLE drag you're trying to catch
When the shelf slides in during a drag, my instinct was to set_focus() so it's ready for interaction. Don't. Calling set_focus() on the target window mid-drag disrupts Explorer's DoDragDrop loop; the OLE drag gets interrupted and the drop fails.
This created an interesting design tension: in Tray mode (where the user opens the shelf manually with no active drag) I do want focus so I can auto-close on blur. But in the drag-in path, focus is poison.
I solved it with a per-open "close policy"; the show function takes a policy that decides whether to focus:
static CLOSE_POLICY: AtomicU8 = AtomicU8::new(0);
const CP_CURSOR_PARK: u8 = 0; // drag-in / hover: close when cursor leaves
const CP_CURSOR_SLIVER: u8 = 1; // tab mode: collapse to a strip
const CP_BLUR: u8 = 2; // tray mode: close on focus loss
const CP_MANUAL: u8 = 3; // pinned: only explicit close
fn do_show_shelf(app: &tauri::AppHandle, policy: u8) {
// position the window...
CLOSE_POLICY.store(policy, Ordering::Relaxed);
// set_focus ONLY when there's no active OLE drag to disrupt:
if policy == CP_BLUR {
let _ = win.set_focus();
}
}
The drag-in path passes CP_CURSOR_PARK (never focuses). Tray open passes CP_BLUR (focuses, so the blur handler can close it). One enum, no broken drags.
Challenge 5: I deleted the "edge detector" window entirely and replaced it with a 50ms poll
The textbook way to detect "user dragged something to the screen edge" is a thin, transparent, always-on-top trigger window glued to the edge that listens for drag-enter. I built that first. It worked, but:
- It's a second WebView; more RAM, against my under-30MB idle goal.
- A click-through (
set_ignore_cursor_events) window won't receive OS drag-enter, so it can't be click-through, which means it eats a 2px strip of clicks. - It's another moving part that can race with the main window.
I scrapped it for a single Rust thread polling the Win32 cursor API directly. It owns both open and close decisions, for every mode:
#[cfg(target_os = "windows")]
fn start_drag_edge_poll(app: tauri::AppHandle) {
use windows_sys::Win32::Foundation::POINT;
use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
use windows_sys::Win32::UI::WindowsAndMessaging::GetCursorPos;
std::thread::spawn(move || {
let mut hover_dwell: u8 = 0;
loop {
std::thread::sleep(std::time::Duration::from_millis(50));
let mut pt = POINT { x: 0, y: 0 };
if unsafe { GetCursorPos(&mut pt) } == 0 { continue; }
let lbtn_down = unsafe { GetAsyncKeyState(0x01) } as u16 & 0x8000 != 0;
// Drag-in: left button held + cursor at right edge -> open. Works in every mode.
if lbtn_down && pt.x >= screen_right() - 30 && in_y_band(pt.y) {
do_show_shelf(&app, CP_CURSOR_PARK);
}
// hover-dwell, tab-strip, and auto-close logic follow...
}
});
}
50ms is imperceptible to the user but trivial for the CPU (the thread is asleep 99% of the time). No second webview, no click-eating strip, and all the timing logic lives in one place. The polling-thread-as-state-machine pattern turned out far simpler than coordinating two windows via events.
Challenge 6: Dragging an item out trips your own auto-close
Here's a fun self-inflicted bug. Auto-close fires when the cursor leaves the shelf rectangle. But dragging a file out of the shelf is literally moving the cursor off the shelf, so the shelf would slam shut mid-drag, cancelling the drag-out.
Fixed with a flag that the drag-out command raises for its duration, and which the poll loop's close check respects:
pub static DRAG_OUT_ACTIVE: AtomicBool = AtomicBool::new(false);
#[tauri::command]
pub async fn start_file_drag(app: tauri::AppHandle, paths: Vec<String>) -> Result<(), String> {
DRAG_OUT_ACTIVE.store(true, Ordering::Relaxed);
let result = tauri::async_runtime::spawn_blocking(move || {
drag::start_drag(/* ... */);
}).await.map_err(|e| e.to_string());
DRAG_OUT_ACTIVE.store(false, Ordering::Relaxed); // self-clearing after the drag completes
result
}
And in the poll loop:
if (policy == CP_CURSOR_PARK || policy == CP_CURSOR_SLIVER)
&& !DRAG_OUT_ACTIVE.load(Ordering::Relaxed) // don't auto-close mid drag-out
&& cursor_outside_shelf_rect(pt)
{
do_hide_shelf(&app);
}
It's self-cleaning: the flag clears when start_drag returns (after drop), and the next poll tick tidies up if the cursor is now off the shelf.
Challenge 7: Shrinking the binary from 17MB to 5.6MB
The first release build was 17MB. For something that markets itself as "lightweight," that stung. Two big levers:
- Cargo release profile. This alone is enormous:
# src-tauri/Cargo.toml
[profile.release]
opt-level = "s" # optimize for size
lto = true # link-time optimization
codegen-units = 1 # better optimization, slower compile
strip = true # strip symbols
panic = "abort" # drop unwinding machinery
- Kill default features on heavy crates. The
imagecrate pulls in decoders for a dozen formats I'll never see in a clipboard shelf:
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
I also ripped out syntax highlighting entirely. I'd reflexively added Shiki + highlight.js for "code snippet" cards early on; it was about 11MB of the frontend bundle. A clipboard shelf does not need a syntax highlighter. Deleting it took the dist/ from 11MB to 249KB. Sometimes the best optimization is admitting a feature was never needed.
Takeaway: audit your default-features. Most heavy crates pull in things you don't use, and Cargo won't tell you. And question features that drag in megabytes for marginal value.
Challenge 8: A regex that quietly corrupted my MSIX manifest
Last one, and a good "always read the docs for the function signature" lesson.
For Microsoft Store distribution I pack the MSIX myself with a PowerShell script. It patches the Identity Version in the manifest from package.json at pack time. My first version used a regex:
# Intended: replace ONLY the Identity version
$xml = [regex]::Replace($xml, 'Version="\d+\.\d+\.\d+\.\d+"', "Version=""$Version.0""", 1)
Bundling failed with 0x80080215, and worse, I burned an hour blaming MinVersion and platform-targeting before I looked at the actual emitted manifest. The bug: that trailing 1 is not "replace the first match." In .NET's Regex.Replace, that overload's last parameter is RegexOptions, and 1 is the integer value of RegexOptions.IgnoreCase. So it replaced every match.
And MinVersion="10.0.18362.0" contains the substring Version="...". So my "version bumper" was happily rewriting MinVersion to a garbage value, producing an invalid manifest that makeappx rejected with an unhelpful error.
The fix: stop pattern-matching XML, use the XML DOM and target exactly the node I mean:
[xml]$doc = Get-Content $Manifest -Raw -Encoding UTF8
$ns = New-Object Xml.XmlNamespaceManager($doc.NameTable)
$ns.AddNamespace("pkg", "http://schemas.microsoft.com/appx/manifest/foundation/windows10")
$doc.SelectSingleNode("/pkg:Package/pkg:Identity", $ns).SetAttribute("Version", "$Version.0")
$doc.Save($out)
Takeaway: two lessons. (1) Don't regex structured formats when a real parser exists. (2) When a build tool throws a cryptic hex error, inspect its actual input before theorizing about the tool.
What I'd tell my past self
- The platform constraints are the product. 80% of the engineering was respecting WebView2/OLE/DWM rules, not writing features.
- "Visible" is not the same as "on screen." Park, don't hide.
- One state-owning thread beats coordinating multiple windows via events. Fewer races, simpler reasoning.
- Tauri's size win is real but not automatic; you still have to tune the release profile and audit features.
- Read the function signature. That
1cost me an afternoon.
The interesting part was always the fight with the platform underneath.
Happy to go deeper on any of these in the comments. The WebView2 drop-target thing especially; if you're hitting "my drop events never fire," I've probably made your exact mistake.
Try it
SnapShelf is free on the Microsoft Store: Get SnapShelf
Works on Windows 10 (22H2) and Windows 11.










