Turbo Desktop

Turbo Native for Desktop — wrap your Rails app in a native macOS / Windows / Linux shell

Tauri 2 Turbo Drive Hotwire Native Rust Rails Gem

The Problem

Rails developers already have Hotwire Native (turbo-ios and turbo-android) to wrap their web apps in native mobile shells. But there has been nothing for desktop.

If you want to ship a macOS, Windows, or Linux app that talks to your existing Rails backend, you have been forced to either build a full Electron app from scratch or cobble together a custom WebView wrapper with no conventions.

Turbo Desktop fills this gap. It gives you a thin, native desktop shell powered by Tauri 2 that treats your Rails app as the single source of truth — the same pattern you already know from Hotwire Native, but for the desktop.

No new UI framework

Your existing Rails views, Turbo Frames, and Stimulus controllers just work.

Native when you need it

Notifications, file pickers, menus, and keyboard shortcuts via Bridge Components.

Tiny binary

Tauri uses the OS WebView — no bundled Chromium. Ship a 5-10 MB app.

Architecture

Three layers that mirror the Hotwire Native pattern:

Request Flow
Backend
Rails Server
HTML + Turbo Drive
→
Rendering
WebView
turbo-desktop.js
→
Shell
Tauri / Rust
Windows, menus, OS APIs
Path Configuration
path-configuration.json
Served by Rails or bundled locally
↓
Rust PathConfigurationStore
Regex matching, last-match wins
↓
Presentation Decision
default | modal | new_window | native | replace | none
Bridge Communication
Stimulus Controller
extends BridgeComponent
↓ send()   ↑ onReceive()
turbo-desktop.js
Tauri invoke / event listen
↓ invoke   ↑ emit
Rust Bridge Handlers
notification, menu-item, file-picker, badge, shortcut

How It Works

1

The Tauri shell loads your Rails URL

On launch, Rust reads turbo-desktop.config.json for your server_url, then navigates the main WebView window to that URL with window.location.replace().

// main.rs — startup
let app_config = window::load_config(None);
let url: url::Url = server_url.parse().expect("Invalid server URL");
main_window.eval(&format!("window.location.replace('{}')", url));
2

turbo-desktop.js is injected on every page load

The on_page_load callback fires after each navigation completes. It injects the full bridge script into the WebView, creating the global window.TurboDesktop API.

// main.rs — injection
.on_page_load(|webview, payload| {
    if let PageLoadEvent::Finished = payload.event() {
        let js = include_str!("../../src/turbo-desktop.js");
        let _ = webview.eval(js);
    }
})
3

Turbo Drive events are intercepted

The injected JS listens for turbo:before-visit. Instead of letting Turbo navigate directly, it sends a visit proposal to the Rust shell via Tauri's invoke IPC.

// turbo-desktop.js
document.addEventListener("turbo:before-visit", async (event) => {
  const url = event.detail.url;
  const response = await TurboDesktop.proposeVisit(url, "advance");

  // If the native shell handled it, cancel the Turbo visit
  if (response.action === "none") {
    event.preventDefault();
  }
});
4

Path configuration determines presentation

Rust matches the URL path against the loaded path configuration rules (regex, last match wins) and returns a Presentation variant: Default, Modal, NewWindow, Replace, Native, or None.

// navigation.rs
match properties.presentation {
    Presentation::Default  => /* navigate in current window */,
    Presentation::Modal    => /* open a new modal window */,
    Presentation::NewWindow => /* open a separate window */,
    Presentation::Replace  => /* replace, no back navigation */,
    Presentation::Native   => /* emit event for native screen */,
    Presentation::None     => /* do nothing (bridge handles it) */,
}
5

Bridge components enable web-to-native communication

Stimulus controllers on the web page extend BridgeComponent and call send() to invoke native features. The Rust side handles built-in components (notifications, menus, file pickers, badges, shortcuts) and forwards unknown ones as events for custom handling.

Path Configuration

