Desktop

@voltro/desktop — the desktop shell contract (window, tray, menu, shortcuts, single instance, autostart, sidecar, OS theme) over the platform adapter, with the Tauri 2 adapter first; hooks useDesktop, useWindowState, useTray, useAppMenu, useGlobalShortcut, useSystemTheme, useDesktopUpdate.

A desktop app is your web app in a native window. Everything the client needs from its environment is already the platform adapter; @voltro/desktop adds what a window has and a tab does not — tray, menus, global shortcuts, window state, an embedded api as a sidecar — as one shell contract every adapter fills in, plus typed extensions for what only one shell can do. The first adapter is Tauri 2 (@voltro/desktop/tauri); the contract is what keeps an Electron adapter possible without touching app code.

Verified in a window on macOS, Windows and Linux (the framework's desktop proof, 2026-09-26). The proof scaffolds the desktop-app template, compiles the Tauri shell on all three runners and boots it: the window's boot report says the Tauri adapter was installed and the web app mounted. Everything below is also unit-tested with fakes and through the platform conformance suite. The Local Stack (an embedded api as the window's sidecar) starts inside that window on all three as well (the desktop-local-app proof).

Building the adapter

The Tauri plugin modules are injected — the package imports no @tauri-apps/* and an app passes exactly the plugins it installed. A module you leave out leaves its port undefined and the capability reads false.

import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import * as os from '@tauri-apps/plugin-os'
import * as deepLink from '@tauri-apps/plugin-deep-link'
import * as notification from '@tauri-apps/plugin-notification'
import { check } from '@tauri-apps/plugin-updater'
import { relaunch } from '@tauri-apps/plugin-process'
import { installPlatform } from '@voltro/client'
import { createTauriPlatform } from '@voltro/desktop/tauri'

installPlatform(createTauriPlatform({
  core: { invoke },
  window: getCurrentWindow(),
  os,
  deepLink,
  notification,
  updater: { check, relaunch },
  keyring: { get: (k) => invoke('keyring_get', { k }), set: (k, v) => invoke('keyring_set', { k, v }), delete: (k) => invoke('keyring_delete', { k }) },
  appVersion: '1.4.0',
}))

keyring is the secure token store the session value from native login belongs in — a Rust command pair over the OS keychain. Without one the adapter uses a memory store and says so (secureStorage.kind === 'memory').

The window's platform script

The window loads the same web build the browser gets — and that build's mount installs the browser adapter. So the shell's adapter has to be installed before the app's own scripts run. That is the platform script: src/main.ts in the template, bundled by voltro desktop platform into src-tauri/gen/platform.js (one classic script, the app version inlined) and injected by the Rust side as the window's initialization script. mount keeps an adapter that is already installed; the pwa update channel only replaces the browser one.

// src/main.ts — the window's platform script
import { invoke } from '@tauri-apps/api/core'
import { awaitWindowBoot } from '@voltro/desktop'
import { preparePlatform } from './platform'

preparePlatform(__APP_VERSION__)
// The Rust side hears the mount: a splash would hide here; the framework's
// window-boot proof reads this report (VOLTRO_DESKTOP_PROOF=<file>) and quits.
void awaitWindowBoot().then((report) => invoke('window_booted', { report }))

awaitWindowBoot({ rootId?, timeoutMs? }) resolves once the web app rendered into the root element (or when the timeout passes, then with mounted: false) with { shell, kind, installed, appVersion, mounted } — windowBootReport() is the same snapshot without waiting. The template's tauri.conf.json declares the window with create: false and runs voltro desktop platform as its beforeDevCommand/beforeBuildCommand, so tauri dev and voltro desktop build both start from a fresh script; lib.rs builds the window from that config with the script attached.

The hooks

import { useDesktop, useWindowState, useTray, useAppMenu, useGlobalShortcut, useSystemTheme, useDesktopUpdate } from '@voltro/desktop'

const shell = useDesktop()                 // the DesktopShell, or null in a browser tab
const win = useWindowState()               // { focused, maximized, fullscreen } live
useTray({ tooltip: 'Acme', items: [{ id: 'open', label: 'Open' }] }, (id) => {})
useAppMenu({ items: [{ id: 'file', label: 'File', submenu: [{ id: 'quit', label: 'Quit', accelerator: 'CmdOrCtrl+Q' }] }] }, (id) => {})
useGlobalShortcut('CmdOrCtrl+Shift+K', () => {})
const theme = useSystemTheme()             // 'light' | 'dark' | null — pair with the UI kit's theme
const { updateAvailable, version, check, apply } = useDesktopUpdate()

Every hook degrades to a no-op in a browser tab, so the same screen runs on the web and in the window. useDesktopUpdate is the platform's updates port in the same shape the PWA's useAppUpdate has; apply() downloads, installs and relaunches. Point the Tauri updater at your api's release feed and the rollout, rollback and minimum-version policy are the same ones your other apps follow.

The app updates itself — <UpdatePrompt />. Mount it once in the web app's root layout (the frontend-app template does): when the window opens it says Checking for updates…, checks the feed (and again every six hours, checkEveryMs), and when a newer version is on the channel the app is on it installs it at once — download, signature check against the updater key, install, relaunch — showing Updating to X…; a failure says why, with Try again. That is mode: 'auto', the default; mode: 'ask' shows the version and notes with Install and restart / Later instead. Nobody runs an installer by hand; on Windows the new installer runs itself in the background. Unstyled — semantic elements with data-voltro-update hooks and a className per part — with strings for your own words. In a browser tab it renders nothing; as an installed PWA it shows the same panel from useAppUpdate's channel.

Tray and menu are Rust-side objects in Tauri; the adapter takes a small command pair your app exposes (setTray, setMenu, onSelect) — a typed model in, one invoke out. useShellExtension('tauri') hands you invoke for anything beyond the contract; using it ties the app to Tauri, by declaration.

The Local Stack — the api as the window's sidecar

A desktop app can carry its own api: voltro build api --target executable turns the api's serve bundle into one binary, and the window starts it as a Tauri sidecar. Declare it in the binding, and voltro desktop build does the packaging:

// voltro.desktop.ts
export default {
  web: '@acme/web',
  shell: 'tauri' as const,
  features: ['updater', 'os', 'process'] as const,
  localStack: { api: '@acme/api', binary: 'acme-api' },
}
  • voltro desktop capabilities scopes shell:allow-execute and shell:allow-spawn to that one sidecar (plus shell:allow-kill) — no blanket shell permission.
  • voltro desktop build builds the api, runs the executable target, copies the binary to src-tauri/binaries/<binary>-<host triple> (Tauri's sidecar naming, the triple from rustc -vV) and the serve bundle to src-tauri/resources/serveBundle/. tauri.conf.json must list "binaries/<binary>" under bundle.externalBin and "resources/serveBundle/**/*" under bundle.resources; the build refuses until it does.

In the window, start it before the web app mounts and hand the url to the client:

import { startLocalStack } from '@voltro/desktop'

const stack = await startLocalStack(adapter.extensions.desktop, { binary: 'acme-api' })
// stack.url === 'http://127.0.0.1:<port>' — the port is READ from the sidecar's
// first log line, and readiness from its own "serve: ready" line (a window's
// fetch cannot probe another origin). A sidecar that never reports is killed
// and the promise rejects with the sidecar's last output.

The sidecar runs serve . as the api would on a server, so the session secret and the schema gate apply unchanged — startLocalStack({ env }) hands the per-installation secrets to the process as environment (the desktop-local-app template mints VOLTRO_SESSION_SECRET into the keychain once and passes it that way; no secret file beside the bundle). The native leaves the api reaches (better-sqlite3 for a local database) ship beside the binary as its node_modules. This is built and unit-tested (a fake sidecar, a real SEA binary booted on Linux); a window starting it on macOS, Windows and Linux is the runner proof still open in the programme plan.

The devtools panel

The in-page devtools overlay has a Desktop tab when a desktop adapter is installed: the shell kind, the OS with architecture, version and theme, the window state, the app version; the updater port with Check now and the version it reports; the Local Stack — whether the shell has a sidecar port and the api url and name from the pairing record startLocalStack writes; and the capabilities the adapter reports as on. Everything comes from the installed PlatformAdapter and its extensions.desktop; the panel imports no shell package, and in a browser tab it says so.

The Electron adapter — @voltro/desktop/electron

The same DesktopShell core behind a second shell. An Electron renderer reaches the main process only through what the app's preload exposes, so createElectronPlatform({ bridge }) takes one bridge object structurally — window state and controls, OS, deep links, safeStorage as the token store, notifications, electron-updater as the update channel, global shortcuts, login-item autostart, tray/menu from the typed models, a child-process sidecar for the Local Stack, second-instance argv — and the package imports no electron. Every hook (useDesktop, useTray, useDesktopUpdate, startLocalStack) works unchanged; what only Electron can do rides under extensions.electron (invoke, on over ipcRenderer) and an app reaches it knowingly with useShellExtension('electron'). That is the adapter layer for Electron-only features: they stay Electron's, never the core's.

// preload.ts — expose the bridge the adapter takes
contextBridge.exposeInMainWorld('voltroBridge', {
  window: { minimize: () => ipcRenderer.invoke('win:minimize'), /* … */ state: () => ipcRenderer.invoke('win:state'), onStateChange: (cb) => on('win:state', cb), theme: () => ipcRenderer.invoke('win:theme'), onThemeChange: (cb) => on('win:theme', cb) },
  os: { platform: () => process.platform, arch: () => process.arch, version: () => os.release() },
  deepLink: { initialUrl: () => ipcRenderer.invoke('links:initial'), onOpenUrl: (cb) => on('links:open', cb) },
  safeStorage: { get: (k) => ipcRenderer.invoke('safe:get', k), set: (k, v) => ipcRenderer.invoke('safe:set', k, v), delete: (k) => ipcRenderer.invoke('safe:delete', k) },
  invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
})
// renderer — before the first render
import { createElectronPlatform, type ElectronBridgeLike } from '@voltro/desktop/electron'
import { installPlatform } from '@voltro/client'
const bridge = (window as Window & { voltroBridge: ElectronBridgeLike }).voltroBridge
installPlatform(createElectronPlatform({ bridge, appVersion }))

The adapter passes the platform conformance suite over fakes, like the Tauri one. There is no Electron template yet; the plan lists the shell as an adapter behind demand, and this is that adapter.