Handbook

Turbo Native, for the desktop

Your Rails app is already the source of truth. Turbo Desktop wraps it in a thin native shell built on Tauri 2 — the same path configuration, bridge components, and progressive enhancement you know from Hotwire Native, now on macOS, Windows, and Linux.

Three layers
01
Rails server
HTML over the wire, Turbo Drive, Stimulus
02
WebView
turbo-desktop.js intercepts every visit
03
Tauri shell
Rust handles windows, menus, OS APIs

Why it exists

Rails developers have had turbo-ios and turbo-android for years. Desktop was the gap. Turbo Desktop fills it without asking you to learn a new UI framework: your existing views, frames, and controllers keep working, and you reach for native only where it pays off.

No new UI framework
Rails views, Turbo Frames, and Stimulus controllers work unchanged.
5–10 MB binaries
Tauri uses the OS WebView. No bundled Chromium to ship.
Native where it counts
Notifications, menus, file pickers, shortcuts, and badges via the Bridge.
Server-driven routing
Path configuration is JSON served by Rails — change it without a rebuild.

Quick start

Four steps from an existing Rails app to a running desktop shell. You need Rails 7+, Ruby 3.1+, and a Rust toolchain.

1

Scaffold the shell

npx turbo-desktop new myapp
cd myapp && npm install
cargo install tauri-cli
2

Point it at your Rails server

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

Leave path_configuration_url out and it defaults to {server_url}/turbo-desktop/path-configuration.json.

3

Teach Rails about the shell

# Gemfile
gem "turbo_desktop-rails"

# bundle install
# rails generate turbo_desktop:install

# config/routes.rb
get "/turbo-desktop/path-configuration",
    to: "turbo_desktop#path_configuration"
<%# app/views/layouts/application.html.erb %>
<% turbo_desktop_only do %>
  <div class="titlebar-drag-region"></div>
<% end %>

<% turbo_web_only do %>
  <nav class="web-navbar">...</nav>
<% end %>
// app/javascript/application.js
if (window.TurboDesktop?.isNative) {
  document.documentElement.dataset.shell = "desktop"
}
4

Run both processes

bin/rails server      # your app
cargo tauri dev       # the shell

The window opens on your server_url. Every Turbo visit from here on is proposed to the shell before it happens.

Path configuration

A JSON document, served by Rails, that maps URL patterns to presentation rules. Same concept and the same last-match-wins semantics as turbo-ios and turbo-android — the shell stays dumb, your server decides how screens appear.

{
  "settings": { "screenshots_enabled": true },
  "rules": [
    { "patterns": ["/"],               "properties": { "presentation": "default" } },
    { "patterns": ["/new$", "/edit$"], "properties": { "presentation": "modal", "title": "Edit" } },
    { "patterns": ["/reports/"],       "properties": { "presentation": "new_window" } },
    { "patterns": ["/settings"],       "properties": { "presentation": "native" } }
  ]
}

See each presentation

Pick a rule to watch what the shell does with the window.

My App
Tasks
Ship path configuration docs
Add badge bridge component
Sign the macOS build
/tasks
Edit — 800×600
Save
Reports — 1200×800
native-screen-requested
Rust takes over. No web content is loaded — your Tauri code renders this screen.
modal
Presentation
Behavior

Bridge components

The Bridge is the desktop equivalent of Strada: structured messages between a Stimulus controller in the WebView and a handler in Rust. Write the controller you would write for mobile, name the component, and the shell routes it.

notification
Native OS notifications
menu-item
Items in the native menu bar
file-picker
Native open and save dialogs
badge
Dock or taskbar badge count
shortcut
Global keyboard shortcuts

A notification, end to end

// app/javascript/controllers/notify_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)
  }
}
<%# Attach bridge data attributes to any element %>
<%= tag.button "Export PDF",
      **turbo_desktop_bridge("menu-item",
        title: "Export PDF",
        shortcut: "Cmd+E") %>
// npm install @turbo-desktop/bridge
import { stimulusBridge, isTurboDesktop } from "@turbo-desktop/bridge"

if (isTurboDesktop()) {
  const info = await TurboDesktop.getWindowInfo()
  console.log(`Running on ${info.platform}`)
}

