Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tauri ↔ Leptos integration (M-INT.2)

The chunk that joins the Leptos shell to the Tauri webview, then forwards OS-level drag-drop events into a Leptos signal.

Data flow

sequenceDiagram
    participant OS
    participant Shell as crates/app/src/main.rs<br/>(Tauri shell)
    participant Bridge as crates/app-ui/index.html<br/>(JS bridge)
    participant App as crates/app-ui/src/app.rs<br/>(Leptos)
    participant View as &lt;App&gt; view

    OS ->> Shell: drag-drop event
    Note over Shell: on_window_event(<br/>WindowEvent::DragDrop)
    Shell -->> Bridge: window.emit("file-dropped", path)
    Note over Bridge: __TAURI__.event.listen
    Bridge -->> App: window.dispatchEvent(<br/>CustomEvent "file-dropped")
    Note over App: install_file_drop_listener()<br/>addEventListener
    App ->> App: set_loaded.set(Some(path))
    App -->> View: swap drop-zone view → player view

Every hop is a one-liner. No tauri-sys crate, no JS-side state — the bridge is one addEventListener per direction.

Tauri side (crates/app/src/main.rs)

#![allow(unused)]
fn main() {
.on_window_event(|window, event| {
    if let WindowEvent::DragDrop(DragDropEvent::Drop { paths, .. }) = event
        && let Some(path) = paths.first()
    {
        let payload = path.to_string_lossy().into_owned();
        if let Err(err) = window.emit("file-dropped", payload) {
            eprintln!("failed to emit file-dropped event: {err}");
        }
    }
})
}

Tauri 2's WindowEvent::DragDrop fires automatically when tauri.conf.json has "dragDropEnabled": true (the default for our window).

JS bridge (crates/app-ui/index.html)

<script>
  window.addEventListener("DOMContentLoaded", () => {
    if (window.__TAURI__ && window.__TAURI__.event) {
      window.__TAURI__.event.listen("file-dropped", (event) => {
        window.dispatchEvent(new CustomEvent("file-dropped", {
          detail: event.payload
        }));
      });
    }
  });
</script>

Why a CustomEvent instead of calling Leptos directly: it keeps the WASM bundle dependency-free of Tauri's JS API. Leptos's web-sys listener works against any browser; the bridge degrades to a no-op when window.__TAURI__ is absent (e.g. running under trunk serve for component review).

Leptos side (crates/app-ui/src/app.rs)

#![allow(unused)]
fn main() {
fn install_file_drop_listener(set_loaded: WriteSignal<Option<String>>) {
    let Some(window) = web_sys::window() else { return; };
    let closure = Closure::wrap(Box::new(move |event: web_sys::Event| {
        if let Ok(ce) = event.dyn_into::<CustomEvent>()
            && let Some(path) = ce.detail().as_string()
        {
            set_loaded.set(Some(path));
        }
    }) as Box<dyn FnMut(_)>);
    let _ = window.add_event_listener_with_callback(
        "file-dropped",
        closure.as_ref().unchecked_ref(),
    );
    closure.forget();   // app-lifetime listener — never removed
}
}

Closure::forget is intentional: the listener lives for the whole app lifetime. Dropping it would silently de-register the handler.

Tauri config (tauri.conf.json)

{
  "build": {
    "frontendDist": "../app-ui/dist",
    "beforeDevCommand": "cd ../app-ui && trunk serve --port 1420",
    "beforeBuildCommand": "cd ../app-ui && trunk build --release",
    "devUrl": "http://localhost:1420"
  }
}

cargo tauri dev automatically runs trunk serve in crates/app-ui/ and waits for it on port 1420 before opening the webview. cargo tauri build runs trunk build --release first; the resulting crates/app-ui/dist/ becomes the bundled webview content.

How to run

# Dev — hot-reload Leptos via Trunk + reload the Tauri webview on save.
cd crates/app && cargo tauri dev

# Production — Trunk build + Tauri build, single binary.
cd crates/app && cargo tauri build

Drop any file on the running window; the path appears under "Preview surface · " in the player view.

Drag-over visual feedback (M-POLISH.1)

Tauri 2 fires four DragDropEvent variants — Enter, Over, Drop, Leave. M-INT.2 only handled Drop. M-POLISH.1 adds Enter and Leave so the drop-zone shows the user "yes, this drop will land here":

#![allow(unused)]
fn main() {
// crates/app/src/main.rs
match drag {
    DragDropEvent::Enter { .. } => emit("file-drag-enter"),
    DragDropEvent::Leave => emit("file-drag-leave"),
    DragDropEvent::Drop { paths, .. } => {
        emit("file-dropped", path);
        emit("file-drag-leave");  // reset visual after drop
    }
    _ => {}  // Over and any future variants
}
}

The JS bridge re-emits as browser CustomEvents (parallel to file-dropped / player-status). Leptos installs an is_dragging signal that flips on enter/leave; the existing DropZoneState::Active variant the storybook already shipped (M-UI.1) provides the visual.

Tier-2 e2e (crates/app-e2e/tests/golden_path.rs) uses __test_drag_enter and __test_drag_leave debug-only commands — parallel to __test_drop_file since WebDriver can't synthesize OS-level drag events.

App-shell visual references

The drop-zone surface (idle) at boot — exactly what the user sees before any file is dropped:

DropZone story · Editor mock composition