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-apptemplate, 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 (thedesktop-local-appproof).
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 capabilitiesscopesshell:allow-executeandshell:allow-spawnto that one sidecar (plusshell:allow-kill) — no blanket shell permission.voltro desktop buildbuilds the api, runs the executable target, copies the binary tosrc-tauri/binaries/<binary>-<host triple>(Tauri's sidecar naming, the triple fromrustc -vV) and the serve bundle tosrc-tauri/resources/serveBundle/.tauri.conf.jsonmust list"binaries/<binary>"underbundle.externalBinand"resources/serveBundle/**/*"underbundle.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.