chore(rust/gui-client): start a GTK 3 prototype (#6838)

Refs #6927

This PR creates a GTK+ event loop, a blank window, and the tray menu. It
connects to the IPC service, you can sign in and everything, but the
About window, Settings window, and Welcome window aren't implemented.

We build a deb package in CI but it isn't pushed to the draft releases
in CD yet.


![image](https://github.com/user-attachments/assets/a0759021-c8c2-4232-8538-654800f29802)

Pros over Iced:
- More mature
- Easy integration with `tray-icon`
- Small binaries (< 1 MB for this example)

Cons:
- GTK 3.x is abandoned as of March. GTK 4 isn't packaged for Ubuntu
20.04.
- Widgets might be hard to use
- Hard to set up on Windows, only using this for Linux for now

---------

Signed-off-by: Reactor Scram <ReactorScram@users.noreply.github.com>
Co-authored-by: Thomas Eizinger <thomas@eizinger.io>
This commit is contained in:
Reactor Scram
2024-10-07 10:08:19 -05:00
committed by GitHub
parent 9b93fc2a2c
commit 29b5a3c3c4
11 changed files with 6060 additions and 2 deletions

2
.github/codespellrc vendored
View File

@@ -1,3 +1,3 @@
[codespell]
skip = ./**/*.svg,./elixir/deps,./**/*.min.js,./kotlin/android/app/build,./kotlin/android/build,./e2e/pnpm-lock.yaml,./website/.next,./website/pnpm-lock.yaml,./rust/connlib/tunnel/testcases,./rust/iced-client/target,./rust/target,Cargo.lock,./website/docs/reference/api/*.mdx,./**/erl_crash.dump,./cover,./vendor,*.json,seeds.exs,./**/node_modules,./deps,./priv/static,./priv/plts,./**/priv/static,./.git,./_build,*.cast,./**/proptest-regressions
skip = ./**/*.svg,./elixir/deps,./**/*.min.js,./kotlin/android/app/build,./kotlin/android/build,./e2e/pnpm-lock.yaml,./website/.next,./website/pnpm-lock.yaml,./rust/connlib/tunnel/testcases,./rust/gtk-client/target,./rust/iced-client/target,./rust/target,Cargo.lock,./website/docs/reference/api/*.mdx,./**/erl_crash.dump,./cover,./vendor,*.json,seeds.exs,./**/node_modules,./deps,./priv/static,./priv/plts,./**/priv/static,./.git,./_build,*.cast,./**/proptest-regressions
ignore-words-list = optin,crate,keypair,keypairs,iif,statics,wee,anull,commitish,inout,fo,superceded

52
.github/workflows/_gtk.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
---
name: GTK
"on":
workflow_call:
defaults:
run:
working-directory: ./rust/gtk-client
permissions:
contents: 'read'
id-token: 'write' # Needed to publish artifacts to releases?
# Never tolerate warnings. Source of truth is `_rust.yml`
env:
RUSTFLAGS: "-Dwarnings"
RUSTDOCFLAGS: "-D warnings"
jobs:
build-gtk:
name: build-gtk-${{ matrix.runs-on }}
runs-on: ${{ matrix.runs-on }}
strategy:
fail-fast: false
matrix:
include:
- runs-on: ubuntu-20.04
arch: x86_64
- runs-on: ubuntu-22.04-arm
arch: aarch64
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-rust
- name: fmt
run: cargo fmt
- name: Install deps
run: sudo apt-get update && sudo apt-get install libgtk-3-dev libxdo-dev && cargo install cargo-deb@2.7.0
- name: Clippy
run: cargo clippy --all-targets
- name: Test
run: cargo test
- name: Build
run: ./build.sh
- name: Upload package
uses: actions/upload-artifact@v4
with:
# mark:next-gui-version
name: firezone-client-gui-gtk-linux_1.3.8_${{ matrix.arch }}-pkg
# This ignores the working dir or something
path: rust/gtk-client/target/debian/firezone-client-gui.deb
if-no-files-found: error
# TODO: Upload debug symbols

View File

@@ -11,7 +11,7 @@ permissions:
contents: 'read'
id-token: 'write'
# Never tolerate warnings. Duplicated in `_tauri.yml`
# Never tolerate warnings. Duplicated in `_gtk.yml` and `_tauri.yml`
env:
RUSTFLAGS: "-Dwarnings"
RUSTDOCFLAGS: "-D warnings"

View File

@@ -19,6 +19,8 @@ concurrency:
cancel-in-progress: ${{ github.event_name != 'workflow_call' }}
jobs:
gtk:
uses: ./.github/workflows/_gtk.yml
kotlin:
uses: ./.github/workflows/_kotlin.yml
secrets: inherit

5486
rust/gtk-client/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
[package]
authors = ["Firezone, Inc."]
default-run = "firezone-gui-client"
description = "Firezone"
name = "firezone-gui-client"
# mark:next-gui-version
version = "1.3.8"
edition = "2021"
[dependencies]
anyhow = "1.0.89"
clap = { version = "4.5", features = ["derive", "env"] }
firezone-bin-shared = { path = "../bin-shared" }
firezone-gui-client-common = { path = "../gui-client/src-common" }
firezone-headless-client = { path = "../headless-client" }
firezone-logging = { path = "../logging" }
firezone-telemetry = { path = "../telemetry" }
glib = "0.18.5"
gtk = "0.18.1"
notify-rust = "4.11.3"
open = "5.3.0"
rustls = { version = "0.23.10", default-features = false, features = ["ring"] }
secrecy = "0.8"
serde_json = "1.0.128"
tokio = { version = "1.40.0", features = ["rt-multi-thread", "sync", "time"] }
tracing = "0.1.40"
tray-icon = "0.19.0"
url = "2.5.2"
[patch.crates-io]
boringtun = { git = "https://github.com/cloudflare/boringtun", branch = "master" }
str0m = { git = "https://github.com/algesten/str0m", branch = "main" }
ip_network = { git = "https://github.com/JakubOnderka/ip_network", branch = "master" } # Waiting for release.
ip_network_table = { git = "https://github.com/edmonds/ip_network_table", branch = "some-useful-traits" } # For `Debug` and `Clone`
tracing-stackdriver = { git = "https://github.com/thomaseizinger/tracing-stackdriver", branch = "deps/bump-otel-0.23" } # Waiting for release.
[profile.release]
codegen-units = 1
#debug = "full"
lto = "thin" # Don't have enough RAM in my VM to do fat LTO
split-debuginfo = "packed"
strip = "none"
[workspace]
[package.metadata.deb]
assets = [
["../gui-client/src-tauri/deb_files/sysusers.conf", "usr/lib/sysusers.d/firezone-client-ipc.conf", "644"],
["../gui-client/src-tauri/deb_files/firezone-client-ipc.service", "usr/lib/systemd/system/", "644"],
["../gui-client/src-tauri/icons/128x128.png", "/usr/share/icons/hicolor/128x128/apps/firezone-client-gui.png", "644"],
# TODO: Once Tauri is removed on Linux, we can move this under `/usr/lib` so it's out of $PATH. We don't want users accidentally running it. For now Tauri and GTK share the systemd service unit,
["target/release/firezone-client-ipc", "usr/bin/", "755"],
["target/release/firezone-gui-client", "usr/bin/firezone-client-gui", "755"],
]
maintainer-scripts = "../gui-client/src-tauri/deb_files"

38
rust/gtk-client/README.md Normal file
View File

@@ -0,0 +1,38 @@
# gtk-client
This crate houses a GTK+ 3 Client for Ubuntu 20.04, 22.04, and 24.04.
## Setup
1. [Install rustup](https://rustup.rs/)
1. `sudo apt-get install libgtk-3-dev libxdo-dev`
1. `cargo install cargo-deb@2.7.0`
## Debugging
```bash
cargo build
# In one terminal
sudo -u root -g firezone-client target/debug/firezone-client-ipc run-debug
# Concurrently, in a 2nd terminal
target/debug/firezone-gui-client
```
## Building
`./build.sh`
This will install dev dependencies such as `libgtk-3-dev`, and the bundling tool `cargo-deb`.
## Installing
`sudo apt-get install target/debian/firezone-client-gui.deb`
## Platform support
- `aarch64` or `x86_64` CPU architecture
- Ubuntu 20.04 through 24.04 inclusive
Other distributions may work but are not officially supported.

10
rust/gtk-client/build.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
# Delete old deb packages so the `mv` glob will work later on
rm -f target/debian/*.deb
cargo deb
mv target/debian/*.deb target/debian/firezone-client-gui.deb
ls target/debian

View File

@@ -0,0 +1,7 @@
fn main() -> anyhow::Result<()> {
rustls::crypto::ring::default_provider()
.install_default()
.expect("Calling `install_default` only once per process should always succeed");
firezone_headless_client::run_only_ipc_service()
}

407
rust/gtk-client/src/main.rs Normal file
View File

@@ -0,0 +1,407 @@
use anyhow::{Context as _, Result};
use clap::{Args, Parser};
use firezone_gui_client_common::{
self as common,
compositor::{self, Image},
controller::{Builder as ControllerBuilder, ControllerRequest, CtlrTx, GuiIntegration},
deep_link,
system_tray::{AppState, ConnlibState, Entry, Icon, IconBase},
updates,
};
use firezone_headless_client::LogFilterReloader;
use firezone_telemetry as telemetry;
use gtk::prelude::*;
use gtk::{Application, ApplicationWindow};
use secrecy::{ExposeSecret as _, SecretString};
use std::str::FromStr;
use tokio::sync::mpsc;
use tray_icon::{menu::MenuEvent, TrayIconBuilder};
// TODO: De-dupe icon compositing with the Tauri Client.
// Figma is the source of truth for the tray icon layers
// <https://www.figma.com/design/THvQQ1QxKlsk47H9DZ2bhN/Core-Library?node-id=1250-772&t=nHBOzOnSY5Ol4asV-0>
const LOGO_BASE: &[u8] = include_bytes!("../../gui-client/src-tauri/icons/tray/Logo.png");
const LOGO_GREY_BASE: &[u8] = include_bytes!("../../gui-client/src-tauri/icons/tray/Logo grey.png");
const BUSY_LAYER: &[u8] = include_bytes!("../../gui-client/src-tauri/icons/tray/Busy layer.png");
const SIGNED_OUT_LAYER: &[u8] =
include_bytes!("../../gui-client/src-tauri/icons/tray/Signed out layer.png");
const UPDATE_READY_LAYER: &[u8] =
include_bytes!("../../gui-client/src-tauri/icons/tray/Update ready layer.png");
const TOOLTIP: &str = "Firezone";
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Cmd>,
}
#[derive(clap::Subcommand)]
enum Cmd {
Debug,
OpenDeepLink(DeepLink),
}
#[derive(Args)]
struct DeepLink {
url: url::Url, // TODO: Should be `Secret`?
}
fn main() -> Result<()> {
let current_exe = std::env::current_exe()?;
let cli = Cli::parse();
match cli.command {
Some(Cmd::Debug) => return Ok(()), // I didn't want to use `if-let` here
Some(Cmd::OpenDeepLink(deep_link)) => {
let rt = tokio::runtime::Runtime::new()?;
if let Err(error) = rt.block_on(deep_link::open(&deep_link.url)) {
tracing::error!(?error, "Error in `OpenDeepLink`");
}
return Ok(());
}
None => {}
}
// We're not a deep link handler, so start telemetry
let telemetry = telemetry::Telemetry::default();
// TODO: Fix missing stuff for telemetry
telemetry.start(
"wss://api.firez.one",
firezone_bin_shared::git_version!("gtk-client-*"),
telemetry::GUI_DSN,
);
let common::logging::Handles {
logger: _logger,
reloader: log_filter_reloader,
} = start_logging("debug")?; // TODO
// The runtime must be multi-thread so that the main thread is free for GTK to consume
// As long as Tokio has at least 1 worker thread (i.e. there is at least 1 CPU core in the system) this will work.
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let _guard = rt.enter();
// This enforces single-instance
let deep_link_server = rt.block_on(deep_link::Server::new())?;
let app = Application::builder()
.application_id("dev.firezone.client")
.build();
app.connect_activate(|app| {
// We create the main window.
let win = ApplicationWindow::builder()
.application(app)
.default_width(640)
.default_height(480)
.title("Firezone GTK+ 3")
.build();
// Don't forget to make all widgets visible.
win.show_all();
});
gtk::init()?;
let tray_icon = TrayIconBuilder::new()
.with_tooltip(TOOLTIP)
.with_icon(icon_to_native_icon(&Icon::default()))
.build()?;
let (ctlr_tx, ctlr_rx) = mpsc::channel(100);
deep_link::register(current_exe)?;
rt.spawn(accept_deep_links(deep_link_server, ctlr_tx.clone()));
{
let ctlr_tx = ctlr_tx.clone();
MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
let Ok(event) = serde_json::from_str::<common::system_tray::Event>(&event.id.0) else {
tracing::error!("Couldn't parse system tray event");
return;
};
if let Err(error) = ctlr_tx.blocking_send(ControllerRequest::SystemTrayMenu(event)) {
tracing::error!(?error, "Couldn't send system tray event to Controller");
}
}));
}
let (_updates_tx, updates_rx) = mpsc::channel(1);
let (main_tx, main_rx) = mpsc::channel(100);
rt.spawn(run_controller(
main_tx,
ctlr_tx,
ctlr_rx,
log_filter_reloader,
telemetry,
updates_rx,
));
let l = MainThreadLoop {
app: app.clone(),
last_icon_set: Default::default(),
main_rx,
tray_icon,
};
glib::spawn_future_local(l.run());
if app.run() != 0.into() {
anyhow::bail!("GTK main loop returned non-zero exit code");
}
Ok(())
}
// Worker task to accept deep links from a named pipe forever
///
/// * `server` An initial named pipe server to consume before making new servers. This lets us also use the named pipe to enforce single-instance
async fn accept_deep_links(mut server: deep_link::Server, ctlr_tx: CtlrTx) -> Result<()> {
loop {
match server.accept().await {
Ok(bytes) => {
let url = SecretString::from_str(
std::str::from_utf8(bytes.expose_secret())
.context("Incoming deep link was not valid UTF-8")?,
)
.context("Impossible: can't wrap String into SecretString")?;
// Ignore errors from this, it would only happen if the app is shutting down, otherwise we would wait
ctlr_tx
.send(ControllerRequest::SchemeRequest(url))
.await
.ok();
}
Err(error) => tracing::error!(?error, "error while accepting deep link"),
}
// We re-create the named pipe server every time we get a link, because of an oddity in the Windows API.
server = deep_link::Server::new().await?;
}
}
/// Handles messages from other tasks / thread to our GTK main thread, such as quitting the app and changing the tray menu.
#[must_use]
struct MainThreadLoop {
app: gtk::Application,
last_icon_set: Icon,
main_rx: mpsc::Receiver<MainThreadReq>,
tray_icon: tray_icon::TrayIcon,
}
impl MainThreadLoop {
/// Handle messages that must be handled on the main thread where GTK is
///
/// This function typically never returns, GLib just stops polling it when the GTK app quits
async fn run(mut self) {
while let Some(req) = self.main_rx.recv().await {
if let Err(error) = self.handle_req(req) {
tracing::error!(?error, "`MainThreadLoop::handle_req` failed");
}
}
}
fn handle_req(&mut self, req: MainThreadReq) -> Result<()> {
match req {
MainThreadReq::Quit => self.app.quit(),
MainThreadReq::SetTrayMenu(app_state) => self.set_tray_menu(*app_state)?,
}
Ok(())
}
fn set_tray_menu(&mut self, state: AppState) -> Result<()> {
let base = match &state.connlib {
ConnlibState::Loading
| ConnlibState::Quitting
| ConnlibState::RetryingConnection
| ConnlibState::WaitingForBrowser
| ConnlibState::WaitingForPortal
| ConnlibState::WaitingForTunnel => IconBase::Busy,
ConnlibState::SignedOut => IconBase::SignedOut,
ConnlibState::SignedIn { .. } => IconBase::SignedIn,
};
let new_icon = Icon {
base,
update_ready: state.release.is_some(),
};
let menu = build_menu("", &state.into_menu())?;
self.tray_icon.set_menu(Some(Box::new(menu)));
// TODO: Set menu tooltip here too
self.set_icon(new_icon)?;
Ok(())
}
fn set_icon(&mut self, icon: Icon) -> Result<()> {
if icon == self.last_icon_set {
return Ok(());
}
// TODO: Does `tray-icon` have the same problem as `tao`,
// where it writes PNGs to `/run/user/$UID/` every time you set an icon?
self.tray_icon.set_icon(Some(icon_to_native_icon(&icon)))?;
self.last_icon_set = icon;
Ok(())
}
}
fn icon_to_native_icon(that: &Icon) -> tray_icon::Icon {
let layers = match that.base {
IconBase::Busy => &[LOGO_GREY_BASE, BUSY_LAYER][..],
IconBase::SignedIn => &[LOGO_BASE][..],
IconBase::SignedOut => &[LOGO_GREY_BASE, SIGNED_OUT_LAYER][..],
}
.iter()
.copied()
.chain(that.update_ready.then_some(UPDATE_READY_LAYER));
let composed =
compositor::compose(layers).expect("PNG decoding should always succeed for baked-in PNGs");
image_to_native_icon(composed)
}
fn image_to_native_icon(val: Image) -> tray_icon::Icon {
tray_icon::Icon::from_rgba(val.rgba, val.width, val.height)
.expect("Converting a tray icon to RGBA should always work")
}
fn build_menu(text: &str, that: &common::system_tray::Menu) -> Result<tray_icon::menu::Submenu> {
let menu = tray_icon::menu::Submenu::new(text, true);
for entry in &that.entries {
match entry {
Entry::Item(item) if item.checked.is_some() => {
menu.append(&build_checked_item(item))?
}
Entry::Item(item) => menu.append(&build_item(item))?,
Entry::Separator => menu.append(&tray_icon::menu::PredefinedMenuItem::separator())?,
Entry::Submenu { title, inner } => menu.append(&build_menu(title, inner)?)?,
};
}
Ok(menu)
}
fn build_checked_item(that: &common::system_tray::Item) -> tray_icon::menu::CheckMenuItem {
let id = serde_json::to_string(&that.event)
.expect("`serde_json` should always be able to serialize tray menu events");
tray_icon::menu::CheckMenuItem::with_id(
id,
&that.title,
that.event.is_some(),
that.checked.unwrap_or_default(),
None,
)
}
fn build_item(that: &common::system_tray::Item) -> tray_icon::menu::MenuItem {
let id = serde_json::to_string(&that.event)
.expect("`serde_json` should always be able to serialize tray menu events");
tray_icon::menu::MenuItem::with_id(id, &that.title, that.event.is_some(), None)
}
/// Something that needs to be done on the GTK+ main thread.
enum MainThreadReq {
/// The controller exited, quit the GTK app and exit the loop
Quit,
SetTrayMenu(Box<common::system_tray::AppState>),
}
async fn run_controller(
main_tx: mpsc::Sender<MainThreadReq>, // Runs stuff on the main thread
ctlr_tx: CtlrTx,
rx: mpsc::Receiver<ControllerRequest>,
log_filter_reloader: LogFilterReloader,
telemetry: telemetry::Telemetry,
updates_rx: mpsc::Receiver<Option<updates::Notification>>,
) -> Result<()> {
let integration = GtkIntegration {
main_tx: main_tx.clone(),
};
let controller = ControllerBuilder {
advanced_settings: Default::default(), // TODO
ctlr_tx,
integration,
log_filter_reloader,
rx,
telemetry,
updates_rx,
}
.build()
.await?;
let result = controller.main_loop().await;
if let Err(error) = &result {
tracing::error!(?error, "`Controller` failed");
}
main_tx.send(MainThreadReq::Quit).await?;
Ok(result?)
}
struct GtkIntegration {
main_tx: mpsc::Sender<MainThreadReq>,
}
impl GuiIntegration for GtkIntegration {
fn set_welcome_window_visible(&self, _visible: bool) -> Result<()> {
tracing::warn!("set_welcome_window_visible not implemented");
Ok(())
}
fn open_url<P: AsRef<str>>(&self, url: P) -> Result<()> {
open::that(std::ffi::OsStr::new(url.as_ref()))?;
Ok(())
}
fn set_tray_icon(&mut self, _icon: common::system_tray::Icon) -> Result<()> {
tracing::warn!("set_tray_icon not implemented");
Ok(())
}
fn set_tray_menu(&mut self, app_state: common::system_tray::AppState) -> Result<()> {
self.main_tx
.try_send(MainThreadReq::SetTrayMenu(Box::new(app_state)))?;
Ok(())
}
fn show_notification(&self, title: &str, body: &str) -> Result<()> {
notify_rust::Notification::new()
.icon("/usr/share/icons/hicolor/128x128/apps/firezone-client-gui.png")
.summary(title)
.body(body)
.show()?;
Ok(())
}
fn show_update_notification(
&self,
_ctlr_tx: CtlrTx,
_title: &str,
_url: url::Url,
) -> Result<()> {
tracing::warn!("show_update_notification not implemented");
Ok(())
}
fn show_window(&self, _window: common::system_tray::Window) -> Result<()> {
tracing::warn!("show_window not implemented");
Ok(())
}
}
/// Starts logging
///
/// Don't drop the log handle or logging will stop.
fn start_logging(directives: &str) -> Result<common::logging::Handles> {
let logging_handles = common::logging::setup(directives)?;
tracing::info!(
arch = std::env::consts::ARCH,
os = std::env::consts::OS,
?directives,
git_version = firezone_bin_shared::git_version!("gui-client-*"),
system_uptime_seconds = firezone_headless_client::uptime::get().map(|dur| dur.as_secs()),
"`gui-client` started logging"
);
Ok(logging_handles)
}

View File

@@ -27,6 +27,7 @@ pub(crate) enum Error {
pub(crate) fn run() -> Result<()> {
let cli = Cli::parse();
// TODO: Remove, this is only needed for Portal connections and the GUI process doesn't connect to the Portal. Unless it's also needed for update checks.
rustls::crypto::ring::default_provider()
.install_default()
.expect("Calling `install_default` only once per process should always succeed");