npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

turbo-desktop

v0.1.1

Published

Turbo Native for Desktop — wrap your Rails/Turbo app in a native desktop shell

Readme


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):

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
  • 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-rails gives your Rails app desktop shell awareness
  • CLI scaffolding — npx turbo-desktop new myapp to 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:

  1. Rails Server — your existing app serves HTML with Turbo Drive
  2. WebView — turbo-desktop.js intercepts Turbo visits and bridges to native
  3. 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 install

2. 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_url is 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:

  1. The last copy the server gave, cached in the user's config directory.
  2. The copy bundled with the app (path-configuration.json beside your app config), for a first run before the server has ever answered.
  3. 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 dev and cargo tauri dev find it. If there is none, the app starts on defaults.
  • In a packaged app, it is read only from inside the bundle (Contents/Resources on macOS), never from the working directory, and the app refuses to start if it is missing. It ships there via bundle.resources in tauri.conf.json, and turbo-desktop build includes 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.

3. Add the Rails gem

# Gemfile
gem "turbo_desktop-rails"
bundle install
rails generate turbo_desktop:install

4. Serve path configuration from Rails

# config/routes.rb
get "/turbo-desktop/path-configuration", to: "turbo_desktop#path_configuration"

5. Run the desktop app

# Start your Rails server
bin/rails server

# Start the Tauri desktop app
cargo tauri dev

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 |

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"

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 alone

refresh() 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.

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.

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 (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"]
  }
}

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 password prompt — which does not say what is about to run, and caches your credential afterwards — the app shows the exact command and asks.

{
  "sudo": {
    "enabled": true,
    "allowed_commands": ["softwareupdate", "brew install"],
    "confirm": true
  }
}

Set confirm to false only if your app already asks the user itself.

Dev Inspector

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").

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

<%# Attach bridge data attributes to any element %>
<%= tag.button "Export PDF",
    **turbo_desktop_bridge("menu-item",
      title: "Export PDF",
      shortcut: "Cmd+E"
    ) %>

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

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.png

Use 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.png

Distribution

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.1.0 && git push origin v0.1.0

See docs/DISTRIBUTION.md for local builds, using it in your own app, and the optional signing / auto-update setup.

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.