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:
- Code: github.com/senasgr-eth/zerotier-gui-rs
- Release: v2.0.0
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,allowManagedandallowDNS - browse peers with role, latency and path count
- inspect active, expired and preferred paths
- start/stop the
zerotier-oneservice on Linux - bring a ZeroTier interface up/down through
pkexecon 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_jsonfor the ZeroTier JSON outputchronofor history timestampsopenfor 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 running2→ no access to the auth token127→zerotier-clinot 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.gzplus a Debian.deb - macOS:
.zipwith an app bundle - Windows:
.zipwith the executable
The v2.0.0 release is the first build from the rewrite.
Pitfalls worth remembering
libcpin. GPUI 0.2.2 pullszed-async-tar, which pullsxattr 0.2.3. That version referenceslibc::ENOATTR, which libc 0.2.186 removed on Linux. I pinnedlibc = "=0.2.185"until the chain is updated.- XKB libraries. GPUI links
-lxkbcommonand-lxkbcommon-x11. The runtime libraries were present but the dev symlinks were not; installinglibxkbcommon-devandlibxkbcommon-x11-devfixed the link step. - Scrollable GPUI elements.
overflow_y_scrollbelongs toStatefulInteractiveElement. The element must get an.id()before calling it. - Windows attribute.
windows_subsystemshould only be applied on Windows:cfg_attr(all(windows, not(debug_assertions)), ...). - Cross-platform commands.
systemctl,pkexecand 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

