Skip to main content

Package a Reflex app as a native desktop app with Tauri.

Project description

reflex-desktop

Ship your Reflex app as a real desktop app — a native window, an icon in the dock, an installer you can hand to someone — with Tauri doing the heavy lifting.

You write a normal Reflex app. reflex-desktop wraps it in a lightweight native shell (the OS's built-in WebView, not a bundled Chromium) and either points it at a backend you host, or tucks the whole Python backend inside the binary so the finished app needs nothing installed on the machine it runs on.

reflex-desktop run     # build the desktop app and open it

How it works

A Reflex app is two halves: a static frontend (a React-Router single-page app) and a Python backend. reflex-desktop builds the frontend with Reflex's own toolchain, drops it into a Tauri shell, and connects it to the backend one of two ways:

  • embedded (default) — the Python backend runs in-process inside the Tauri binary via PyO3, against a bundled standalone interpreter. The result is self-contained: no Python, no separate server, nothing to install. Just launch the app.
  • remote — a frontend-only desktop app that talks to a backend you run somewhere else over a URL. Good when the backend already lives on a server.

Most people want embedded. Reach for remote when the backend is hosted separately.


Your first app

Never built a Reflex app? Start with the framework, not with us — go through the Reflex quickstart first. Everything there is exactly the same here; reflex-desktop only changes the last step, where the app gets packaged.

If you just want to see it work, here's a hello-world from scratch.

1. Create a Reflex app (see the installation guide):

mkdir hello && cd hello
uv venv && source .venv/bin/activate
uv pip install reflex
reflex init                 # pick a blank template

2. Add reflex-desktop. It isn't on PyPI yet, so install it from this repo:

uv pip install -e /path/to/reflex-desktop

3. Write the app. Replace hello/hello.py with a tiny counter — the count lives in Python state, so every click round-trips to the backend and back:

import reflex as rx


class State(rx.State):
    count: int = 0

    @rx.event
    def increment(self):
        self.count += 1


def index():
    return rx.center(
        rx.vstack(
            rx.heading(f"Count: {State.count}"),
            rx.button("Click me", on_click=State.increment),
            align="center",
        ),
        height="100vh",
    )


app = rx.App()
app.add_page(index)

4. Turn on the desktop plugin in rxconfig.py:

import reflex as rx

from reflex_desktop import DesktopPlugin

config = rx.Config(
    app_name="hello",
    cors_allowed_origins=["*"],          # let the Tauri webview reach the backend
    plugins=[
        DesktopPlugin(backend="embedded"),
    ],
)

5. Build and launch:

reflex-desktop run

That's it — a native window with your app inside. The first build pulls down a standalone Python interpreter and compiles the Rust shell, so it takes a few minutes; everything after that is quick.

A note on iterating: for pure UI/backend work, plain reflex run (browser, hot reload) is still the fastest loop — the app code is identical. The moment you use a native feature (desktop.notify, window controls, dialogs, invoke), switch to reflex-desktop dev: the same hot-reloading dev server, but inside the real Tauri webview where window.__TAURI__ exists.

There's a fuller, multi-page example (background events, forms, native window controls) in example/ if you want something less trivial to poke at.


Commands

reflex-desktop dev                # hot reload inside the real desktop webview
reflex-desktop run                # build the app, then launch it
reflex-desktop run --skip-build   # relaunch an already-built app, no recompile
reflex-desktop build              # build only (release)
reflex-desktop build --bundle     # also produce installers (.dmg/.msi/.AppImage/.deb)
reflex-desktop doctor             # check your machine has what it needs to build

dev starts the normal reflex run dev server, waits until both the frontend and the backend health check are actually answering, then opens a Tauri shell pointed at the live dev backend — edit your Python and the window hot-reloads, while native bridge features (window.__TAURI__) keep working. Ports come from your rxconfig.py (or Reflex's defaults), and if one is already taken it bumps to the next free one, same as reflex run. The dev shell lives in <app>/tauri-dev/ and only compiles Rust the first time (it needs the Tauri CLI, like --bundle).

run does a fast debug build by default (like cargo run); add --release for an optimized one. build defaults to release. The generated Tauri project lives under <app>/tauri/, and it's a perfectly normal Tauri project — edit src-tauri/ directly if you ever need to.


Prerequisites

Two very different audiences here:

People who use the shipped app need nothing. The installer is self-contained — in embedded mode it even carries its own Python. (The one asterisk: Windows needs the WebView2 runtime, which already ships with Windows 11 and recent Windows 10, and the bundler can include the installer for it.)

People who build the app need a native toolchain, because the build compiles a Rust shell from source — a pip install can't ship you a compiler. This is the standard Tauri setup:

What you need
All platforms Rust, via rustup
Linux libwebkit2gtk-4.1-dev, build-essential, libssl-dev, librsvg2-dev, libayatana-appindicator3-dev, plus curl/wget/file
macOS Xcode Command Line Tools (xcode-select --install)
Windows Microsoft C++ Build Tools (MSVC) + the WebView2 runtime
--bundle / dev the Tauri CLI: cargo install tauri-cli --locked (prebuilt: cargo binstall tauri-cli)
embedded a network connection on the first build (it downloads an interpreter and installs the backend)

Package names drift between distros — Tauri's prerequisites page is the source of truth.

Not sure you're set up? Run:

reflex-desktop doctor            # Rust + platform WebView deps
reflex-desktop doctor --bundle   # also checks the Tauri CLI, for installers

build and run run these checks first anyway, and stop with copy-pasteable install commands if something's missing — no cryptic linker errors halfway through a compile.


Customizing the window, icon & native APIs

DesktopPlugin is the single source of truth for the generated tauri.conf.json, capabilities, and plugin dependencies. It re-applies them on every build, and leaves your hand edits outside the managed regions alone. Configure it in rxconfig.py:

DesktopPlugin(
    backend="embedded",
    window_width=1100, window_height=750,
    resizable=True, min_width=900, min_height=650, center=True,
    theme="Dark",                    # + fullscreen / decorations / transparent / always_on_top
    icon="assets/logo.png",          # a PNG, copied over the bundle icons
    tray=True,                       # system tray icon with a Show / Quit menu
    with_global_tauri=True,          # default — exposes window.__TAURI__ for the bridge below
    tauri_plugins=("notification", "dialog", "clipboard-manager"),
    extra_capabilities=("os:default",),
)

Ports. In embedded mode the backend binds a loopback port baked into the build. The default is derived from your app's identifier, so two installed reflex-desktop apps don't collide; pass port=... to pin one explicitly. If something else already owns the port at launch, the app explains that in a native error dialog (a second launch of the same app just focuses the running window).

Icons. icon= copies one PNG over the four default bundle icons. For the full platform set (.ico/.icns and every size), run cd <app>/tauri/src-tauri && cargo tauri icon <path>.

Driving the OS from Python. reflex_desktop.desktop wraps window.__TAURI__ so a Reflex event handler can control the window or fire native APIs (needs with_global_tauri=True, which is the default):

from reflex_desktop import desktop

rx.button("minimize", on_click=desktop.minimize())
rx.button("max/restore", on_click=desktop.toggle_maximize())
rx.button("close", on_click=desktop.close())
rx.button("notify", on_click=desktop.notify("Title", "Body"))  # needs tauri_plugins=("notification",)

# Native file dialogs (needs tauri_plugins=("dialog",)) — the picked path arrives in
# a normal event handler, and since the backend runs locally you can open() it directly:
rx.button("open…", on_click=desktop.open_file(State.handle_path, filters={"CSV": ["csv"]}))
rx.button("save as…", on_click=desktop.save_file(State.handle_save, default_path="report.csv"))

# Clipboard (needs tauri_plugins=("clipboard-manager",)):
rx.button("copy", on_click=desktop.clipboard_write("hello"))
rx.button("paste", on_click=desktop.clipboard_read(State.handle_clipboard))

# Any custom #[tauri::command] you added to main.rs:
rx.button("go", on_click=desktop.invoke("my_command", {"file_path": "/tmp/x"}))

desktop.invoke() converts argument names to the camelCase spellings Tauri expects on the wire (file_pathfilePath), which is the classic silent failure when calling commands by hand; pass convert_keys=False if your command is declared with #[tauri::command(rename_all = "snake_case")].

The package ships type stubs for all of this, so your editor and type checker know what desktop.minimize() and friends are without you doing anything.

Auto-updates & signing. tauri_plugins=("updater",) plus updater_endpoints=(...)/updater_pubkey=... wires Tauri's updater into the shell and turns on signed update artifacts at bundle time — see docs/updater.md. OS code signing / macOS notarization is env-var driven at --bundle time — see docs/signing.md.

desktop.notify() asks for OS notification permission before sending (otherwise it's a silent no-op on macOS/Windows). On Linux, a bundled app (--bundle, which installs a .desktop entry) shows banners reliably; a bare dev binary may route them to the notification tray instead.

Anything the plugin doesn't cover, you can still do by hand — src-tauri/ is a standard Tauri project, so add crates, edit main.rs, whatever you need.


Limitations

  • reflex-desktop run/build package a production build — the window loads a prebuilt static frontend, so editing code doesn't live-update it. That's what reflex-desktop dev is for: the hot-reloading dev server inside the real webview.
  • dev talks to the dev server, not the embedded interpreter — the webview points at the live dev backend, so the in-process interpreter isn't exercised until you do a real run/build. Smoke-test the built app before shipping.
  • The dev shell compiles Rust once (first dev invocation) and whenever you change native config (tray, plugins). Pure Python edits never recompile.
  • No tkinter in embedded builds. The bundled runtime drops Tcl/Tk entirely to keep installers small (and to keep the Linux bundler happy), so an embedded backend that imports tkinter will crash. You're building your UI in Reflex anyway, but if some dependency reaches for it, that's why.

What's solid vs. still in progress

  • remote — done and verified end-to-end.
  • embedded — self-contained builds and installers: the binary boots a bundled interpreter in-process and serves your Reflex backend locally. Installed bundles find the bundled interpreter through relative library paths ($ORIGIN on Linux, @rpath/@executable_path on macOS, exe-adjacent DLLs on Windows), and the backend dependencies are pinned to the exact reflex version you built with. CI installs the built .deb and boots the embedded backend from the installed location on every push.
  • Installers are a reasonable size. The bundled runtime gets slimmed hard before packaging — debug symbols stripped from libpython, the interpreter deduplicated down to a single copy, Tcl/Tk and the stdlib test suite dropped, compiled wheel extensions stripped. That took the example app's .deb from 262 MB to 85 MB. The stripping only happens on Linux (it would break code signing on macOS, and Windows has no strip), so expect bigger numbers there.
  • Upgrades actually deliver your new code. An installed app keeps a per-user writable copy of the app payload; each build stamps it with a content hash, and when the installed version changes, the copy refreshes on next launch — without wiping runtime state like uploaded files.
  • Linux installers (.deb, .AppImage) build and install cleanly, including on machines that no longer ship libfuse2.
  • macOS/Windows builds compile in CI; broader real-hardware validation (and signing — see docs/signing.md) is on you for now.

Heads up for snap users: if you build from a snap-confined terminal (e.g. VS Code installed via snap), the launched binary would otherwise inherit the snap's GTK/locale paths and crash. reflex-desktop run detects this and restores the originals before launch, so it just works.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

reflex_desktop-0.1.0.tar.gz (49.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

reflex_desktop-0.1.0-py3-none-any.whl (61.7 kB view details)

Uploaded Python 3

File details

Details for the file reflex_desktop-0.1.0.tar.gz.

File metadata

  • Download URL: reflex_desktop-0.1.0.tar.gz
  • Upload date:
  • Size: 49.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for reflex_desktop-0.1.0.tar.gz
Algorithm Hash digest
SHA256 3dac911208b923efc9cab89b707aa51708556664f419109165d2a4214da955d9
MD5 5422747ba28003ae080ccc787a4419c3
BLAKE2b-256 4e043a15c21d5871de89e480a260362ee47f3898334a466fbdfc5af11def946d

See more details on using hashes here.

Provenance

The following attestation bundles were made for reflex_desktop-0.1.0.tar.gz:

Publisher: publish.yml on FarhanAliRaza/reflex-desktop

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file reflex_desktop-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: reflex_desktop-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 61.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for reflex_desktop-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 db6a7146ab628ef0b1760044914a99025297ff243b22b259431c0298342a25c9
MD5 a44ad8f37a15311b57d489e633f211ac
BLAKE2b-256 6bb2dde86fc0df860d6e3f2317afaa510b31ba133799f2e7693bb26d6c16ddad

See more details on using hashes here.

Provenance

The following attestation bundles were made for reflex_desktop-0.1.0-py3-none-any.whl:

Publisher: publish.yml on FarhanAliRaza/reflex-desktop

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page