devlog / 2026-11-13

Rewriting ZeroTier-GUI in Rust + GPUI

The Python/Tkinter app was dormant, so I rebuilt it as a native Rust desktop app with GPUI, cross-platform CI and a Linux .deb.

Why

The original ZeroTier-GUI by Tomás Ralph was a Python/Tkinter desktop app. It did its job, but it had been dormant for years and I wanted a native version I could keep building on.

The original is GPL-3.0, so the rewrite stays GPL-3.0, keeps the attribution, and lives in a separate repository:

This is an unofficial project, not affiliated with ZeroTier, Inc.

What the app does

I kept the original feature set and moved it into a single GPUI window:

  • list joined networks with ID, name and controller status
  • join and leave networks, with a persistent network history
  • inspect assigned addresses, type, interface, MAC, MTU, DHCP and bridge
  • toggle allowDefault, allowGlobal, allowManaged and allowDNS
  • browse peers with role, latency and path count
  • inspect active, expired and preferred paths
  • start/stop the zerotier-one service on Linux
  • bring a ZeroTier interface up/down through pkexec on Linux
  • guided first-run setup for the auth token and group permissions

Stack

  • Rust 2024 edition, MSRV 1.88
  • GPUI 0.2.2
  • serde / serde_json for the ZeroTier JSON output
  • chrono for history timestamps
  • open for the ZeroTier Central link
  • Linux system tools: zerotier-cli, systemctl, pkexec

Layout

src/
├── main.rs       window setup
├── app.rs        app state, screens and actions
├── zerotier.rs   typed wrapper around zerotier-cli
├── history.rs    persistent network history
└── theme.rs      colors, fonts and shared widgets

The window is created with a normal GPUI Application:

Application::new().run(|cx: &mut App| {
    let bounds = Bounds::centered(None, size(px(960.), px(680.)), cx);
    cx.open_window(WindowOptions {
        window_bounds: Some(WindowBounds::Windowed(bounds)),
        window_min_size: Some(size(px(800.), px(560.))),
        app_id: Some("zerotier-gui".into()),
        ..Default::default()
    }, |_, cx| cx.new(ZeroTierGui::new)).unwrap();
    cx.activate(true);
});

Shell commands run on GPUI’s background executor, then the result is applied on the foreground:

fn spawn_blocking<T, F, D>(cx: &mut Context<Self>, work: F, done: D)
where
    F: FnOnce() -> T + Send + 'static,
    T: Send + 'static,
    D: FnOnce(&mut Self, T, &mut Context<Self>) + 'static,
{
    cx.spawn(async move |this, cx| {
        let value = cx.background_executor().spawn(async move { work() }).await;
        this.update(cx, |this, cx| {
            done(this, value, cx);
            cx.notify();
        }).ok();
    }).detach();
}

That keeps refreshes and service actions off the UI thread.

Talking to ZeroTier

All network and peer data comes from the CLI’s JSON output:

pub fn list_networks() -> Result<Vec<Network>, CliError> {
    let raw = run_cli(&["-j", "listnetworks"])?;
    serde_json::from_str(&raw).map_err(|error| {
        CliError::new(
            CliErrorKind::Parse,
            format!("could not parse zerotier-cli output: {error}"),
        )
    })
}

Exit codes are mapped to the situations the UI can explain:

  • 1 → service not running
  • 2 → no access to the auth token
  • 127 → zerotier-cli not installed

Interface state is read from /sys/class/net/<iface>/operstate. TUN/TAP devices often report unknown even when they are up, so I fall back to the interface flags and check IFF_UP.

Service state comes from systemctl show --property=ActiveState, and interface changes go through pkexec ip link set.

The Linux-only actions are compiled behind cfg(target_os = "linux"). On macOS and Windows the network, peer and path views still work through zerotier-cli; the systemd/polkit controls are hidden.

Network history

The history file stays compatible with the Python app:

~/.local/share/zerotier-gui/network_history.json

On macOS and Windows the path follows the platform data directory. A successful join writes the network ID, name and a YYYY/MM/DD HH:MM timestamp. The same entry is updated when the controller returns the real network name.

Build and test

On Debian/Ubuntu, GPUI needs the XKB development packages:

sudo apt install build-essential libxkbcommon-dev libxkbcommon-x11-dev libxcb1-dev
cargo build --release
cargo test
cargo clippy --all-targets -- -D warnings

The binary is target/release/zerotier-gui.

CI and release

GitHub Actions builds and tests the project on Linux, macOS and Windows. The Linux job also runs cargo fmt --check and Clippy. The release workflow is attached to tags:

  • Linux: portable .tar.gz plus a Debian .deb
  • macOS: .zip with an app bundle
  • Windows: .zip with the executable

The v2.0.0 release is the first build from the rewrite.

Pitfalls worth remembering

  • libc pin. GPUI 0.2.2 pulls zed-async-tar, which pulls xattr 0.2.3. That version references libc::ENOATTR, which libc 0.2.186 removed on Linux. I pinned libc = "=0.2.185" until the chain is updated.
  • XKB libraries. GPUI links -lxkbcommon and -lxkbcommon-x11. The runtime libraries were present but the dev symlinks were not; installing libxkbcommon-dev and libxkbcommon-x11-dev fixed the link step.
  • Scrollable GPUI elements. overflow_y_scroll belongs to StatefulInteractiveElement. The element must get an .id() before calling it.
  • Windows attribute. windows_subsystem should only be applied on Windows: cfg_attr(all(windows, not(debug_assertions)), ...).
  • Cross-platform commands. systemctl, pkexec and sysfs are Linux-only. They compile behind cfg gates, and the UI only exposes those buttons on Linux.

Result

The rewrite is packaged and published. The source is GPL-3.0, the original Python/Tkinter project is credited, and the app runs as a native Rust binary on the three desktop platforms.

Next I want to keep testing it against more ZeroTier network types and tighten the join/history flow.

Screenshots

ZeroTier-GUI network list
Joined networks and interface status

ZeroTier-GUI peers
Peers with role, latency and path count