Skip to content

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++ FFIffi/ crate (libleaf.{so,dll,dylib} + leaf.h).
  • Java / JVMjni-wrapper/ crate + the leaf-sdk-java Gradle 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

# Cargo.toml
[dependencies]
leaf_sdk_desktop = { version = "1.4.1", registry = "kellnr" }

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
}
// src-tauri/tauri.conf.json
{
  "bundle": { "externalBin": ["./bin/leaf-ipc"] }
}

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.


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.

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_INTERFACE so outbounds bind to the physical uplink, rewrites the active interface's DNS to 1.1.1.1 + 8.8.8.8, and installs either public-only or catch-all routes into the TUN depending on bypass_lan.
  • A net-route event 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 fires force_health_check_all_failovers() so every failover group 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 after TUN_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_core returns Ok(()) before the core is actually ready. Wait for CoreState::STARTED before 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