Turbo Native for Desktop — wrap your Rails app in a native macOS / Windows / Linux shell
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.
Your existing Rails views, Turbo Frames, and Stimulus controllers just work.
Notifications, file pickers, menus, and keyboard shortcuts via Bridge Components.
Tauri uses the OS WebView — no bundled Chromium. Ship a 5-10 MB app.
Three layers that mirror the Hotwire Native pattern:
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));
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);
}
})
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();
}
});
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) */,
}
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.
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.
{
"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" }
}
]
}
| Value | Behavior |
|---|---|
default | Navigate in the current window. Turbo Drive handles it normally. |
modal | Open the URL in a new modal-style window (800x600). The JS visit is cancelled. |
new_window | Open the URL in a full separate window (1200x800). |
replace | Replace the current page with no back-navigation history entry. |
native | Emit a native-screen-requested event for fully native Rust UI. |
none | Do nothing — the navigation is handled entirely by a Bridge Component. |
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.
The Bridge is the desktop equivalent of Strada. It lets your web components talk to native OS features through structured message passing.
BridgeComponentthis.send("connect", { title: "Export" })turbo-desktop.js forwards this to Rust via Tauri's invokeonReceive(message)notificationShow native OS notifications with title and body.
menu-itemRegister / unregister items in the native menu bar.
file-pickerOpen native file-open and file-save dialogs.
badgeSet the dock/taskbar badge count (macOS).
shortcutRegister global keyboard shortcuts with accelerators.
filesystemRead, 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.
shellSpawn child processes through the user's login shell (version managers included), with stdout/stderr streamed back line by line.
sudoRun allowlisted commands elevated through the platform's own prompt: macOS password dialog, polkit on Linux, UAC on Windows.
clipboardThe system clipboard past webview limits: read what any application copied, write without a user gesture.
autostartLaunch at login, meant for a visible settings toggle — recorded with the OS, never enabled silently.
file-openFiles 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.
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)
}
}
#[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" }))
}
}
}
<%# Attach bridge data attributes to any element %>
<%= tag.button "Export PDF",
**turbo_desktop_bridge("menu-item",
title: "Export PDF",
shortcut: "Cmd+E"
) %>
The turbo_desktop-rails gem gives your Rails app awareness of the desktop shell,
exactly like turbo-rails does for Turbo Native mobile apps.
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).
| Helper | Description |
|---|---|
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. |
<%# 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 %>
git clone https://github.com/aguspe/turbo_desktop.git
cd turbo_desktop
cargo install tauri-cli
npm install
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"
}
# Gemfile
gem "turbo_desktop-rails"
bundle install
rails generate turbo_desktop:install
The gem ships an engine that serves the path configuration JSON:
# config/routes.rb
mount TurboDesktop::Engine, at: "/turbo-desktop"
# 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.
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.