Turbo Desktop
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
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.
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.
Example App
Here's what a Rails app looks like running inside Turbo Desktop (from the example Task Manager app):
The same app, from the same Rails code, on the three platforms:
| macOS | Windows | Linux |
|---|---|---|
![]() |
![]() |
![]() |
The badge in the corner of each is what Rails was told: turbo_desktop_platform,
turbo_desktop_arch, and the shell's version.
Features
- 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
- Small installers β Tauri uses the OS WebView, no bundled Chromium. The shell's own installers are 5 to 15 MB
- 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
Architecture
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββββββ
β 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
Quick Start
1. Clone and install dependencies
git clone https://github.com/aguspe/turbo_desktop.git
cd turbo_desktop
cargo install tauri-cli
npm install2. Configure your Rails server URL
Edit 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.
Where the rules come from
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.
Where the config is read from
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.
User preferences
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.
Starting the server automatically
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 shell on macOS and Linux, started as a login and interactive one, 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.
3. Add the Rails gem
# Gemfile
gem "turbo_desktop-rails", "~> 0.2"bundle install
rails generate turbo_desktop:install4. Serve path configuration from Rails
# config/routes.rb
mount TurboDesktop::Engine => "/turbo-desktop"The installer adds this line for you. It serves
/turbo-desktop/path-configuration.json from the rules in
config/initializers/turbo_desktop.rb, and the Dev Inspector's scripts.
5. Run the desktop app
cargo tauri devWith a server.command configured, this also starts your Rails server; without
one, run bin/rails server in another terminal first.
Path Configuration
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 |
Bridge Components
The Bridge is the desktop equivalent of Strada. It lets your web components talk to native OS features through structured message passing.
Built-in Components
| 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 |
filesystem |
Read and write inside declared roots and granted paths |
shell |
Run child processes through the login shell, output streamed |
sudo |
Run allowlisted commands elevated: macOS dialog, polkit, UAC |
clipboard |
Read and write the system clipboard, no gesture needed |
autostart |
Launch at login, behind a toggle the user can see |
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 |
updater |
Check for and install app updates |
dialog |
Ask or tell with a dialog of the system's own; what data-turbo-confirm uses |
devtools |
Open the webview's developer tools (development builds) |
A file dialog can be told what it is for:
TurboDesktop.sendBridgeMessage("file-picker", "save", {
title: "Export tasks",
defaultName: "tasks.csv",
filters: [{ name: "CSV", extensions: ["csv"] }]
})Under tauri dev on macOS, notifications are shown through AppleScript: a
bare binary has no bundle for the system to file them under. A built app shows
them as its own, and asks permission the first time. Either way a Focus mode
keeps them out of the corner of the screen and puts them in the notification
centre.
A component that cannot do its job on the machine it is running on (no
notification service, a shortcut another application holds) answers with
status: "unavailable" and the reason, rather than failing.
Menu items a page registers go into an Actions menu at the end of the
menu bar, which is there while it has something in it, and choosing one
sends { event: "clicked", data: { id } } to the menu-item component. A
global shortcut sends { event: "triggered", data: { id, accelerator } } to
the shortcut component, whether or not the app is in front:
TurboDesktop.sendBridgeMessage("menu-item", "connect", {
id: "export", title: "Export PDF", shortcut: "CmdOrCtrl+E"
})
TurboDesktop.sendBridgeMessage("shortcut", "register", {
id: "quick-add", accelerator: "CmdOrCtrl+Shift+K"
})
TurboDesktop.sendBridgeMessage("badge", "set", { count: 3 }) // 0 clears it
TurboDesktop.sendBridgeMessage("notification", "show", {
title: "Task done", body: "Set up Turbo Desktop"
})filesystem, shell, sudo, clipboard, autostart, updater, dialog and
devtools have a JavaScript API of their own on TurboDesktop
(TurboDesktop.fs, .shell, .clipboard, and so on). The others are reached
with sendBridgeMessage, from markup with turbo_desktop_bridge, or arrive as
DOM events. All of it is typed in src/turbo-desktop.d.ts and in the
turbo-desktop-bridge npm package.
What differs between platforms
| Component | Where it differs |
|---|---|
badge |
macOS and Linux. Windows has no badge count on a taskbar icon, and the component answers unavailable or does nothing there. |
shortcut |
On Linux, under X11 only. Under Wayland a global shortcut cannot be registered and the component answers unavailable. |
shell |
Through a login shell on macOS and Linux, through cmd on Windows. |
sudo |
On Windows the output of an elevated command arrives when it has finished, not line by line. |
updater |
Off until the app names its update server: see Distribution. |
| Tray icon | Stock GNOME shows no tray icons without an extension. |
Modal and secondary windows
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"A modal that has done its work
A form in a modal is saved, and the server redirects to the list. The list is
not a modal's page, so the modal closes and the window underneath goes there,
showing what was saved. The same happens for a link in a modal to any page
whose rule is not modal. Nothing has to be written for this: it is what
Hotwire Native does, and it follows from the path configuration.
A page rendered for a modal has a window of its own around it, so the app's navigation does not belong in it. The gem can tell from the path configuration:
<%= render "navigation" unless turbo_desktop_modal? %>turbo_desktop_presentation gives the presentation itself: "default",
"modal", "new_window". Both are nil or false in a browser.
Dismissing a modal
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.
Deep links
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.
A link that starts the app is kept until the app's page has loaded, and followed then. Nothing has to be written for it.
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.
Refreshing when you come back
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()
})External links
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.
Connection loss and error pages
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.
The shell reports two of them today, network_failure and http_failure: a
server that does not answer in time is reported as a network_failure. The
other two names are kept so that an app written against them goes on working
when the shell tells them apart. One name is the desktop shell's own:
port_in_use, shown on the error page when the app starts its own server and
something else already holds its port. The shell does not open on the stranger,
and keeps waiting for your server rather than theirs.
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.
Bridge security
The bridge reaches the shell, the filesystem and 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.
Asking before something is done
A webview in the shell does not show the browser's confirm() and alert():
a page that asks that way is answered no, and nobody is asked. Turbo asks that
way for data-turbo-confirm, so the shell gives Turbo the system's dialog
instead, and this works as written:
<%= button_to "Delete", task_path(task), method: :delete,
data: { turbo_confirm: "Are you sure?" } %>An app that has set Turbo.config.forms.confirm itself keeps its own. To ask
from JavaScript:
if (await TurboDesktop.confirm("Delete this task?", { confirm: "Delete", cancel: "Keep" })) {
// β¦
}
await TurboDesktop.alert("Exported.", { title: "Tasks" })No answer is a no: if the shell cannot ask, nothing goes ahead.
Drag & drop from the desktop
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) can be reached through the filesystem bridge for the session. That is reading and writing both, and for a folder removing what is in it: a grant is for the path, not for one thing done to it. The protected locations stay refused.
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).
Clipboard
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.
Launch at login
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.
Opening files with your app
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" }
]
}
}macOS reports an opened file as a file and as a file: URL; it is opened
once, and is never taken for a link to a page.
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 through the filesystem
bridge, for reading and for writing.
On Windows and Linux a file opened while the app is running starts the app a second time; that copy hands the file to the one already open and leaves, so there is one window either way. Links arrive the same way.
// data-action="turbo-desktop:file-open@document->importer#fileOpened"
async fileOpened(event) {
const { content } = await TurboDesktop.fs.read(event.detail.paths[0]);
}Dev Inspector
In development, press Cmd/Ctrl+Shift+D to open the Dev Inspector β an in-app overlay that shows:
- Components β the bridge components it knows, and how many messages each has sent since the inspector was opened
- Messages β a live log of what the page sends to the shell
- Navigation β the presentation the path configuration gave the last visit
- Shell β platform, arch and version
It does not list every component yet: clipboard, autostart, file-open,
dialog and devtools are missing from it.
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").
JavaScript Example
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)
}
}Desktop-only templates
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.
Rails View Helpers
An element can declare a component in its markup, and gets it without a Stimulus controller of its own:
<%# A menu item, with a shortcut. Choosing it presses the button. %>
<%= tag.button "Export PDF",
**turbo_desktop_bridge("menu-item", title: "Export PDF", shortcut: "CmdOrCtrl+E") %>
<%# A shortcut that works with the app in the background. %>
<%= tag.button "Quick add",
**turbo_desktop_bridge("shortcut", id: "quick-add", accelerator: "CmdOrCtrl+Shift+K") %>
<%# A notification when the button is pressed. %>
<%= tag.button "Complete",
**turbo_desktop_bridge("notification", title: "Task completed", body: task.title) %>
<%# The badge on the dock icon, set to what the page shows. %>
<%= tag.span pending.count, **turbo_desktop_bridge("badge", count: pending.count) %>The helper returns whole attribute names, so splat it among the element's
attributes as above, not inside data: { }, which would prefix them a second
time.
A menu item or shortcut belongs to the page that declared it, and is taken away when Turbo moves on to a page that does not.
Rails Gem
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 |
Comparison
| 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 | initialization script |
| Rails gem | turbo-rails | turbo-rails | turbo_desktop-rails |
| Installer size | System WebKit | ~20 MB | 5 to 15 MB |
| Platforms | iOS, iPadOS | Android | macOS, Windows, Linux |
Custom App Icon
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.pngDistribution
Ship 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.2.5 && git push origin v0.2.5turbo-desktop new writes the same workflow into your app. Add six APPLE_* repository secrets
and macOS builds come out signed and notarized; nothing in the workflow needs editing. See
docs/DISTRIBUTION.md for the secrets, local builds and auto-update.
Project Structure
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
License
MIT β see LICENSE for details.
Built with Tauri, Hotwire, and Ruby on Rails.



