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.

The Task Manager example on macOS
macOS
The Task Manager example on Windows 11
Windows
The Task Manager example on Ubuntu
Linux

One Rails app, three shells. The badge in the corner of each window is what Rails was told about the shell it is in.

No new UI framework
Rails views, Turbo Frames, and Stimulus controllers work unchanged.
Small installers
Tauri uses the OS WebView, so there is no Chromium to ship: 4 MB on Windows, 9 MB as a .deb, 14 MB as a universal .dmg.
Native where it counts
Notifications, menus, dialogs, clipboard, shell & sudo, drag & drop, file associations, launch at login — via the Bridge.
Server-driven routing
Path configuration is JSON served by Rails — change it without a rebuild.
Opens like an app
The shell starts your Rails server on launch and stops it on quit. One icon, not two terminals.
Files, with consent
The filesystem is scoped to declared roots — but a path the user picks, drops, or opens with the app is consent, and just works.

Quick start

Four steps to a running desktop shell, for a new Rails app or one you already have. You need Rails 7+, Ruby 3.2+, Node 18+, and a Rust toolchain.

1

Scaffold the shell

cargo install tauri-cli

npx turbo-desktop new myapp      # a new Rails app, with its shell
npx turbo-desktop init .         # or: add a shell to the app you are in

Both put the shell in desktop/, next to your Rails code. new also adds the gem, runs its installer, and mounts the engine, so steps 2 and 3 are already done for you.

2

Point it at your Rails server

desktop/turbo-desktop.config.json
{
  "server_url": "http://localhost:3000",
  "app_name": "My App",
  "server": { "command": "bin/rails server", "directory": ".." }
}

server_url is also the app's trust boundary: the bridge answers only pages served from that exact origin. Every key is described under Configuration.

3

Teach Rails about the shell

# Gemfile
gem "turbo_desktop-rails", "~> 0.2"

# bundle install
# rails generate turbo_desktop:install

# config/routes.rb — the installer adds this line
mount TurboDesktop::Engine => "/turbo-desktop"
<%# 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 it

cd desktop
npx turbo-desktop dev

The scaffolded config carries "server": { "command": "bin/rails server" }, so opening the app starts Rails too — and quitting stops it. A server you started yourself is left alone. The first run compiles the shell, which takes a few minutes; later runs start in seconds. Every Turbo visit from here on is proposed to the shell before it happens.

Configuration

One file, desktop/turbo-desktop.config.json, describes the app to the shell. Everything in it is closed by default and opened by naming what you need.

Key
What it does
server_url
Where your Rails app answers. Also the only origin the bridge trusts.
app_name
The window title and the name in the menu bar.
path_configuration_url
Optional. Defaults to {server_url}/turbo-desktop/path-configuration.json.
user_agent
Optional, and best left out: the shell then reports its own version and platform. If you set it, it replaces the whole string, so keep the Turbo Desktop token the gem detects on.
window
width, height, min_width, min_height, resizable.
server
command and directory: start your Rails server when the app opens.
navigation
internal_hosts and refresh_after_seconds. See Links & navigation.
filesystem
allowed_roots. See Security.
sudo
enabled, allowed_commands, confirm. See Security.

Starting the server with the app

With a server block, opening the app starts Rails too, so it behaves like an application rather than a viewer for something you have to run first.

{
  "server": {
    "command": "bin/rails server",
    "directory": ".."
  }
}

command runs through your shell on macOS and Linux, started the way a terminal starts it, so rbenv, asdf, nvm, or mise are set up as they are there — including when they live in ~/.zshrc. On Windows it runs through cmd: use ruby bin\rails server. directory is relative to the config file. If something already answers on server_url, the app uses it and leaves it running on quit; a server the app started is stopped when the app quits.

Where the file is read from

When
Where
In development
The directory you run in, or one level up. With no file, the app starts on defaults.
In a packaged app
Only from inside the bundle. The app refuses to start without it.

