Desktop SDK (Rust)¶
leaf_sdk_desktop is a pure-Rust crate that wraps the Leaf core and exposes a small, callback-driven API for desktop apps. It is the idiomatic choice for Tauri, iced, egui, CLI utilities, or any Rust server/daemon.
Bindings for other languages build on top of this crate:
- C / C++ FFI —
ffi/crate (libleaf.{so,dll,dylib}+leaf.h). - Java / JVM —
jni-wrapper/crate + theleaf-sdk-javaGradle project.
1. How the SDK is split¶
| Crate | Purpose |
|---|---|
leaf_sdk_desktop |
Public Rust API: core lifecycle, Leaf lifecycle, subscription, preferences, ApiClient. |
leaf-ipc |
Privileged sidecar binary. Installed alongside your app and elevated to root / Administrator to manage the TUN device. |
ipc |
Transport layer: Unix Domain Sockets on Linux/macOS, Named Pipes on Windows. |
leaf-util |
Shared subscription / asset / preferences layer (also used by Android). |
Your UI process never touches the TUN device; it drives leaf-ipc over the IPC transport. start_core() launches leaf-ipc and elevates if necessary (pkexec on Linux, runas on macOS/Windows).
2. Cargo setup¶
Registry¶
Add the Surf Shield Kellnr registry to your global Cargo config or a project-local .cargo/config.toml:
# .cargo/config.toml
[registries.kellnr]
index = "sparse+https://cargo.surfshield.org/api/v1/crates/"
credential-provider = ["cargo:token"]
Export CARGO_REGISTRIES_KELLNR_TOKEN in your shell / CI environment rather than committing the token.
Dependency¶
Bundling the leaf-ipc sidecar¶
Your UI binary must ship leaf-ipc alongside itself so start_core can launch it. The recommended path is to use leaf-build-deps as a build-dependency — it downloads the correct pre-built leaf-ipc for every target, verifies its SHA-256, and drops it next to your binary. The leaf-desktop sample wires it up in three lines:
# src-tauri/Cargo.toml
[build-dependencies]
leaf-build-deps = { version = "0.14.4", registry = "kellnr" }
// src-tauri/build.rs
use leaf_build_deps::LeafBuilder;
fn main() {
LeafBuilder::new()
.cache_dir("./sidecar_cache")
.output_dir("./bin")
.version("1.4.1") // pin a known-good leaf-ipc version
.build()
.expect("sidecar preparation failed");
tauri_build::build(); // if you use Tauri
}
The file ./bin/leaf-ipc-<target>[.exe] is produced on every cargo build; Tauri then packages it as a sidecar. Retrieve the launch path inside your code with:
let program = app.shell().sidecar("leaf-ipc")?.into_command().get_program();
leaf_sdk_desktop::start_core(program.to_string_lossy().into(), true, cb)?;
If you are not using Tauri, drop the resolved ./bin/leaf-ipc-<target> binary next to your own executable and pass its absolute path to start_core.
Supported target triples and the matching leaf-ipc-core-<version>-<os>-<arch>.zip bundles (pulled from https://repo.surfshield.org/repository/static-public/leaf-ipc-<version>/) are listed in Distribution & Registries.
3. Lifecycle API¶
use leaf_sdk_desktop::{CoreState, LeafState, SubscriptionState};
fn main() -> anyhow::Result<()> {
// 1. Launch privileged core (prompts for root / UAC on first run)
leaf_sdk_desktop::start_core(
"/path/to/leaf-ipc".into(),
true, // daemonize
|state| match state {
CoreState::STARTING => println!("core starting..."),
CoreState::STARTED => println!("core ready"),
CoreState::STOPPED => println!("core exited"),
CoreState::ERROR { error } => eprintln!("core error: {error}"),
},
)?;
// 2. Refresh subscription from the panel
leaf_sdk_desktop::update_subscription(
None, None, // auto TLS / fragment
"YOUR_CLIENT_UUID".into(),
Some(true), Some(true), // speedtest + try-all
|state| match state {
SubscriptionState::UPDATING => (),
SubscriptionState::SUCCESS => start_leaf(),
SubscriptionState::ERROR { error } => eprintln!("sub error: {error}"),
},
);
Ok(())
}
fn start_leaf() {
leaf_sdk_desktop::run_leaf(|state| match state {
LeafState::STARTING => (),
LeafState::STARTED => println!("VPN connected"),
LeafState::STOPPED => println!("VPN disconnected"),
LeafState::RELOADED => println!("config reloaded"),
LeafState::ERROR { error } => eprintln!("leaf error: {error}"),
}).unwrap();
}
Function Reference¶
All callbacks are Fn + Send + Clone + 'static. They are invoked on the internal Tokio runtime — marshal back to your UI thread if needed.
| Function | Purpose |
|---|---|
start_core(program, daemon, cb) |
Launch leaf-ipc. Elevates privileges if necessary. Idempotent. |
is_core_running() -> bool |
IPC ping to the core. |
shutdown_core(daemon, cb) |
Graceful shutdown. |
force_shutdown_core(cb) |
Graceful attempt followed by hard kill. |
run_leaf(cb) -> Result<()> |
Start Leaf, loading the last subscription. |
is_leaf_running() -> Result<bool> |
Check Leaf state synchronously. |
reload_leaf(cb) -> Result<()> |
Hot-reload the rendered config. |
stop_leaf(cb) |
Stop Leaf but keep the core alive. |
test_config() -> Result<()> |
Parse the current subscription and verify the generated .conf. |
auto_update_subscription(cb) |
Re-fetch with last-known successful flags. |
update_subscription(tls, fragment, client_id, enable_speedtest, enable_try_all, cb) |
Full control. None means auto-pick. |
update_custom_config(config, cb) |
Load a raw Leaf .conf string. |
import_offline_subscription(path, passphrase, keyring_json, cb) |
Import a .leafsub bundle. keyring_json is a JSON object mapping keyId → verifying key. |
update_assets(major, minor, patch) -> Result<()> |
Blocking refresh of geoip.dat / geosite.dat. |
verify_file_integrity() -> Result<()> |
SHA-256 check before start. |
ping() -> Result<String> |
IPC liveness check. Returns "pong". |
get_version() -> String |
Leaf core version. |
get_preferences() -> Result<LeafPreferences> / set_preferences(...) |
See Preferences Reference. |
setup_wintun(path) -> Result<()> (Windows only) |
Write wintun.dll to path before starting Leaf. |
remove_wintun_dll() -> Result<()> (Windows only) |
Cleanup on exit. |
ApiClient¶
A Tokio-powered HTTP client for the local runtime API is re-exported from the SDK:
use leaf_sdk_desktop::ApiClient;
let prefs = leaf_sdk_desktop::get_preferences()?;
let api = ApiClient::new(format!("http://127.0.0.1:{}", prefs.api_port));
let outbounds = api.list_outbounds().await?;
let usage = api.get_usage_by_tag("default").await?;
let logs = api.get_logs_json(200, 0).await?;
Full method list in the ApiClient implementation:
reload_configuration()/shutdown_server()list_outbounds()/list_outbounds_with_selection(tag)update_outbound_selection(outbound, select)/get_selected_outbound(outbound)get_usage_by_tag(tag)/get_usage_by_user_id(user_id)get_logs_json(limit, offset)/get_logs_html()/clear_logs()get_current_connections_json()/get_current_connections_html()get_last_peer_active(tag)/get_since_last_peer_active(tag)get_outbound_health(tag)— TCP / UDP timing in ms.check_connectivity()get_failover_status(outbound)/force_failover_healthcheck(outbound)/force_failover_healthcheck_all()
See the full list of endpoints and payloads in the Runtime HTTP API reference.
4. Tauri integration (recommended)¶
Register each SDK function as a Tauri command. The leaf-desktop sample is the canonical reference — here is the gist:
#[tauri::command]
fn start_core<R: Runtime>(app: AppHandle<R>, window: Window<R>) -> Result<(), String> {
let program = app.shell().sidecar("leaf-ipc").unwrap()
.into().get_program().to_string_lossy().to_string();
leaf_sdk_desktop::start_core(program, /* daemon */ true, move |state| {
window.emit("core-event", state).unwrap();
}).map_err(|e| e.to_string())
}
#[tauri::command]
fn run_leaf<R: Runtime>(window: Window<R>) -> Result<(), String> {
leaf_sdk_desktop::run_leaf(move |state| {
window.emit("leaf-event", state).unwrap();
}).map_err(|e| e.to_string())
}
#[tauri::command]
fn update_subscription<R: Runtime>(window: Window<R>, client_id: String) {
leaf_sdk_desktop::update_subscription(None, None, client_id, None, None,
move |state| { window.emit("subscription-event", state).unwrap(); });
}
On the JavaScript side:
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
await invoke('start_core');
listen('core-event', ({ payload }) => console.log('core:', payload));
await invoke('update_subscription', { clientId: 'YOUR_CLIENT_UUID' });
await invoke('run_leaf');
Windows — setup Wintun once¶
#[cfg(target_os = "windows")]
fn setup_wintun<R: Runtime>(app: &AppHandle<R>) -> anyhow::Result<()> {
let program = app.shell().sidecar("leaf-ipc")?.into();
let dir = Path::new(&program).parent().unwrap();
leaf_sdk_desktop::setup_wintun(dir.join("wintun.dll").display().to_string())
}
Call this inside Tauri's .setup() before launching the core, and leaf_sdk_desktop::remove_wintun_dll() on exit.
Deep links (leafvpn://)¶
The sample uses tauri-plugin-single-instance + tauri-plugin-deep-link so that when the user opens a .leafsub file or a leafvpn:// URL the existing instance focuses and emits the path to the frontend, which calls import_offline_subscription or update_subscription accordingly.
5. Preferences¶
Read / write via the same fields described in the Preferences Reference:
let mut prefs = leaf_sdk_desktop::get_preferences()?;
prefs.fake_ip = true;
prefs.bypass_geoip_list = Some(vec!["cn".into(), "ir".into(), "ru".into()]);
leaf_sdk_desktop::set_preferences(
prefs.enable_ipv6, prefs.prefer_ipv6, prefs.memory_logger,
prefs.log_level, prefs.api_port, prefs.auto_reload,
prefs.user_agent.unwrap_or_default(),
prefs.bypass_lan, prefs.bypass_lan_in_core,
prefs.fake_ip, prefs.force_resolve_domain,
prefs.bypass_geoip_list, prefs.bypass_geosite_list,
prefs.reject_geoip_list, prefs.reject_geosite_list,
prefs.internal_dns_server,
)?;
// If the core is running, hot-swap:
leaf_sdk_desktop::reload_leaf(|_| {})?;
6. Auto-Routing (hands-off)¶
The Leaf core ships with an AutoRouting manager that automatically owns the system routing table while the VPN is up. You get this for free when Leaf is started with a TUN inbound — no SDK call required. In short:
- On start, the manager picks the current default route, pins
OUTBOUND_INTERFACEso outbounds bind to the physical uplink, rewrites the active interface's DNS to1.1.1.1+8.8.8.8, and installs either public-only or catch-all routes into the TUN depending onbypass_lan. - A
net-routeevent stream plus a 3-second poller watch for default-route changes (Wi-Fi ↔ Ethernet ↔ cellular / dock ↔ hotspot). On change, the manager re-binds outbounds and firesforce_health_check_all_failovers()so everyfailovergroup re-evaluates — your VPN survives network flaps without reconnect. - On stop, the manager removes every route it added, restores the original DNS servers, and clears
OUTBOUND_INTERFACE.
The behaviour is controlled through LeafPreferences: bypass_lan, bypass_lan_in_core, enable_ipv6, prefer_ipv6. See Leaf Core — Auto-Routing for the implementation contract if you need to tune the environment (TUN_DEVICE_NAME, ROUTE_POLL_INTERVAL, gateway addresses) for a custom build.
Windows note: Wintun driver installation must run before the first
run_leaf()call; AutoRouting assumes the TUN device exists and is named afterTUN_DEVICE_NAME.
7. Error handling patterns¶
- Synchronous setup functions return
anyhow::Result<T>. Surface them to your UI in plain strings (map_err(|e| e.to_string())). - Lifecycle callbacks emit
*::ERROR { error: String }. Treat them as non-terminal; the SDK will attempt cleanup and you may call the same function again after the user acknowledges the error. start_corereturnsOk(())before the core is actually ready. Wait forCoreState::STARTEDbefore issuing other calls. The SDK already debounces races internally but your UI should reflect the transition.
8. Full example — minimal CLI¶
use leaf_sdk_desktop::{CoreState, LeafState, SubscriptionState};
use std::{sync::mpsc::{channel, Sender}, time::Duration};
fn main() -> anyhow::Result<()> {
env_logger::init();
let (tx, rx) = channel::<&'static str>();
let tx_core = tx.clone();
leaf_sdk_desktop::start_core("./leaf-ipc".into(), true, move |s| {
if matches!(s, CoreState::STARTED) { tx_core.send("core").ok(); }
})?;
assert_eq!(rx.recv()?, "core");
let tx_sub = tx.clone();
leaf_sdk_desktop::update_subscription(None, None,
std::env::var("CLIENT_ID").unwrap(),
Some(true), Some(true),
move |s| if matches!(s, SubscriptionState::SUCCESS) { tx_sub.send("sub").ok(); });
assert_eq!(rx.recv()?, "sub");
leaf_sdk_desktop::verify_file_integrity()?;
let tx_leaf = tx.clone();
leaf_sdk_desktop::run_leaf(move |s| match s {
LeafState::STARTED => { tx_leaf.send("leaf").ok(); }
LeafState::ERROR { error } => eprintln!("{error}"),
_ => {}
})?;
assert_eq!(rx.recv()?, "leaf");
std::thread::sleep(Duration::from_secs(30));
leaf_sdk_desktop::stop_leaf(|_| {});
leaf_sdk_desktop::shutdown_core(true, |_| {});
Ok(())
}
9. Next steps¶
- Live stats & selection: Runtime HTTP API.
- Need C ABI? Desktop SDK (C/C++ FFI).
- Need JVM bindings? Desktop SDK (Java).
- See how a real Tauri app wires everything together: Sample Projects and
github.com/shiroedev2024/leaf-desktop.