Turbo Native for Desktop — wrap your Rails app in a native macOS / Windows / Linux shell
🌐 Official site: turbo-desktop.dev
Website • Features • Architecture • Quick Start • Path Config • Bridge • Rails Gem • Comparison • Docs
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.
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.
Here's what a Rails app looks like running inside Turbo Desktop (from the example Task Manager app):
- 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
- Path configuration — JSON-based routing rules (same concept as turbo-ios / turbo-android)
- Bridge components — web-to-native communication via Stimulus controllers
- Rails gem —
turbo_desktop-railsgives your Rails app desktop shell awareness - CLI scaffolding —
npx turbo-desktop new myappto get started fast
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ Rails Server │ ──▶ │ WebView │ ──▶ │ Tauri / Rust │
│ HTML + Turbo │ │ turbo- │ │ Windows, menus, │
│ Drive │ │ desktop.js │ │ OS APIs │
└──────────────┘ └──────────────┘ └──────────────────┘
Three layers that mirror the Hotwire Native pattern:
- Rails Server — your existing app serves HTML with Turbo Drive
- WebView —
turbo-desktop.jsintercepts Turbo visits and bridges to native - Tauri Shell — Rust handles window management, path config routing, and OS APIs
git clone https://github.com/aguspe/turbo_desktop.git
cd turbo_desktop
cargo install tauri-cli
npm installEdit turbo-desktop.config.json:
{
"server_url": "http://localhost:3000",
"app_name": "My App",
"path_configuration_url": "http://localhost:3000/turbo-desktop/path-configuration.json"
}
path_configuration_urlis optional — it defaults to{server_url}/turbo-desktop/path-configuration.json.
The server is the source of truth, but it is not always reachable, so the shell starts with rules rather than none — the same layering Hotwire Native uses:
- The last copy the server gave, cached in the user's config directory.
- The copy bundled with the app (
path-configuration.jsonbeside your app config), for a first run before the server has ever answered. - Failing both, everything routes to the default presentation.
The server's copy replaces whichever was loaded as soon as it arrives, and is cached for next time. A cold start with your server down therefore keeps the routing you had, instead of silently sending every route to the default and making modals appear to stop working.
Keys the desktop shell does not use — Hotwire Native's settings, say — are
ignored, so one endpoint can serve every shell.
server_url is also the app's trust boundary: the bridge only answers calls from
pages on that exact origin (scheme, host and port). A page from anywhere else —
an off-site link, a redirect, an embedded frame — gets a refusal instead of
native access. See Bridge security.
The window is created from this file at startup, so app_name, user_agent and
the window block all take effect. user_agent replaces the webview's own
string rather than extending it, so keep the Turbo Desktop token — the Rails
gem's turbo_desktop_app? and the turbo_desktop_only helper match on it.
This file carries the app's trust boundary, so where it is read from matters:
- In development, it is read from the project you run in — the working
directory or one level up, so both
turbo-desktop devandcargo tauri devfind it. If there is none, the app starts on defaults. - In a packaged app, it is read only from inside the bundle
(
Contents/Resourceson macOS), never from the working directory, and the app refuses to start if it is missing. It ships there viabundle.resourcesintauri.conf.json, andturbo-desktop buildincludes it automatically.
A config that exists but does not parse is always fatal, in both cases.
The window size the user leaves the app at is remembered separately, in their own
config directory (~/Library/Application Support/<bundle id>/preferences.json on
macOS), and reapplied on the next launch:
{ "window": { "width": 1440, "height": 900 } }That file is the only user-writable input the app reads, and it can hold nothing
but geometry. Adding a sudo or server_url key to it has no effect — the type
it deserializes into has nowhere to put them. Sizes that would produce an
unusable window (below the configured minimum, negative, not a number) fall back
to the configured defaults, and a corrupt file is ignored rather than fatal,
since losing a remembered window size should not stop the app from starting.
Only size is remembered, not position: a remembered position becomes an off-screen window as soon as the display arrangement changes.
The reason for the split is that a writable config is a way around every other
protection here: server_url decides which origin the bridge trusts, and the
filesystem roots and sudo allowlist sit in the same file. Reading it from the
working directory of a shipped app would let anyone who can write a file next to
it grant themselves shell and sudo access. Note that the bundle only becomes
tamper-resistant once you sign the app — see
Signing & notarization.
If the server is not reachable when the app launches, it opens a bundled page that waits and redirects once your server answers.
With a server block, opening the app starts your Rails server too, so the app
behaves like an application rather than a viewer for something you have to run
first:
{
"server": {
"command": "bin/rails server",
"directory": ".."
}
}commandruns through your login shell on macOS and Linux, so a Ruby version manager (rbenv, asdf, mise) is set up the same way it would be in a terminal. On Windows it runs throughcmd, and the Unixbin/railsbinstub does not apply — setcommandtoruby bin\rails serverthere.directoryis resolved relative to the config file and defaults to..— the project root, one level abovedesktop/.
If something is already listening on server_url — a server you started by
hand, say — the app leaves it alone: it neither starts a second one nor kills
yours on quit. A server the app did start is stopped when the app quits.
Omit command (or the whole block) to manage the server yourself.
# 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"cargo tauri devWith a server.command configured, this also starts your Rails server; without
one, run bin/rails server in another terminal first.
The path configuration is a JSON file that maps URL patterns to presentation rules — the same concept from turbo-ios and turbo-android.
{
"settings": {
"screenshots_enabled": true,
"pull_to_refresh_enabled": false
},
"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" }
}
]
}| Presentation | Behavior |
|---|---|
default |
Navigate in the current window (Turbo Drive handles it) |
modal |
Open the URL in a modal-style window (800×600 unless the rule sets width/height) |
new_window |
Open the URL in a full separate window (1200×800) |
replace |
Replace the current page with no back-navigation |
native |
Emit a native-screen-requested event for Rust UI |
none |
Do nothing — handled entirely by a Bridge Component |
The Bridge is the desktop equivalent of Strada. It lets your web components talk to native OS features through structured message passing.
| Component | Description |
|---|---|
notification |
Show native OS notifications |
menu-item |
Register items in the native menu bar |
file-picker |
Open native file-open/save dialogs |
badge |
Set the dock/taskbar badge count |
shortcut |
Register global keyboard shortcuts |
A rule with presentation: "modal" or "new_window" opens the URL in its own
window, sized by the rule's width and height. These carry everything the
main window does — the user agent your Rails app detects on, off-origin links
going to the browser, and a working bridge.
A page in one of these windows knows where it is and can dismiss itself:
if (TurboDesktop.isModal) {
TurboDesktop.closeModal() // no argument: closes the window it is in
}
TurboDesktop.windowLabel // e.g. "modal-9b8b948"Closing a modal usually means something for the screen underneath. The three outcomes are named after Hotwire Native's, and mean the same things:
TurboDesktop.recede() // close, and go back underneath
TurboDesktop.refresh() // close, and reload underneath — after a form submits
TurboDesktop.resume() // close, and leave underneath alonerefresh() goes through Turbo when it is present, so scroll position and
morphing are preserved, and falls back to a reload when it is not.
A modal is attached to the main window, so it travels with it and closes with
it rather than being left behind. That is ownership, not modality: the main
window stays interactive. A blocking sheet needs AppKit APIs Tauri does not
expose. Secondary windows (new_window) are meant to stand alone and are not
attached.
A link from outside — an email, a calendar entry, another app — can open your app at a particular page:
task-manager://orders/123?ref=email
becomes a Turbo visit to {server_url}/orders/123?ref=email, so your path
configuration still decides how it is presented.
The scheme is per app. turbo-desktop new derives it from the app name and
writes it into tauri.conf.json, along with a matching bundle identifier. It
belongs there rather than in turbo-desktop.config.json because the operating
system needs it at build time: macOS reads it from the app's Info.plist,
Windows from a registry key written at install.
That per-app choice matters. No desktop OS arbitrates duplicate scheme registrations in a way you control — on Windows the last installer wins, on macOS Launch Services decides — so if every app built on this shell shared one scheme, installing two of them would send one app's links to the other. Pick something distinctive: nothing stops unrelated software registering the same string.
Links are resolved against server_url and refused if they point anywhere else.
A deep link arrives from outside the app, so it is not trusted to say where to
go.
To change the scheme later, edit plugins.deep-link.desktop.schemes in
tauri.conf.json — and expect links already sent to stop working.
Mobile shells reload when the app returns to the foreground, and data goes stale here for the same reason. A desktop window loses focus far more often though — every glance at another app — so this is opt-in and waits for an absence long enough to matter:
{
"navigation": {
"refresh_after_seconds": 300
}
}Coming back sooner than that does nothing. Omit the key, or set it to 0, and
the shell never refreshes on its own.
A refresh goes through Turbo when it is present, so with turbo-refresh-method
set to morph the page updates in place rather than being thrown away.
It will not interrupt someone typing. If the focus is in a field or a contenteditable element when the window returns, the refresh is skipped — losing half a form is worse than showing data a few seconds old.
Every return is announced whether or not a refresh is proposed, so an app can revalidate its own way, or veto a refresh it knows is unsafe:
document.addEventListener("turbo-desktop:focus", (event) => {
const { awaySeconds, refreshing } = event.detail
if (refreshing && hasUnsavedChanges()) event.preventDefault()
})Links to anywhere other than your app open in the system browser, the same way
Hotwire Native treats off-origin links. Without that, following a link to a
payment provider or a terms page replaces your app in its own window and leaves
the person with no way back. mailto:, tel: and other non-web schemes go to
whichever app owns them.
This is decided in the shell rather than in JavaScript, because Turbo only
intercepts same-origin links — an off-origin one never reaches the web layer at
all. Ordinary navigations, target="_blank", window.open and path
configuration rules pointing off-origin all go the same way.
Sometimes you need a third-party page inside the app: an OAuth round trip has to happen in this webview for the session cookie to land in the right place. List those hosts:
{
"navigation": {
"internal_hosts": ["accounts.google.com"]
}
}Matching is exact, so example.com does not admit evil-example.com or
sub.example.com. Being internal is not the same as being trusted: the bridge
still answers only your app's own origin, so a listed host can render but cannot
reach the shell.
The shell watches your server and reports failures using the same vocabulary as
Hotwire Native, so network_failure, timeout_failure, http_failure and
page_load_failure mean here what they mean on turbo-ios and turbo-android.
What happens by default. If your server is unreachable at launch, the window opens on a bundled error page. If it goes away while the app is running, a banner appears. Either way the shell keeps probing, and puts the window back on your app as soon as the server answers — you do not have to do anything.
The shell is what notices this, not the web layer, because the browser's
offline event fires when this machine loses its network, not when your
server goes down. The second is the case that actually happens.
Customising the error page. desktop/src/error.html is yours. It is
bundled with your app, so it must work with no network: inline everything, no
CDN fonts or remote stylesheets. It receives the server URL as
window.__TURBO_DESKTOP_SERVER_URL__ and the reason as an ?error= parameter.
Handling failures in your app instead. Listen for turbo-desktop:visit-error
and call preventDefault() to suppress the shell's banner for that failure:
document.addEventListener("turbo-desktop:visit-error", (event) => {
const { error, status, retry } = event.detail
event.preventDefault()
showMyOwnBanner(error, status, retry) // retry() attempts the visit again
})retry is the desktop counterpart of the retry handler Hotwire Native passes to
a failed visitable. To take over presentation entirely rather than case by case:
<meta name="turbo-desktop-error-handling" content="manual">There is also turbo-desktop:connection with { online, error } for reacting to
the connection dropping and returning without tying it to a specific visit.
Server errors your app can render itself are left alone — a 404 or a 422 is your page to serve. Only 5xx responses and failures to reach the server at all are reported.
The bridge reaches the shell, the filesystem and (on macOS) administrator privileges, so it is closed by default and opened deliberately.
Origin. Every bridge message is checked against server_url before it is
dispatched. Only pages served from that origin can use the bridge.
Filesystem. The filesystem component can only read and write under the
roots you declare. With no configuration it is limited to the app's own data
directory. Paths are resolved before the check, so .. and symlinks cannot walk
out of a root, and locations like .ssh, .aws, .gnupg and Rails
master.key / credentials.yml.enc are refused even inside one.
{
"filesystem": {
"allowed_roots": ["~/Projects", "~/.rbenv"]
}
}A path the user picks in a native file dialog is treated as consent for that
path: picking a file (open or save) makes that one file readable and writable,
picking a folder covers everything inside it. So "Save As… → Desktop" works
without listing ~/Desktop as a root. Grants last for the session only, and
the protected locations above stay refused even when picked.
Sudo. The sudo component is off unless you enable it and name the commands
it may run. A command is matched whole or as a prefix up to a word boundary, and
anything containing shell metacharacters (;, &&, |, backticks, $(...))
is refused so an allowed prefix cannot be extended into a second command. Before
the system's own elevation prompt — which does not say what is about to run, and
may cache your credential afterwards — the app shows the exact command and asks.
Elevation goes through each platform's native mechanism: the macOS password
dialog (osascript), polkit's authentication dialog on Linux (pkexec, present
on every desktop distribution), and UAC on Windows. One platform difference: on
Windows an elevated command's output cannot stream line-by-line into the app —
it arrives in full when the command finishes.
{
"sudo": {
"enabled": true,
"allowed_commands": ["softwareupdate", "brew install"],
"confirm": true
}
}Set confirm to false only if your app already asks the user itself.
Files dragged from the Finder or Explorer onto any app window reach your page with their real paths — something a browser never gives you. The drop counts as consent, like a dialog pick: the dropped files (and folders, with their contents) become readable through the filesystem bridge for the session.
Subscribe from a Stimulus controller with plain DOM events:
// data-action="turbo-desktop:drop@document->importer#filesDropped"
filesDropped(event) {
const { paths, position } = event.detail;
paths.forEach((path) => TurboDesktop.fs.read(path));
}turbo-desktop:drag-enter and turbo-desktop:drag-leave fire around it for
hover styling, or use the callback API: TurboDesktop.dragDrop.onDrop(cb),
.onEnter(cb), .onLeave(cb).
The browser clipboard API needs a user gesture and a focused document; the
native clipboard does not. TurboDesktop.clipboard.readText() returns what any
application put there (null when it holds no text), and .writeText(text)
sets it — from a Turbo Stream callback, a timer, wherever:
const text = await TurboDesktop.clipboard.readText();
await TurboDesktop.clipboard.writeText("INV-2024-001");Ordinary copy and paste inside the page keeps working through the webview as in any browser.
Offer a toggle in your app's settings page; the shell records the choice with
the OS — a Launch Agent on macOS, the registry Run key on Windows, an XDG
autostart entry on Linux:
// A Stimulus controller behind a checkbox
async toggle(event) {
if (event.target.checked) await TurboDesktop.autostart.enable();
else await TurboDesktop.autostart.disable();
}
async connect() {
this.checkboxTarget.checked = await TurboDesktop.autostart.isEnabled();
}It is deliberately not a config key: registering login items silently is how apps end up on "why does this start with my computer" lists. Ask first.
Declare the file types your app owns in tauri.conf.json, and the OS offers
your app for them — double-click, "Open With…", drop on the dock icon:
{
"bundle": {
"fileAssociations": [
{ "ext": ["csv"], "description": "Data import", "role": "Viewer" }
]
}
}Opened files arrive as a turbo-desktop:file-open DOM event with
event.detail.paths, whether the app was already running or was launched by
the double-click — a launch queues the paths until your page is up. Like a
dialog pick, being asked to open a file grants it for reading through the
filesystem bridge.
// data-action="turbo-desktop:file-open@document->importer#fileOpened"
async fileOpened(event) {
const { content } = await TurboDesktop.fs.read(event.detail.paths[0]);
}In development, press Cmd/Ctrl+Shift+D to open the Dev Inspector — an in-app overlay that shows:
- Components — every available bridge component, with a copy-pasteable Rails + Stimulus snippet, and which are active on the current page
- Messages — a live log of web↔native bridge traffic
- Navigation — the path-configuration presentation applied to the current URL
- Shell — platform, arch, version, and server URL
Enable it from the Rails gem (added by the installer in development):
# config/initializers/turbo_desktop.rb
config.inspector_enabled = Rails.env.development?<%# app/views/layouts/application.html.erb, in <head> %>
<%= turbo_desktop_inspector_meta_tag %>Or flip it on against any build without a rebuild:
localStorage.setItem("td:inspector", "1").
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)
}
}Requests from the desktop app carry a Rails variant, so an entire template can be written for it instead of branching inside a shared one:
app/views/orders/show.html.erb # everyone
app/views/orders/show.html+desktop.erb # the desktop app
Layouts too (layouts/application.html+desktop.erb). Rails falls back to the
plain template wherever no variant exists, so it costs nothing until you add one.
Rename it with config.variant, or set it to nil to leave variants alone.
<%# 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.
| Helper | Description |
|---|---|
turbo_desktop_app? |
Returns true if request comes from Turbo Desktop |
turbo_desktop_platform |
Returns "macos", "windows", "linux", or nil |
turbo_desktop_arch |
Returns "aarch64", "x86_64", or nil |
turbo_desktop_only { } |
Renders block only inside the desktop app |
turbo_web_only { } |
Renders block only for web (non-desktop) users |
turbo_desktop_bridge(component, **opts) |
Outputs bridge data attributes |
| 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 |
| 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 |
Your app ships with the default Turbo Desktop icon (in src-tauri/icons/). To use your own, run
Tauri's icon generator on a single source image — it produces every size and format
(.png, macOS .icns, Windows .ico, and mobile sets):
npm run tauri icon path/to/your-icon.png
# or: cargo tauri icon path/to/your-icon.pngUse a square PNG, 1024×1024, with a transparent background. The generator overwrites
src-tauri/icons/, and tauri.conf.json's bundle.icon already points at those files — so the next
cargo tauri build (or tagged release) uses your icon automatically. No config changes needed.
Prefer to do it by hand? Replace the files in src-tauri/icons/ listed under bundle.icon.
Starting a new app? Brand it from the start — the CLI generates your icon during scaffolding:
npx turbo-desktop new myapp --icon ./logo.pngShip native installers for macOS, Windows, and Linux by pushing a git tag — the release workflow builds each OS and attaches the installers to a draft GitHub Release:
git tag v0.1.0 && git push origin v0.1.0See docs/DISTRIBUTION.md for local builds, using it in your own app, and the optional signing / auto-update setup.
turbo_desktop/
├── src/ # JavaScript (turbo-desktop.js)
├── src-tauri/ # Rust / Tauri shell
│ └── src/
│ ├── main.rs # App entry point
│ ├── security.rs # Origin, filesystem and sudo policy
│ ├── navigation.rs # Visit proposals & path config routing
│ ├── bridge.rs # Bridge dispatch
│ ├── shell_bridge.rs # Process spawning
│ ├── fs_bridge.rs # Scoped filesystem access
│ ├── sudo_bridge.rs # Privileged commands
│ ├── config.rs # Path configuration
│ └── window.rs # Window management & app config
├── turbo_desktop-rails/ # Rails gem
├── cli/ # CLI scaffolding tool
├── templates/ # Project templates
├── test/ # Tests
└── docs/ # Documentation
MIT — see LICENSE for details.
Built with Tauri, Hotwire, and Ruby on Rails.