The path configuration is a JSON file that maps URL patterns to presentation rules. This is the same concept from turbo-ios and turbo-android — the native shell consults it for every navigation to decide how to display each page.

path-configuration.json
{
  "settings": {
    "screenshots_enabled": true,
    "pull_to_refresh_enabled": false
  },
  "rules": [
    {
      "patterns": ["/"],
      "properties": { "presentation": "default" }
    },
    {
      "patterns": ["/new$", "/edit$"],
      "properties": {
        "presentation": "modal",
        "title": "Edit"
      }
    },
    {
      "patterns": ["/reports/"],
      "properties": { "presentation": "new_window" }
    },
    {
      "patterns": ["/settings"],
      "properties": { "presentation": "native" }
    }
  ]
}

Presentation Types

ValueBehavior
defaultNavigate in the current window. Turbo Drive handles it normally.
modalOpen the URL in a new modal-style window (800x600). The JS visit is cancelled.
new_windowOpen the URL in a full separate window (1200x800).
replaceReplace the current page with no back-navigation history entry.
nativeEmit a native-screen-requested event for fully native Rust UI.
noneDo nothing — the navigation is handled entirely by a Bridge Component.

Rule Matching

Patterns are regular expressions matched against the URL path. Rules are evaluated in order and the last match wins — identical to Hotwire Native. This lets you set broad defaults early and add specific overrides later.

Bridge Components

The Bridge is the desktop equivalent of Strada. It lets your web components talk to native OS features through structured message passing.

The Flow

  1. A Stimulus controller on the web page extends BridgeComponent
  2. It calls this.send("connect", { title: "Export" })
  3. turbo-desktop.js forwards this to Rust via Tauri's invoke
  4. Rust handles it (e.g., registers a native menu item)
  5. When the native side triggers (e.g., menu clicked), it emits a response back
  6. The web component receives it via onReceive(message)

Built-in Components

notification

Show native OS notifications with title and body.

menu-item

Register / unregister items in the native menu bar.

file-picker

Open native file-open and file-save dialogs.

badge

Set the dock/taskbar badge count (macOS).

shortcut

Register global keyboard shortcuts with accelerators.

filesystem

Read, write, list, mkdir and remove — inside the roots you declare, plus any path the user granted through a dialog, a drop or an OS file-open.

shell

Spawn child processes through the user's login shell (version managers included), with stdout/stderr streamed back line by line.

sudo

Run allowlisted commands elevated through the platform's own prompt: macOS password dialog, polkit on Linux, UAC on Windows.

clipboard

The system clipboard past webview limits: read what any application copied, write without a user gesture.

autostart

Launch at login, meant for a visible settings toggle — recorded with the OS, never enabled silently.

file-open

Files the OS asked the app to open — double-click on an associated type, "Open With…", dock drop — queued through launch and drained by the page.

Unknown component names are automatically forwarded as bridge-message events, making the system fully extensible without modifying Rust code.

JavaScript: Stimulus Bridge

app/javascript/controllers/notification_bridge_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends TurboDesktop.stimulusBridge(Controller, "notification") {
  connect() {
    super.connect()
    this.sendBridge("connect", { title: "My App" })
  }

  notify(event) {
    this.sendBridge("connect", {
      title: "New Message",
      body:  event.target.dataset.body
    })
  }

  receiveBridge(message) {
    console.log("Native says:", message)
  }
}

Rust: Handling Bridge Messages

src-tauri/src/bridge.rs
#[tauri::command]
pub async fn handle_bridge_message(
    app: tauri::AppHandle,
    message: BridgeMessage,
) -> Result<serde_json::Value, String> {
    match message.component.as_str() {
        "notification" => handle_notification(&app, &message).await,
        "menu-item"   => handle_menu_item(&app, &message).await,
        "file-picker" => handle_file_picker(&app, &message).await,
        "badge"       => handle_badge(&app, &message).await,
        "shortcut"    => handle_shortcut(&app, &message).await,
        _ => {
            // Forward unknown components as events
            app.emit("bridge-message", &message)?;
            Ok(serde_json::json!({ "status": "forwarded" }))
        }
    }
}