The file names the origin the bridge trusts, the filesystem roots, and the sudo allowlist. Reading it from the working directory of a shipped app would let anyone who can write a file beside it grant themselves all three. A config that exists but does not parse is always fatal.

What the user can change

Only the window size. The shell remembers the size the user left the app at and reapplies it on the next launch. That preferences file can hold nothing but geometry; position is not remembered, because a remembered position becomes an off-screen window as soon as the displays are rearranged.

Path configuration

A JSON document, served by Rails, that maps URL patterns to presentation rules. A rule may also set title, width, and height for the window it opens. 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", "width": 640, "height": 480 } },
    { "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

When the server cannot be reached

The shell starts with rules rather than none. It uses the last copy the server gave, cached on the user's machine; failing that, the copy bundled with the app as desktop/path-configuration.json; failing both, everything takes the default presentation. The server's copy replaces whichever was loaded as soon as it arrives, and is asked for again whenever the server comes back — which, for an app that starts its own server, is a moment after launch. Keys the shell does not use are ignored, so one endpoint can serve the mobile shells too.

Windows & modals

A rule with modal or new_window opens the URL in its own window: 800×600 and 1200×800 unless the rule says otherwise. These windows carry everything the main one does — the user agent, off-origin links going to the browser, and a working bridge.

In the page
Meaning
TurboDesktop.isModal
True inside a modal window.
TurboDesktop.windowLabel
This window's label, e.g. "modal-9b8b948".
TurboDesktop.closeModal()
Close the window the page is in. Pass a label to close another.
TurboDesktop.recede()
Close, and go back underneath.
TurboDesktop.refresh()
Close, and reload underneath — after a form submits.
TurboDesktop.resume()
Close, and leave underneath alone.

The three ways to dismiss are named after Hotwire Native's and mean the same. refresh() goes through Turbo when it is present, so scroll position and morphing survive. A modal is attached to the main window: it travels and closes with it, but the main window stays interactive. Windows opened with new_window stand alone.

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; a pick grants the path
filesystem
Read and write inside declared roots and granted paths
shell
Child processes through the login shell, output streamed
sudo
Allowlisted elevation: macOS dialog, polkit, UAC
clipboard
Read and write the system clipboard, no gesture needed
autostart
Launch at login, behind a visible toggle
file-open
Files opened with the app, even from a cold launch
deep-link
The link the app was asked to open, kept until the page is there
dialog
The system's own dialog; what data-turbo-confirm uses
badge
Badge count on the icon, on macOS and Linux
shortcut
Global keyboard shortcuts; on Linux, under X11
updater
Check for and install app updates, once an update server is named
devtools
The webview's developer tools, in development builds

A component that cannot do its job on the machine it is on answers status: "unavailable" with the reason, rather than failing. On Windows and Linux a link followed or a file opened while the app is running reaches the window that is already open.

Dropped files and OS-opened files also surface as DOM events — turbo-desktop:drop and turbo-desktop:file-open — so a Stimulus data-action is all a page needs.

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: "CmdOrCtrl+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. It looks the shell up when it is used, so the same controller runs in a plain browser, where sendBridge resolves with null.

Files from the desktop

A browser never tells a page where a file lives. The shell does, and treats the way the file arrived as consent to read it.

How it arrives
What the page gets
Dragged onto a window
turbo-desktop:drop with paths and position; drag-enter and drag-leave around it.
Opened with the app
turbo-desktop:file-open with paths — double-click, “Open With”, or a drop on the dock icon.
Picked in a dialog
The file-picker component's response.
// data-action="turbo-desktop:drop@document->importer#filesDropped
//              turbo-desktop:file-open@document->importer#filesDropped"
async filesDropped(event) {
  for (const path of event.detail.paths) {
    const { content } = await TurboDesktop.fs.read(path)
  }
}

Each of the three grants that path to the filesystem bridge for the session: one file for a file, everything inside for a folder. So “Save As… → Desktop” works without declaring ~/Desktop as a root. A file opened by double-click while the app was closed is queued until your page is up.

Owning a file type

desktop/src-tauri/tauri.conf.json
{
  "bundle": {
    "fileAssociations": [
      { "ext": ["csv"], "description": "Data import", "role": "Viewer" }
    ]
  }
}

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 %>

The installer writes the first line for you. The meta tag is yours to add. Need the inspector against a build you cannot rebuild? Flip it on at runtime with localStorage.setItem("td:inspector", "1").

Requires turbo_desktop-rails 0.2.3 or later. Earlier versions answered 422 for the inspector's scripts in any app with forgery protection on, which is every app that uses load_defaults, so the overlay never opened. bundle update turbo_desktop-rails fixes it.

Connection loss

The shell watches your server and reports failures in Hotwire Native's vocabulary: network_failure, timeout_failure, http_failure, and page_load_failure mean here what they mean on mobile.

Unreachable at launch
The window opens on a bundled error page, and moves to your app as soon as the server answers.
Lost while running
A banner appears. The shell keeps probing and clears it on its own.
Your 404s and 422s
Left alone: those are your pages to serve. Only 5xx and failures to reach the server are reported.
Your error page
desktop/src/error.html is yours. It ships inside the app, so it must work with no network.
document.addEventListener("turbo-desktop:visit-error", (event) => {
  const { error, status, retry } = event.detail
  event.preventDefault()                 // suppress the shell's banner
  showMyOwnBanner(error, status, retry)  // retry() attempts the visit again
})

To take over entirely rather than case by case, add <meta name="turbo-desktop-error-handling" content="manual">. There is also turbo-desktop:connection, with { online, error }, for reacting to the connection itself. The shell notices all this rather than the page, because the browser's offline event fires when this machine loses its network, not when your server goes down.

Security

The bridge reaches the shell, the filesystem, and administrator privileges. It is closed by default and opened deliberately.

Boundary
Rule
Origin
Every bridge message is checked against server_url: scheme, host, and port. A page from anywhere else — an off-site link, a redirect, an embedded frame — is refused.
Filesystem
Reads and writes stay under the roots you declare, plus paths the user picked, dropped, or opened. With no configuration, only the app's own data directory.
Protected paths
.ssh, .aws, .gnupg, and Rails master.key and credentials.yml.enc are refused even inside a root, and even when picked.
Path tricks
Paths are resolved before the check, so .. and symlinks cannot walk out of a root.
Sudo
Off unless enabled, and then only for the commands you name. Anything with shell metacharacters is refused, so an allowed prefix cannot be extended into a second command.
Launch at login
Not a config key, on purpose. The page has to ask, behind a toggle the user can see.
{
  "filesystem": {
    "allowed_roots": ["~/Projects"]
  },
  "sudo": {
    "enabled": true,
    "allowed_commands": ["softwareupdate", "brew install"],
    "confirm": true
  }
}

Before the system's own elevation prompt, which does not say what is about to run, the app shows the exact command and asks. Set confirm to false only if your app already asks. Elevation uses each platform's mechanism: the macOS password dialog, polkit on Linux, UAC on Windows — where an elevated command's output arrives whole when it finishes rather than line by line.

Rails gem

The turbo_desktop-rails gem gives your app shell awareness. Detection reads the User-Agent, e.g. Turbo Desktop/0.2.4 (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
turbo_desktop_inspector_meta_tag
Points the shell at the Dev Inspector. Renders nothing unless it is enabled

A whole template for the desktop

Requests from the shell carry a Rails variant, so a view can be written for it instead of branching inside a shared one. Rails falls back to the plain template wherever no variant exists.

app/views/orders/show.html.erb            # everyone
app/views/orders/show.html+desktop.erb    # the desktop app
app/views/layouts/application.html+desktop.erb

Configuration

Setting
Default
config.path_configuration
Everything takes the default presentation
config.inspector_enabled
false; the installer sets it to development only
config.variant
:desktop. Rename it, or set nil to leave variants alone
config.user_agent_pattern
/Turbo Desktop/
config.inspector_mount_path
"/turbo-desktop". Change it if you mount the engine elsewhere

Requires Ruby 3.2 or later and Rails 7.0 or later. Pin the gem to the minor version, as the CLI does: gem "turbo_desktop-rails", "~> 0.2".

# 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.
version / platform / isNative
The shell's version; "macos", "windows", or "linux"; and true inside the shell.
errors / reportVisitError(error, options?)
The failure vocabulary, and a way to report a failure your app detected itself.
isModal / windowLabel
Whether this page is in a modal, and the window's label.
closeModal(label?)
Close a modal by its label, or the one the page is in.
recede() / refresh() / resume()
Dismiss a modal and go back, reload, or do nothing underneath.
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, allowlisted
execute(command)
Run elevated through the platform's own prompt: macOS password dialog, polkit on Linux, UAC on Windows.
spawn(id, command)
Run privileged with streaming output.
TurboDesktop.clipboard
system clipboard
readText()
What any application copied, or null when the clipboard holds no text.
writeText(text)
Set the clipboard — no user gesture or focus required.
TurboDesktop.autostart
launch at login
enable() / disable()
Record the choice with the OS: Launch Agent, registry Run key, or XDG entry.
isEnabled()
The current OS-side state, for a settings toggle.
TurboDesktop.dragDrop
files from the desktop
onDrop(cb) / onEnter(cb) / onLeave(cb)
Files dragged onto any window, with real paths and the drop position. Also dispatched as DOM events.
DOM events
dispatched on document
turbo-desktop:drop
Files dropped on a window: { paths, position }.
turbo-desktop:drag-enter / drag-leave
Around a drop, for hover styling.
turbo-desktop:file-open
Files opened with the app: { paths }.
turbo-desktop:focus
The window came back: { awaySeconds, refreshing }. Cancel to veto a refresh.
turbo-desktop:visit-error
A visit failed: { error, status, retry }. Cancel to suppress the banner.
turbo-desktop:connection
The server went away or came back: { online, error }.
TurboDesktop.updater
app updates
check()
Resolves to { status, version, date, body }.
downloadAndInstall()
Download and install the available update. May restart the app.

CLI

Scaffolding and running, no clone required.

Command
What it does
new <name> [--icon file]
A new Rails app with the gem installed, the engine mounted, and a shell in desktop/.
init [path] [--icon file]
Adds desktop/ to a Rails app you already have.
dev
Runs the shell in development. Works from the project root or from desktop/.
build [--target arch]
Builds installers for this machine's platform. Unsigned unless you set up signing.
npx turbo-desktop new myapp --icon ./logo.png
npx turbo-desktop build --target universal-apple-darwin

Each scaffolded app gets its own name, bundle identifier, and deep-link scheme, so two apps built on the shell do not share preferences or answer each other's links. New apps are pinned to turbo_desktop-rails "~> 0.2".

Shipping

A Turbo Desktop app is a Tauri app, so shipping means native installers per OS.

Your icon

cd desktop
cargo tauri icon path/to/your-icon.png

One square PNG, 1024×1024, transparent background. The generator writes every size and format, and tauri.conf.json already points at them.

Installers

npx turbo-desktop build      # this machine's platform

git tag v1.0.0 && git push origin v1.0.0   # every platform, in CI

Tauri cannot cross-compile, so the release workflow builds macOS, Windows, and Linux on their own runners and attaches the installers to a draft GitHub Release. Copy it into your app's .github/workflows/. Set server_url to your production URL before building.

Signing

Builds are unsigned by default. They run, but macOS Gatekeeper and Windows SmartScreen warn about them. Signing is also what makes the bundled config tamper-resistant. The distribution guide covers signing, notarization, and auto-update.

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
initialization script
Rails gem
turbo-rails
turbo-rails
turbo_desktop-rails
Installer size
System WebKit
~20 MB
4–14 MB
Platforms
iOS, iPadOS
Android
macOS, Windows, Linux