The turbo-desktop.js IIFE is injected into every page by the shell. The npm package only adds types and ESM exports over the same globals — nothing is bundled twice.

Dev Inspector

In development, press Cmd/Ctrl Shift D to open an in-app overlay over your running app.

Components
Every available bridge component, which are active on this page, and a copy-pasteable Rails + Stimulus snippet.
Messages
A live log of web↔native bridge traffic as it happens.
Navigation
The path-configuration presentation currently applied to this URL.
Shell
Platform, architecture, shell version, and the configured server URL.
# config/initializers/turbo_desktop.rb
config.inspector_enabled = Rails.env.development?

# app/views/layouts/application.html.erb, in <head>
# <%= turbo_desktop_inspector_meta_tag %>

Need it against a build you cannot rebuild? Flip it on at runtime with localStorage.setItem("td:inspector", "1").

Rails gem

The turbo_desktop-rails gem gives your app shell awareness. Detection reads the User-Agent, e.g. Turbo Desktop/0.0.1 (macOS; aarch64).

Helper
Returns
turbo_desktop_app?
true when the request comes from a Turbo Desktop shell
turbo_desktop_platform
"macos", "windows", "linux", or nil
turbo_desktop_arch
"aarch64", "x86_64", or nil
turbo_desktop_only { }
Renders the block only inside the desktop app
turbo_web_only { }
Renders the block only for regular web browsers
turbo_desktop_bridge(component, **opts)
Outputs the bridge data attributes for an element
# config/initializers/turbo_desktop.rb
TurboDesktop.configure do |config|
  config.path_configuration = {
    settings: { screenshots_enabled: true },
    rules: [
      { patterns: ["/"],                 properties: { presentation: "default" } },
      { patterns: ["/new$", "/edit$"],   properties: { presentation: "modal" } },
      { patterns: ["/settings"],         properties: { presentation: "native" } }
    ]
  }
end

JavaScript API

Everything hangs off window.TurboDesktop, injected by the shell on every page load. Outside the shell it is undefined — guard with isTurboDesktop().

TurboDesktop
core
proposeVisit(url, action?)
Ask the shell how to present a URL. Resolves to { action, presentation }.
setTitle(title)
Update the native window title bar.
sendBridgeMessage(component, event, data?)
Send a bridge message; resolves with the native response or null.
getWindowInfo()
Size, position, scale factor, fullscreen state, platform, and arch.
closeModal(label)
Close a modal window by its label.
toggleDevTools()
Toggle developer tools and the bridge inspector.
TurboDesktop.shell
child processes
spawn(id, command, args?, options?)
Spawn a tracked child process with streaming output.
kill(id) / status(id) / list()
Manage and inspect tracked processes.
onOutput(id, cb) / offOutput(id)
Subscribe to stdout, stderr, and exit events.
TurboDesktop.fs
file system, supports ~/ expansion
read(path, encoding?)
Read a file as utf8 or base64.
write(path, content, { append })
Write or append to a file.
exists(path) / list(path)
Check a path or list a directory.
mkdir(path) / remove(path, { recursive })
Create or delete directories and files.
TurboDesktop.sudo
privileged commands (macOS)
execute(command)
Run with admin privileges; triggers the OS password dialog.
spawn(id, command)
Run privileged with streaming output.
TurboDesktop.updater
app updates
check()
Resolves to { status, version, date, body }.
downloadAndInstall()
Download and install the available update. May restart the app.

CLI

Scaffolding for a new shell, no clone required.

npx turbo-desktop new myapp     # scaffold a shell
npx turbo-desktop dev            # run the shell against your server
npx turbo-desktop build          # produce a signed platform bundle

How it compares

Same mental model as the mobile shells, different runtime underneath.

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
Native comms
Strada
Strada
BridgeComponent
JS injection
WKUserScript
evaluateJavascript
on_page_load + eval
Rails gem
turbo-rails
turbo-rails
turbo_desktop-rails
Binary size
System WebKit
~20 MB
~5–10 MB
Platforms
iOS, iPadOS
Android
macOS, Windows, Linux