Rails: View Helpers

app/views/posts/show.html.erb
<%# Attach bridge data attributes to any element %>
<%= tag.button "Export PDF",
    **turbo_desktop_bridge("menu-item",
      title: "Export PDF",
      shortcut: "Cmd+E"
    ) %>

Rails Gem

The turbo_desktop-rails gem gives your Rails app awareness of the desktop shell, exactly like turbo-rails does for Turbo Native mobile apps.

Detection

The gem detects desktop app requests by inspecting the User-Agent header. The Tauri shell sends a UA string like Turbo Desktop/0.2.5 (macOS; aarch64).

View Helpers

HelperDescription
turbo_desktop_app? Returns true if the request comes from a Turbo Desktop shell.
turbo_desktop_platform Returns "macos", "windows", "linux", or nil.
turbo_desktop_arch Returns "aarch64", "x86_64", or nil.
turbo_desktop_only { } Renders the block only when inside the desktop app.
turbo_web_only { } Renders the block only for regular web (non-desktop) users.
turbo_desktop_bridge(component, **opts) Outputs data-turbo-desktop-bridge attributes for bridge components.

Usage Examples

app/views/layouts/application.html.erb
<%# Hide the web navbar inside the desktop app %>
<% turbo_web_only do %>
  <nav class="web-navbar">...</nav>
<% end %>

<%# Show a native-only sidebar button %>
<% turbo_desktop_only do %>
  <button data-controller="sidebar">Toggle Sidebar</button>
<% end %>

<%# Platform-specific styles %>
<% if turbo_desktop_platform == "macos" %>
  <body class="desktop macos">
<% end %>

Quick Start

1

Clone and install dependencies

git clone https://github.com/aguspe/turbo_desktop.git
cd turbo_desktop
cargo install tauri-cli
npm install
2

Configure your Rails server URL

Edit turbo-desktop.config.json to point at your running Rails app:

{
  "server_url": "http://localhost:3000",
  "app_name": "My App",
  "path_configuration_url": "http://localhost:3000/turbo-desktop/path-configuration.json"
}
3

Add the Rails gem

# Gemfile
gem "turbo_desktop-rails"
bundle install
rails generate turbo_desktop:install
4

Mount the path configuration endpoint

The gem ships an engine that serves the path configuration JSON:

# config/routes.rb
mount TurboDesktop::Engine, at: "/turbo-desktop"
5

Run the desktop app

# Start your Rails server in one terminal
bin/rails server

# Start the Tauri desktop app in another
cargo tauri dev

Your Rails app now runs inside a native desktop window with full Turbo Drive integration, path-configuration-based routing, and bridge component support.

How It Relates to turbo-ios and turbo-android

Turbo Desktop is the third leg of the Hotwire Native story. Same patterns, different platform.

Concept turbo-ios turbo-android Turbo Desktop
Shell runtime WKWebView (Swift) WebView (Kotlin) Tauri WebView (Rust)
Path configuration JSON, last-match-wins JSON, last-match-wins JSON, last-match-wins
Bridge / native comms Strada Strada BridgeComponent
Presentation types push, modal, replace push, modal, replace default, modal, new_window, replace, native, none
JS injection WKUserScript evaluateJavascript on_page_load + eval
Rails gem turbo-rails (turbo_native_app?) turbo-rails (turbo_native_app?) turbo_desktop-rails (turbo_desktop_app?)
Binary size Uses system WebKit ~20 MB (Chrome WebView) ~5-10 MB (OS WebView)
Platforms iOS, iPadOS Android macOS, Windows, Linux

The key insight is that every concept transfers directly: if you already know how to structure a Hotwire Native mobile app with path configuration and Strada bridge components, you already know how to use Turbo Desktop. The only difference is that the native shell is written in Rust (via Tauri) instead of Swift or Kotlin.