packages/bun-tauri holds the aphrody fork of Tauri 3 (alpha) as its own Cargo workspace: the core crates (aphrody-tauri, -runtime, -runtime-cef, -utils, -plugin, -build, -codegen, -macros, -cli, -bundler) and one plugin crate, tauri-plugin-aphrody. That crate contains every plugin of the fork plus the MCP debug bridge and tauri-wrap. Its JavaScript side is @aphrody/tauri.
The code comes from aphrody-labs/aphrody@55652e3f56 (crates/tauri, crates/ui/tauri-plugin-mcp-bridge, crates/ui/tauri-wrap), under Apache-2.0 OR MIT. The MCP bridge is MIT (hypothesi).
One crate, one plugin per module
Each module is still its own Tauri plugin. Command names (plugin:fs|read_file) and permission identifiers (fs:allow-read-file) are the same as in the separate plugins. A Cargo feature with the same name enables each module:
| Feature | Plugin | Notes |
|---|---|---|
| (always) | aphrody | Bun runtime: bun_info, bun_request, bun_restart |
autostart, clipboard-manager, deep-link, dialog, fs, global-shortcut, log, notification, opener, os, process, single-instance, store, updater, window-state | same name | in default |
fs-watch, log-colored, log-tracing, notification-windows7-compat, single-instance-semver, single-instance-deep-link, updater-zip, updater-rustls-tls, updater-native-tls, updater-system-proxy, dialog-xdg-portal | sub-features | renamed from the per-plugin features |
mcp-bridge | mcp-bridge | opt-in, pulls tauri-runtime-cef |
wrap, wrap-bundle, wrap-debug-bridge | tauri-wrap (no IPC) | opt-in |
A single crate can publish permissions for several plugins because of a change in tauri-utils and tauri-plugin. tauri_plugin::Bundle builds every member: it writes permissions/<name>/, the autogenerated command permissions, the scope schema and the global API script. Each member's permission list is announced as cargo:__BUNDLE__<NAME>_PERMISSION_FILES_PATH. The app's tauri-build maps that key back to the plugin <name> (tauri_utils::acl::build::plugin_name_from_dep_var). generate_handler![#![plugin(<name>)] …] makes sure unused-command removal targets the right plugin.
use tauri_plugin_aphrody::BuilderExt;
fn main() {
tauri::Builder::default()
// the `aphrody` plugin + every enabled module that takes no argument
.aphrody_plugins()
.plugin(tauri_plugin_aphrody::single_instance::init(|_app, _argv, _cwd| {}))
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
aphrody_plugins() does not register single-instance (it needs a callback), updater (it needs plugins > updater with a public key), mcp-bridge or wrap.
Bun as the backend
The aphrody plugin starts a Bun server next to the app and stops it when the app exits. Configure it under plugins > aphrody > bun:
{
"plugins": {
"aphrody": {
"bun": { "entry": "server.ts", "hot": true }
}
}
}
| Key | Meaning |
|---|---|
entry | Script run with bun [--hot] <entry>. The bun binary is binary, then BUN_TAURI_BUN, then PATH. |
sidecar | Name of a bun build --compile executable placed next to the app (bundle > externalBin). Takes precedence over entry. |
hot | Pass --hot so Bun.serve hot-reloads in development. |
args, env, cwd | Extra arguments, environment and working directory. |
port | Fixed port. By default the plugin picks a free loopback port. |
readyTimeoutMs | How long to wait for the port to accept connections (default 15000). |
autostart | Start with the app (default true). Otherwise it starts on the first bun_restart. |
The server receives its port in PORT, which Bun.serve uses by default, and in BUN_TAURI_PORT. It also gets BUN_TAURI=1. The plugin waits until the port accepts connections. Each output line goes to the log crate and is emitted as an aphrody://bun-log event.
import { serveForTauri } from "@aphrody/tauri/server";
serveForTauri({
routes: { "/api/hello": () => Response.json({ runtime: `bun ${Bun.version}` }) },
});
In the webview, bunRequest and bunFetch go through IPC, so you need no CORS headers and no CSP entry for the port:
import { bunFetch, bunInfo, onBunLog } from "@aphrody/tauri/bun";
const res = await bunFetch("/api/hello");
console.log(await res.json(), await bunInfo());
await onBunLog(({ stream, line }) => console.log(stream, line));
The default permission aphrody:default grants bun_info and bun_request. Restarting the server needs aphrody:allow-bun-restart. When withGlobalTauri is on, the same calls are available as window.__TAURI__.aphrody.
For a release build, compile the server with bun build --compile server.ts --outfile binaries/server-<target-triple>. List it under bundle > externalBin and set "sidecar": "server".
Runtimes and platforms
Tauri 3 has no default runtime. Pass one with Builder::runtime(...).
- CEF (
aphrody-tauri-runtime-cef, CEF 152.3.0) is the desktop runtime on Windows and Linux with glibc. On Linux it forces X11/XWayland. On Windows Chromium runs without a sandbox, whatever the policy. - Alpine (musl): CEF does not ship musl builds. A window on Alpine depends on the WebKitGTK runtime (
aphrody-tauri-runtime-wry), which comes from the shared webview layer (packages/bun-webview-core). The runtime crates belong to that layer, not to this plugin. - Mobile: the Android and iOS projects of each plugin are kept under
crates/tauri-plugin-aphrody/mobile/<name>/. They are not wired into the bundle yet.
Test
cd packages/bun-tauri
cargo test -p tauri-plugin-aphrody
tests/e2e.rs builds a MockRuntime app for each module and resolves its ACL from the permission files the build script generated. It then calls the commands through real IPC, as a webview would. That covers os, fs (inside and outside its scope), store, log, window-state, notification and deep-link. It also starts a real Bun server through aphrody and calls bun_request and bun_restart.
dialog, clipboard-manager, global-shortcut and opener need a desktop session, so this suite does not run them.