refactor(gui-client): inline -common crate (#9022)

In order to experiment with alternative GUI libraries, we extracted a
`gui-client-common` crate that would hold GUI-library agnostic code.
We've since upgraded to Tauri v2 and settled on that as the GUI
framework for the Windows and Linux Firezone Clients. Therefore this
abstraction is unnecessary and can be removed again.

This makes it easier to work on the GUI client and also allows the
compiler to flag unused code more easily.
This commit is contained in:
Thomas Eizinger
2025-05-06 12:28:03 +10:00
committed by GitHub
parent f11a902b3d
commit c20cc779ac
34 changed files with 1714 additions and 343 deletions

View File

@@ -25,7 +25,7 @@ outputs:
value: ${{
(runner.os == 'Linux' && '--workspace') ||
(runner.os == 'macOS' && '-p connlib-client-apple -p connlib-client-shared -p firezone-tunnel -p snownet') ||
(runner.os == 'Windows' && '-p connlib-client-shared -p connlib-model -p firezone-bin-shared -p firezone-gui-client -p firezone-gui-client-common -p firezone-headless-client -p firezone-logging -p firezone-telemetry -p firezone-tunnel -p gui-smoke-test -p http-test-server -p ip-packet -p phoenix-channel -p snownet -p socket-factory -p tun') }}
(runner.os == 'Windows' && '-p connlib-client-shared -p connlib-model -p firezone-bin-shared -p firezone-gui-client -p firezone-headless-client -p firezone-logging -p firezone-telemetry -p firezone-tunnel -p gui-smoke-test -p http-test-server -p ip-packet -p phoenix-channel -p snownet -p socket-factory -p tun') }}
nightly_version:
description: The nightly version of Rust
value: ${{ steps.nightly.outputs.nightly }}

58
rust/Cargo.lock generated
View File

@@ -156,9 +156,9 @@ checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "arbitrary"
version = "1.3.2"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110"
checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
dependencies = [
"derive_arbitrary",
]
@@ -1583,9 +1583,9 @@ dependencies = [
[[package]]
name = "derive_arbitrary"
version = "1.3.2"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611"
checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800"
dependencies = [
"proc-macro2",
"quote",
@@ -2188,24 +2188,31 @@ name = "firezone-gui-client"
version = "1.4.13"
dependencies = [
"anyhow",
"arboard",
"atomicwrites",
"chrono",
"clap",
"connlib-client-shared",
"connlib-model",
"derive_more 1.0.0",
"dirs 5.0.1",
"firezone-bin-shared",
"firezone-gui-client-common",
"firezone-headless-client",
"firezone-logging",
"firezone-telemetry",
"futures",
"hex",
"keyring",
"native-dialog",
"nix 0.29.0",
"output_vt100",
"png",
"rand 0.8.5",
"reqwest",
"rustls",
"sadness-generator",
"secrecy",
"semver",
"serde",
"serde_json",
"subtle",
@@ -2220,52 +2227,15 @@ dependencies = [
"tauri-winrt-notification",
"thiserror 1.0.69",
"tokio",
"tokio-util",
"tracing",
"tracing-subscriber",
"url",
"uuid",
"windows 0.61.1",
]
[[package]]
name = "firezone-gui-client-common"
version = "1.4.13"
dependencies = [
"anyhow",
"arboard",
"atomicwrites",
"connlib-model",
"derive_more 1.0.0",
"dirs 5.0.1",
"firezone-bin-shared",
"firezone-headless-client",
"firezone-logging",
"firezone-telemetry",
"futures",
"hex",
"keyring",
"native-dialog",
"output_vt100",
"png",
"rand 0.8.5",
"reqwest",
"sadness-generator",
"secrecy",
"semver",
"serde",
"serde_json",
"subtle",
"thiserror 1.0.69",
"time",
"tokio",
"tokio-stream",
"tokio-util",
"tracing",
"tracing-journald",
"tracing-log",
"tracing-subscriber",
"url",
"uuid",
"windows 0.61.1",
"winreg 0.52.0",
"zip",
]

View File

@@ -14,7 +14,6 @@ members = [
"dns-types",
"etherparse-ext",
"gateway",
"gui-client/src-common",
"gui-client/src-tauri",
"headless-client",
"ip-packet",
@@ -73,7 +72,6 @@ env_logger = "0.11.6"
etherparse = { version = "0.17", default-features = false }
etherparse-ext = { path = "etherparse-ext" }
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-relay = { path = "relay/server" }

View File

@@ -1,51 +0,0 @@
[package]
name = "firezone-gui-client-common"
# mark:next-gui-version
version = "1.4.13"
edition = { workspace = true }
license = { workspace = true }
[dependencies]
anyhow = { workspace = true }
arboard = { workspace = true }
atomicwrites = { workspace = true }
connlib-model = { workspace = true }
derive_more = { workspace = true, features = ["debug"] }
firezone-bin-shared = { workspace = true }
firezone-headless-client = { workspace = true }
firezone-logging = { workspace = true }
firezone-telemetry = { workspace = true }
futures = { workspace = true }
hex = { workspace = true }
keyring = { workspace = true, features = ["crypto-rust", "sync-secret-service", "windows-native"] }
native-dialog = { workspace = true }
output_vt100 = { workspace = true }
png = { workspace = true } # `png` is mostly free since we already need it for Tauri
rand = { workspace = true }
reqwest = { workspace = true, features = ["stream", "rustls-tls"] }
sadness-generator = { workspace = true }
secrecy = { workspace = true }
semver = { workspace = true, features = ["serde"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
subtle = { workspace = true }
thiserror = { workspace = true }
time = { workspace = true, features = ["formatting"] }
tokio = { workspace = true }
tokio-stream = { workspace = true }
tracing = { workspace = true }
tracing-log = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
url = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
zip = { workspace = true, features = ["deflate", "time"] }
[target.'cfg(target_os = "linux")'.dependencies]
dirs = { workspace = true }
tracing-journald = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = { workspace = true }
[lints]
workspace = true

View File

@@ -1,18 +0,0 @@
#![cfg_attr(test, allow(clippy::unwrap_used))]
pub mod auth;
pub mod compositor;
pub mod controller;
pub mod deep_link;
pub mod ipc;
pub mod logging;
pub mod settings;
pub mod system_tray;
pub mod updates;
pub mod uptime;
/// The Sentry "release" we are part of.
///
/// IPC service and GUI client are always bundled into a single release.
/// Hence, we have a single constant for IPC service and GUI client.
pub const RELEASE: &str = concat!("gui-client@", env!("CARGO_PKG_VERSION"));

View File

@@ -14,22 +14,29 @@ tauri-build = { workspace = true, features = [] }
[dependencies]
anyhow = { workspace = true }
arboard = { workspace = true }
atomicwrites = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true, features = ["derive", "env"] }
connlib-client-shared = { workspace = true }
connlib-model = { workspace = true }
derive_more = { workspace = true, features = ["debug"] }
firezone-bin-shared = { workspace = true }
firezone-gui-client-common = { workspace = true }
firezone-headless-client = { workspace = true }
firezone-logging = { workspace = true }
firezone-telemetry = { workspace = true }
futures = { workspace = true }
hex = { workspace = true }
keyring = { workspace = true, features = ["crypto-rust", "sync-secret-service", "windows-native"] }
native-dialog = { workspace = true }
output_vt100 = { workspace = true }
png = { workspace = true } # `png` is mostly free since we already need it for Tauri
rand = { workspace = true }
reqwest = { workspace = true, features = ["stream", "rustls-tls"] }
rustls = { workspace = true }
sadness-generator = { workspace = true }
secrecy = { workspace = true }
semver = { workspace = true, features = ["serde"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
subtle = { workspace = true }
@@ -42,20 +49,25 @@ tauri-runtime = { workspace = true }
tauri-utils = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["signal", "time", "macros", "rt", "rt-multi-thread"] }
tokio-stream = { workspace = true }
tokio-util = { workspace = true, features = ["codec"] }
tracing = { workspace = true }
tracing-log = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
url = { workspace = true, features = ["serde"] }
url = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
zip = { workspace = true, features = ["deflate", "time"] }
[target.'cfg(target_os = "linux")'.dependencies]
dirs = { workspace = true }
nix = { workspace = true, features = ["user"] }
tracing-journald = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
[target.'cfg(target_os = "windows")'.dependencies]
tauri-winrt-notification = "0.7.2"
winreg = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies.windows]
workspace = true

View File

@@ -83,12 +83,6 @@ pub struct Session {
pub(crate) actor_name: String,
}
impl Session {
pub fn account_slug(&self) -> &str {
&self.account_slug
}
}
struct SessionAndToken {
session: Session,
token: SecretString,

View File

@@ -1,19 +1,22 @@
use anyhow::{Context as _, Result, bail};
use clap::{Args, Parser};
use firezone_gui_client_common::{
self as common, controller::Failure, deep_link, settings::AdvancedSettings,
};
use firezone_headless_client::ipc;
use controller::Failure;
use firezone_telemetry::Telemetry;
use settings::AdvancedSettings;
use tracing::instrument;
use tracing_subscriber::EnvFilter;
mod about;
mod auth;
mod controller;
mod debug_commands;
mod deep_link;
mod elevation;
mod gui;
mod ipc;
mod logging;
mod settings;
mod updates;
mod welcome;
/// The program's entry point, equivalent to `main`
@@ -54,15 +57,15 @@ pub(crate) fn run() -> Result<()> {
}
Some(Cmd::SmokeTest) => {
// Can't check elevation here because the Windows CI is always elevated
let settings = common::settings::load_advanced_settings().unwrap_or_default();
let settings = settings::load_advanced_settings().unwrap_or_default();
let mut telemetry = Telemetry::default();
telemetry.start(
settings.api_url.as_ref(),
firezone_gui_client_common::RELEASE,
crate::RELEASE,
firezone_telemetry::GUI_DSN,
);
// Don't fix the log filter for smoke tests
let common::logging::Handles {
let logging::Handles {
logger: _logger,
reloader,
} = start_logging(&settings.log_filter)?;
@@ -85,12 +88,12 @@ pub(crate) fn run() -> Result<()> {
/// Automatically logs or shows error dialogs for important user-actionable errors
// Can't `instrument` this because logging isn't running when we enter it.
fn run_gui(cli: Cli) -> Result<()> {
let mut settings = common::settings::load_advanced_settings().unwrap_or_default();
let mut settings = settings::load_advanced_settings().unwrap_or_default();
let mut telemetry = Telemetry::default();
// In the future telemetry will be opt-in per organization, that's why this isn't just at the top of `main`
telemetry.start(
settings.api_url.as_ref(),
firezone_gui_client_common::RELEASE,
crate::RELEASE,
firezone_telemetry::GUI_DSN,
);
// Get the device ID before starting Tokio, so that all the worker threads will inherit the correct scope.
@@ -99,7 +102,7 @@ fn run_gui(cli: Cli) -> Result<()> {
Telemetry::set_firezone_id(id.id);
}
fix_log_filter(&mut settings)?;
let common::logging::Handles {
let logging::Handles {
logger: _logger,
reloader,
} = start_logging(&settings.log_filter)?;
@@ -125,7 +128,10 @@ fn run_gui(cli: Cli) -> Result<()> {
return Err(anyhow);
}
if anyhow.root_cause().is::<ipc::NotFound>() {
if anyhow
.root_cause()
.is::<firezone_headless_client::ipc::NotFound>()
{
show_error_dialog("Couldn't find Firezone IPC service. Is the service running?")?;
return Err(anyhow);
}
@@ -175,8 +181,8 @@ fn show_error_dialog(msg: &str) -> Result<()> {
/// 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)?;
fn start_logging(directives: &str) -> Result<logging::Handles> {
let logging_handles = logging::setup(directives)?;
let system_uptime_seconds = firezone_bin_shared::uptime::get().map(|dur| dur.as_secs());
tracing::info!(
arch = std::env::consts::ARCH,

View File

@@ -5,12 +5,19 @@
//! "Notification Area" is Microsoft's official name instead of "System tray":
//! <https://learn.microsoft.com/en-us/windows/win32/shell/notification-area?redirectedfrom=MSDN#notifications-and-the-notification-area>
use crate::updates::Release;
use anyhow::{Context as _, Result};
use firezone_gui_client_common::{
compositor::{self, Image},
system_tray::{AppState, ConnlibState, Entry, Event, Icon, IconBase, Item, Menu},
};
use compositor::Image;
use connlib_model::{ResourceId, ResourceStatus, ResourceView};
use std::collections::HashSet;
use tauri::AppHandle;
use url::Url;
use builder::item;
pub use builder::{Entry, Event, Item, Menu, Window};
mod builder;
mod compositor;
type IsMenuItem = dyn tauri::menu::IsMenuItem<tauri::Wry>;
type TauriMenu = tauri::menu::Menu<tauri::Wry>;
@@ -24,6 +31,25 @@ const BUSY_LAYER: &[u8] = include_bytes!("../../../icons/tray/Busy layer.png");
const SIGNED_OUT_LAYER: &[u8] = include_bytes!("../../../icons/tray/Signed out layer.png");
const UPDATE_READY_LAYER: &[u8] = include_bytes!("../../../icons/tray/Update ready layer.png");
const QUIT_TEXT_SIGNED_OUT: &str = "Quit Firezone";
const NO_ACTIVITY: &str = "[-] No activity";
const GATEWAY_CONNECTED: &str = "[O] Gateway connected";
const ALL_GATEWAYS_OFFLINE: &str = "[X] All Gateways offline";
const ENABLED_SYMBOL: &str = "<->";
const DISABLED_SYMBOL: &str = "";
const ADD_FAVORITE: &str = "Add to favorites";
const REMOVE_FAVORITE: &str = "Remove from favorites";
const FAVORITE_RESOURCES: &str = "Favorite Resources";
const RESOURCES: &str = "Resources";
const OTHER_RESOURCES: &str = "Other Resources";
const SIGN_OUT: &str = "Sign out";
const DISCONNECT_AND_QUIT: &str = "Disconnect and quit Firezone";
const DISABLE: &str = "Disable this resource";
const ENABLE: &str = "Enable this resource";
const TOOLTIP: &str = "Firezone";
pub(crate) struct Tray {
@@ -57,13 +83,8 @@ impl Tray {
on_event: impl Fn(&AppHandle, Event) + Send + Sync + 'static,
) -> Result<Self> {
let tray = tauri::tray::TrayIconBuilder::new()
.icon(icon_to_tauri_icon(
&firezone_gui_client_common::system_tray::Icon::default(),
))
.menu(&build_app_state(
&app,
&firezone_gui_client_common::system_tray::AppState::default().into_menu(),
)?)
.icon(icon_to_tauri_icon(&Icon::default()))
.menu(&build_app_state(&app, &AppState::default().into_menu())?)
.on_menu_event(move |app, event| {
let id = &event.id.0;
tracing::debug!(?id, "SystemTrayEvent::MenuItemClick");
@@ -224,3 +245,628 @@ fn build_item(app: &AppHandle, item: &Item) -> Result<Box<IsMenuItem>> {
};
Ok(item)
}
pub struct AppState {
pub connlib: ConnlibState,
pub release: Option<Release>,
}
impl Default for AppState {
fn default() -> AppState {
AppState {
connlib: ConnlibState::Loading,
release: None,
}
}
}
impl AppState {
pub fn into_menu(self) -> Menu {
let quit_text = match &self.connlib {
ConnlibState::Loading
| ConnlibState::Quitting
| ConnlibState::RetryingConnection
| ConnlibState::SignedOut
| ConnlibState::WaitingForBrowser
| ConnlibState::WaitingForPortal
| ConnlibState::WaitingForTunnel => QUIT_TEXT_SIGNED_OUT,
ConnlibState::SignedIn(_) => DISCONNECT_AND_QUIT,
};
let menu = match self.connlib {
ConnlibState::Loading => Menu::default().disabled("Loading..."),
ConnlibState::Quitting => Menu::default().disabled("Quitting..."),
ConnlibState::RetryingConnection => retrying_sign_in("Waiting for Internet access..."),
ConnlibState::SignedIn(x) => signed_in(&x),
ConnlibState::SignedOut => Menu::default().item(Event::SignIn, "Sign In"),
ConnlibState::WaitingForBrowser => signing_in("Waiting for browser..."),
ConnlibState::WaitingForPortal => signing_in("Connecting to Firezone Portal..."),
ConnlibState::WaitingForTunnel => signing_in("Raising tunnel..."),
};
menu.add_bottom_section(self.release, quit_text)
}
}
pub enum ConnlibState {
Loading,
Quitting,
RetryingConnection,
SignedIn(SignedIn),
SignedOut,
WaitingForBrowser,
WaitingForPortal,
WaitingForTunnel,
}
pub struct SignedIn {
pub actor_name: String,
pub favorite_resources: HashSet<ResourceId>,
pub resources: Vec<ResourceView>,
pub internet_resource_enabled: Option<bool>,
}
impl SignedIn {
fn is_favorite(&self, resource: &ResourceId) -> bool {
self.favorite_resources.contains(resource)
}
fn add_favorite_toggle(&self, submenu: &mut Menu, resource: ResourceId) {
if self.is_favorite(&resource) {
submenu.add_item(item(Event::RemoveFavorite(resource), REMOVE_FAVORITE).checked(true));
} else {
submenu.add_item(item(Event::AddFavorite(resource), ADD_FAVORITE).checked(false));
}
}
/// Builds the submenu that has the resource address, name, desc,
/// sites online, etc.
fn resource_submenu(&self, res: &ResourceView) -> Menu {
let mut submenu = Menu::default().resource_description(res);
if res.is_internet_resource() {
submenu.add_separator();
if self.is_internet_resource_enabled() {
submenu.add_item(item(Event::DisableInternetResource, DISABLE));
} else {
submenu.add_item(item(Event::EnableInternetResource, ENABLE));
}
}
if !res.is_internet_resource() {
self.add_favorite_toggle(&mut submenu, res.id());
}
if let Some(site) = res.sites().first() {
// Emojis may be causing an issue on some Ubuntu desktop environments.
let status = match res.status() {
ResourceStatus::Unknown => NO_ACTIVITY,
ResourceStatus::Online => GATEWAY_CONNECTED,
ResourceStatus::Offline => ALL_GATEWAYS_OFFLINE,
};
submenu
.separator()
.disabled("Site")
.copyable(&site.name) // Hope this is okay - The code is simpler if every enabled item sends an `Event` on click
.copyable(status)
} else {
submenu
}
}
fn is_internet_resource_enabled(&self) -> bool {
self.internet_resource_enabled.unwrap_or_default()
}
}
#[derive(Clone, PartialEq)]
pub struct Icon {
pub base: IconBase,
pub update_ready: bool,
}
/// Generic icon for unusual terminating cases like if the IPC service stops running
pub(crate) fn icon_terminating() -> Icon {
Icon {
base: IconBase::SignedOut,
update_ready: false,
}
}
#[derive(Clone, PartialEq)]
pub enum IconBase {
/// Must be equivalent to the default app icon, since we assume this is set when we start
Busy,
SignedIn,
SignedOut,
}
impl Default for Icon {
fn default() -> Self {
Self {
base: IconBase::Busy,
update_ready: false,
}
}
}
fn signed_in(signed_in: &SignedIn) -> Menu {
let SignedIn {
actor_name,
favorite_resources,
resources, // Make sure these are presented in the order we receive them
internet_resource_enabled,
..
} = signed_in;
let has_any_favorites = resources
.iter()
.any(|res| favorite_resources.contains(&res.id()));
let mut menu = Menu::default()
.disabled(format!("Signed in as {actor_name}"))
.item(Event::SignOut, SIGN_OUT)
.separator();
tracing::debug!(
resource_count = resources.len(),
"Building signed-in tray menu"
);
if has_any_favorites {
menu = menu.disabled(FAVORITE_RESOURCES);
// The user has some favorites and they're in the list, so only show those
// Always show Resources in the original order
for res in resources
.iter()
.filter(|res| favorite_resources.contains(&res.id()) || res.is_internet_resource())
{
let mut name = res.name().to_string();
if res.is_internet_resource() {
name = append_status(&name, internet_resource_enabled.unwrap_or_default());
}
menu = menu.add_submenu(name, signed_in.resource_submenu(res));
}
} else {
// No favorites, show every Resource normally, just like before
// the favoriting feature was created
// Always show Resources in the original order
menu = menu.disabled(RESOURCES);
for res in resources {
let mut name = res.name().to_string();
if res.is_internet_resource() {
name = append_status(&name, internet_resource_enabled.unwrap_or_default());
}
menu = menu.add_submenu(name, signed_in.resource_submenu(res));
}
}
if has_any_favorites {
let mut submenu = Menu::default();
// Always show Resources in the original order
for res in resources
.iter()
.filter(|res| !favorite_resources.contains(&res.id()) && !res.is_internet_resource())
{
submenu = submenu.add_submenu(res.name(), signed_in.resource_submenu(res));
}
menu = menu.separator().add_submenu(OTHER_RESOURCES, submenu);
}
menu
}
fn retrying_sign_in(waiting_message: &str) -> Menu {
Menu::default()
.disabled(waiting_message)
.item(Event::RetryPortalConnection, "Retry sign-in")
.item(Event::CancelSignIn, "Cancel sign-in")
}
fn signing_in(waiting_message: &str) -> Menu {
Menu::default()
.disabled(waiting_message)
.item(Event::CancelSignIn, "Cancel sign-in")
}
fn append_status(name: &str, enabled: bool) -> String {
let symbol = if enabled {
ENABLED_SYMBOL
} else {
DISABLED_SYMBOL
};
format!("{symbol} {name}")
}
impl Menu {
/// Appends things that always show, like About, Settings, Help, Quit, etc.
pub(crate) fn add_bottom_section(mut self, release: Option<Release>, quit_text: &str) -> Self {
self = self.separator();
if let Some(release) = release {
self = self.item(
Event::Url(release.download_url),
format!("Download Firezone {}...", release.version),
)
}
self.item(Event::ShowWindow(Window::About), "About Firezone")
.item(Event::AdminPortal, "Admin Portal...")
.add_submenu(
"Help",
Menu::default()
.item(
Event::Url(utm_url("https://www.firezone.dev/kb")),
"Documentation...",
)
.item(
Event::Url(utm_url("https://www.firezone.dev/support")),
"Support...",
),
)
.item(Event::ShowWindow(Window::Settings), "Settings")
.separator()
.item(Event::Quit, quit_text)
}
}
pub(crate) fn utm_url(base_url: &str) -> Url {
Url::parse(&format!(
"{base_url}?utm_source={}-client",
std::env::consts::OS
))
.expect("Hard-coded URL should always be parsable")
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use std::str::FromStr as _;
use builder::INTERNET_RESOURCE_DESCRIPTION;
impl Menu {
fn checkable<E: Into<Option<Event>>, S: Into<String>>(
mut self,
id: E,
title: S,
checked: bool,
) -> Self {
self.add_item(item(id, title).checked(checked));
self
}
}
fn signed_in(
resources: Vec<ResourceView>,
favorite_resources: HashSet<ResourceId>,
internet_resource_enabled: Option<bool>,
) -> AppState {
AppState {
connlib: ConnlibState::SignedIn(SignedIn {
actor_name: "Jane Doe".into(),
favorite_resources,
resources,
internet_resource_enabled,
}),
release: None,
}
}
fn resources() -> Vec<ResourceView> {
let s = r#"[
{
"id": "73037362-715d-4a83-a749-f18eadd970e6",
"type": "cidr",
"name": "172.172.0.0/16",
"address": "172.172.0.0/16",
"address_description": "cidr resource",
"sites": [{"name": "test", "id": "bf56f32d-7b2c-4f5d-a784-788977d014a4"}],
"status": "Unknown"
},
{
"id": "03000143-e25e-45c7-aafb-144990e57dcd",
"type": "dns",
"name": "MyCorp GitLab",
"address": "gitlab.mycorp.com",
"address_description": "https://gitlab.mycorp.com",
"sites": [{"name": "test", "id": "bf56f32d-7b2c-4f5d-a784-788977d014a4"}],
"status": "Online"
},
{
"id": "1106047c-cd5d-4151-b679-96b93da7383b",
"type": "internet",
"name": "Internet Resource",
"address": "All internet addresses",
"sites": [{"name": "test", "id": "eb94482a-94f4-47cb-8127-14fb3afa5516"}],
"status": "Offline"
}
]"#;
serde_json::from_str(s).unwrap()
}
#[test]
fn no_resources_no_favorites() {
let resources = vec![];
let favorites = Default::default();
let disabled_resources = Default::default();
let input = signed_in(resources, favorites, disabled_resources);
let actual = input.into_menu();
let expected = Menu::default()
.disabled("Signed in as Jane Doe")
.item(Event::SignOut, SIGN_OUT)
.separator()
.disabled(RESOURCES)
.add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple
assert_eq!(
actual,
expected,
"{}",
serde_json::to_string_pretty(&actual).unwrap()
);
}
#[test]
fn no_resources_invalid_favorite() {
let resources = vec![];
let favorites = HashSet::from([ResourceId::from_u128(42)]);
let disabled_resources = Default::default();
let input = signed_in(resources, favorites, disabled_resources);
let actual = input.into_menu();
let expected = Menu::default()
.disabled("Signed in as Jane Doe")
.item(Event::SignOut, SIGN_OUT)
.separator()
.disabled(RESOURCES)
.add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple
assert_eq!(
actual,
expected,
"{}",
serde_json::to_string_pretty(&actual).unwrap()
);
}
#[test]
fn some_resources_no_favorites() {
let resources = resources();
let favorites = Default::default();
let disabled_resources = Default::default();
let input = signed_in(resources, favorites, disabled_resources);
let actual = input.into_menu();
let expected = Menu::default()
.disabled("Signed in as Jane Doe")
.item(Event::SignOut, SIGN_OUT)
.separator()
.disabled(RESOURCES)
.add_submenu(
"172.172.0.0/16",
Menu::default()
.copyable("cidr resource")
.separator()
.disabled("Resource")
.copyable("172.172.0.0/16")
.copyable("172.172.0.0/16")
.checkable(
Event::AddFavorite(
ResourceId::from_str("73037362-715d-4a83-a749-f18eadd970e6").unwrap(),
),
ADD_FAVORITE,
false,
)
.separator()
.disabled("Site")
.copyable("test")
.copyable(NO_ACTIVITY),
)
.add_submenu(
"MyCorp GitLab",
Menu::default()
.item(
Event::Url("https://gitlab.mycorp.com".parse().unwrap()),
"<https://gitlab.mycorp.com>",
)
.separator()
.disabled("Resource")
.copyable("MyCorp GitLab")
.copyable("gitlab.mycorp.com")
.checkable(
Event::AddFavorite(
ResourceId::from_str("03000143-e25e-45c7-aafb-144990e57dcd").unwrap(),
),
ADD_FAVORITE,
false,
)
.separator()
.disabled("Site")
.copyable("test")
.copyable(GATEWAY_CONNECTED),
)
.add_submenu(
"— Internet Resource",
Menu::default()
.disabled(INTERNET_RESOURCE_DESCRIPTION)
.separator()
.item(Event::EnableInternetResource, ENABLE)
.separator()
.disabled("Site")
.copyable("test")
.copyable(ALL_GATEWAYS_OFFLINE),
)
.add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple
assert_eq!(
actual,
expected,
"{}",
serde_json::to_string_pretty(&actual).unwrap(),
);
}
#[test]
fn some_resources_one_favorite() -> Result<()> {
let resources = resources();
let favorites = HashSet::from([ResourceId::from_str(
"03000143-e25e-45c7-aafb-144990e57dcd",
)?]);
let disabled_resources = Default::default();
let input = signed_in(resources, favorites, disabled_resources);
let actual = input.into_menu();
let expected = Menu::default()
.disabled("Signed in as Jane Doe")
.item(Event::SignOut, SIGN_OUT)
.separator()
.disabled(FAVORITE_RESOURCES)
.add_submenu(
"MyCorp GitLab",
Menu::default()
.item(
Event::Url("https://gitlab.mycorp.com".parse().unwrap()),
"<https://gitlab.mycorp.com>",
)
.separator()
.disabled("Resource")
.copyable("MyCorp GitLab")
.copyable("gitlab.mycorp.com")
.checkable(
Event::RemoveFavorite(ResourceId::from_str(
"03000143-e25e-45c7-aafb-144990e57dcd",
)?),
REMOVE_FAVORITE,
true,
)
.separator()
.disabled("Site")
.copyable("test")
.copyable(GATEWAY_CONNECTED),
)
.add_submenu(
"— Internet Resource",
Menu::default()
.disabled(INTERNET_RESOURCE_DESCRIPTION)
.separator()
.item(Event::EnableInternetResource, ENABLE)
.separator()
.disabled("Site")
.copyable("test")
.copyable(ALL_GATEWAYS_OFFLINE),
)
.separator()
.add_submenu(
OTHER_RESOURCES,
Menu::default().add_submenu(
"172.172.0.0/16",
Menu::default()
.copyable("cidr resource")
.separator()
.disabled("Resource")
.copyable("172.172.0.0/16")
.copyable("172.172.0.0/16")
.checkable(
Event::AddFavorite(ResourceId::from_str(
"73037362-715d-4a83-a749-f18eadd970e6",
)?),
ADD_FAVORITE,
false,
)
.separator()
.disabled("Site")
.copyable("test")
.copyable(NO_ACTIVITY),
),
)
.add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple
assert_eq!(
actual,
expected,
"{}",
serde_json::to_string_pretty(&actual).unwrap()
);
Ok(())
}
#[test]
fn some_resources_invalid_favorite() -> Result<()> {
let resources = resources();
let favorites = HashSet::from([ResourceId::from_str(
"00000000-0000-0000-0000-000000000000",
)?]);
let disabled_resources = Default::default();
let input = signed_in(resources, favorites, disabled_resources);
let actual = input.into_menu();
let expected = Menu::default()
.disabled("Signed in as Jane Doe")
.item(Event::SignOut, SIGN_OUT)
.separator()
.disabled(RESOURCES)
.add_submenu(
"172.172.0.0/16",
Menu::default()
.copyable("cidr resource")
.separator()
.disabled("Resource")
.copyable("172.172.0.0/16")
.copyable("172.172.0.0/16")
.checkable(
Event::AddFavorite(ResourceId::from_str(
"73037362-715d-4a83-a749-f18eadd970e6",
)?),
ADD_FAVORITE,
false,
)
.separator()
.disabled("Site")
.copyable("test")
.copyable(NO_ACTIVITY),
)
.add_submenu(
"MyCorp GitLab",
Menu::default()
.item(
Event::Url("https://gitlab.mycorp.com".parse().unwrap()),
"<https://gitlab.mycorp.com>",
)
.separator()
.disabled("Resource")
.copyable("MyCorp GitLab")
.copyable("gitlab.mycorp.com")
.checkable(
Event::AddFavorite(ResourceId::from_str(
"03000143-e25e-45c7-aafb-144990e57dcd",
)?),
ADD_FAVORITE,
false,
)
.separator()
.disabled("Site")
.copyable("test")
.copyable(GATEWAY_CONNECTED),
)
.add_submenu(
"— Internet Resource",
Menu::default()
.disabled(INTERNET_RESOURCE_DESCRIPTION)
.separator()
.item(Event::EnableInternetResource, ENABLE)
.separator()
.disabled("Site")
.copyable("test")
.copyable(ALL_GATEWAYS_OFFLINE),
)
.add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple
assert_eq!(
actual,
expected,
"{}",
serde_json::to_string_pretty(&actual).unwrap(),
);
Ok(())
}
}

View File

@@ -1,77 +0,0 @@
use crate::client::gui::Managed;
use anyhow::{Result, bail};
use firezone_gui_client_common::{
controller::{ControllerRequest, CtlrTx},
logging as common,
};
use firezone_logging::err_with_src;
use std::path::PathBuf;
use tauri_plugin_dialog::DialogExt as _;
#[tauri::command]
pub(crate) async fn clear_logs(managed: tauri::State<'_, Managed>) -> Result<(), String> {
let (tx, rx) = tokio::sync::oneshot::channel();
if let Err(error) = managed.ctlr_tx.send(ControllerRequest::ClearLogs(tx)).await {
// Tauri will only log errors to the JS console for us, so log this ourselves.
tracing::error!(
"Error while asking `Controller` to clear logs: {}",
err_with_src(&error)
);
return Err(error.to_string());
}
if let Err(error) = rx.await {
tracing::error!(
"Error while awaiting log-clearing operation: {}",
err_with_src(&error)
);
return Err(error.to_string());
}
Ok(())
}
#[tauri::command]
pub(crate) async fn export_logs(
app: tauri::AppHandle,
managed: tauri::State<'_, Managed>,
) -> Result<(), String> {
show_export_dialog(&app, managed.ctlr_tx.clone()).map_err(|e| e.to_string())
}
#[tauri::command]
pub(crate) async fn count_logs() -> Result<common::FileCount, String> {
common::count_logs().await.map_err(|e| e.to_string())
}
/// Pops up the "Save File" dialog
fn show_export_dialog(app: &tauri::AppHandle, ctlr_tx: CtlrTx) -> Result<()> {
let now = chrono::Local::now();
let datetime_string = now.format("%Y_%m_%d-%H-%M");
let stem = PathBuf::from(format!("firezone_logs_{datetime_string}"));
let filename = stem.with_extension("zip");
let Some(filename) = filename.to_str() else {
bail!("zip filename isn't valid Unicode");
};
tauri_plugin_dialog::FileDialogBuilder::new(app.dialog().clone())
.add_filter("Zip", &["zip"])
.set_file_name(filename)
.save_file(move |file_path| {
let Some(file_path) = file_path else {
return;
};
let path = match file_path.clone().into_path() {
Ok(path) => path,
Err(e) => {
tracing::warn!(%file_path, "Invalid file path: {}", err_with_src(&e));
return;
}
};
// blocking_send here because we're in a sync callback within Tauri somewhere
if let Err(e) = ctlr_tx.blocking_send(ControllerRequest::ExportLogs { path, stem }) {
tracing::warn!("Failed to send `ExportLogs` command: {e}");
}
});
Ok(())
}

View File

@@ -1,65 +0,0 @@
//! Everything related to the Settings window, including
//! advanced settings and code for manipulating diagnostic logs.
use crate::client::gui::Managed;
use anyhow::{Context, Result};
use firezone_gui_client_common::{
controller::{ControllerRequest, CtlrTx},
settings::{AdvancedSettings, save},
};
use std::time::Duration;
use tokio::sync::oneshot;
/// Saves the settings to disk and then applies them in-memory (except for logging)
#[tauri::command]
pub(crate) async fn apply_advanced_settings(
managed: tauri::State<'_, Managed>,
settings: AdvancedSettings,
) -> Result<(), String> {
if managed.inner().inject_faults {
tokio::time::sleep(Duration::from_secs(2)).await;
}
apply_inner(&managed.ctlr_tx, settings)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub(crate) async fn reset_advanced_settings(
managed: tauri::State<'_, Managed>,
) -> Result<AdvancedSettings, String> {
let settings = AdvancedSettings::default();
apply_advanced_settings(managed, settings.clone()).await?;
Ok(settings)
}
/// Saves the settings to disk and then tells `Controller` to apply them in-memory
async fn apply_inner(ctlr_tx: &CtlrTx, settings: AdvancedSettings) -> Result<()> {
save(&settings).await?;
// TODO: Errors aren't handled here. But there isn't much that can go wrong
// since it's just applying a new `Settings` object in memory.
ctlr_tx
.send(ControllerRequest::ApplySettings(Box::new(settings)))
.await?;
Ok(())
}
#[tauri::command]
pub(crate) async fn get_advanced_settings(
managed: tauri::State<'_, Managed>,
) -> Result<AdvancedSettings, String> {
let (tx, rx) = oneshot::channel();
managed
.ctlr_tx
.send(ControllerRequest::GetAdvancedSettings(tx))
.await
.context("couldn't request advanced settings from controller task")
.map_err(|e| e.to_string())?;
rx.await.map_err(|_| {
"Couldn't get settings from `Controller`, maybe the program is crashing".to_string()
})
}

View File

@@ -1,8 +1,9 @@
use crate::{
auth, deep_link, ipc, logging,
auth, deep_link,
gui::system_tray,
ipc, logging,
settings::{self, AdvancedSettings},
system_tray::{self, Event as TrayMenuEvent},
updates,
updates, uptime,
};
use anyhow::{Context, Result, anyhow};
use connlib_model::ResourceView;
@@ -46,7 +47,7 @@ pub struct Controller<I: GuiIntegration> {
rx: ReceiverStream<ControllerRequest>,
status: Status,
updates_rx: ReceiverStream<Option<updates::Notification>>,
uptime: crate::uptime::Tracker,
uptime: uptime::Tracker,
dns_notifier: BoxStream<'static, Result<()>>,
network_notifier: BoxStream<'static, Result<()>>,
@@ -67,7 +68,6 @@ pub trait GuiIntegration {
fn show_window(&self, window: system_tray::Window) -> Result<()>;
}
// Allow dead code because `UpdateNotificationClicked` doesn't work on Linux yet
pub enum ControllerRequest {
/// The GUI wants us to use these settings in-memory, they've already been saved to disk
ApplySettings(Box<AdvancedSettings>),
@@ -82,7 +82,11 @@ pub enum ControllerRequest {
GetAdvancedSettings(oneshot::Sender<AdvancedSettings>),
SchemeRequest(SecretString),
SignIn,
SystemTrayMenu(TrayMenuEvent),
SystemTrayMenu(system_tray::Event),
#[cfg_attr(
any(target_os = "linux", target_os = "macos"),
expect(dead_code, reason = "Doesn't work in Linux yet and is unused on MacOS")
)]
UpdateNotificationClicked(Url),
}
@@ -448,7 +452,7 @@ impl<I: GuiIntegration> Controller<I> {
tracing::error!("`handle_deep_link` failed: {error:#}");
}
},
Req::SignIn | Req::SystemTrayMenu(TrayMenuEvent::SignIn) => {
Req::SignIn | Req::SystemTrayMenu(system_tray::Event::SignIn) => {
let req = self
.auth
.start_sign_in()
@@ -461,21 +465,21 @@ impl<I: GuiIntegration> Controller<I> {
.context("Couldn't open auth page")?;
self.integration.set_welcome_window_visible(false)?;
}
Req::SystemTrayMenu(TrayMenuEvent::AddFavorite(resource_id)) => {
Req::SystemTrayMenu(system_tray::Event::AddFavorite(resource_id)) => {
self.advanced_settings
.favorite_resources
.insert(resource_id);
self.refresh_favorite_resources().await?;
}
Req::SystemTrayMenu(TrayMenuEvent::AdminPortal) => self
Req::SystemTrayMenu(system_tray::Event::AdminPortal) => self
.integration
.open_url(&self.advanced_settings.auth_base_url)
.context("Couldn't open auth page")?,
Req::SystemTrayMenu(TrayMenuEvent::Copy(s)) => arboard::Clipboard::new()
Req::SystemTrayMenu(system_tray::Event::Copy(s)) => arboard::Clipboard::new()
.context("Couldn't access clipboard")?
.set_text(s)
.context("Couldn't copy resource URL or other text to clipboard")?,
Req::SystemTrayMenu(TrayMenuEvent::CancelSignIn) => match &self.status {
Req::SystemTrayMenu(system_tray::Event::CancelSignIn) => match &self.status {
Status::Disconnected
| Status::RetryingConnection { .. }
| Status::WaitingForPortal { .. } => {
@@ -493,24 +497,24 @@ impl<I: GuiIntegration> Controller<I> {
self.sign_out().await?;
}
},
Req::SystemTrayMenu(TrayMenuEvent::RemoveFavorite(resource_id)) => {
Req::SystemTrayMenu(system_tray::Event::RemoveFavorite(resource_id)) => {
self.advanced_settings
.favorite_resources
.remove(&resource_id);
self.refresh_favorite_resources().await?;
}
Req::SystemTrayMenu(TrayMenuEvent::RetryPortalConnection) => {
Req::SystemTrayMenu(system_tray::Event::RetryPortalConnection) => {
self.try_retry_connection().await?
}
Req::SystemTrayMenu(TrayMenuEvent::EnableInternetResource) => {
Req::SystemTrayMenu(system_tray::Event::EnableInternetResource) => {
self.advanced_settings.internet_resource_enabled = Some(true);
self.update_disabled_resources().await?;
}
Req::SystemTrayMenu(TrayMenuEvent::DisableInternetResource) => {
Req::SystemTrayMenu(system_tray::Event::DisableInternetResource) => {
self.advanced_settings.internet_resource_enabled = Some(false);
self.update_disabled_resources().await?;
}
Req::SystemTrayMenu(TrayMenuEvent::ShowWindow(window)) => {
Req::SystemTrayMenu(system_tray::Event::ShowWindow(window)) => {
self.integration.show_window(window)?;
// When the About or Settings windows are hidden / shown, log the
// run ID and uptime. This makes it easy to check client stability on
@@ -522,15 +526,15 @@ impl<I: GuiIntegration> Controller<I> {
"Uptime info"
);
}
Req::SystemTrayMenu(TrayMenuEvent::SignOut) => {
Req::SystemTrayMenu(system_tray::Event::SignOut) => {
tracing::info!("User asked to sign out");
self.sign_out().await?;
}
Req::SystemTrayMenu(TrayMenuEvent::Url(url)) => self
Req::SystemTrayMenu(system_tray::Event::Url(url)) => self
.integration
.open_url(&url)
.context("Couldn't open URL from system tray")?,
Req::SystemTrayMenu(TrayMenuEvent::Quit) => {
Req::SystemTrayMenu(system_tray::Event::Quit) => {
tracing::info!("User clicked Quit in the menu");
self.status = Status::Quitting;
self.ipc_client.send_msg(&IpcClientMsg::Disconnect).await?;

View File

@@ -27,7 +27,7 @@ pub(crate) struct StoreTokenArgs {
pub fn run(cmd: Cmd) -> Result<()> {
match cmd {
Cmd::Replicate6791 => firezone_gui_client_common::auth::replicate_6791(),
Cmd::Replicate6791 => crate::auth::replicate_6791(),
Cmd::SetAutostart(SetAutostartArgs { enabled }) => set_autostart(enabled),
}
}
@@ -35,6 +35,6 @@ pub fn run(cmd: Cmd) -> Result<()> {
fn set_autostart(enabled: bool) -> Result<()> {
firezone_headless_client::setup_stdout_logging()?;
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(crate::client::gui::set_autostart(enabled))?;
rt.block_on(crate::gui::set_autostart(enabled))?;
Ok(())
}

View File

@@ -153,9 +153,7 @@ mod tests {
/// Will fail with permission error if Firezone already ran as sudo
#[tokio::test]
async fn socket_smoke_test() -> Result<()> {
let server = super::Server::new()
.await
.context("Couldn't start Server")?;
let server = Server::new().await.context("Couldn't start Server")?;
let server_task = tokio::spawn(async move {
let bytes = server.accept().await?;
Ok::<_, anyhow::Error>(bytes)

View File

@@ -3,19 +3,14 @@
//! Most of this Client is stubbed out with panics on macOS.
//! The real macOS Client is in `swift/apple`
use crate::client::{
self, about, logging,
settings::{self},
};
use anyhow::{Context, Result, bail};
use common::system_tray::Event as TrayMenuEvent;
use firezone_gui_client_common::{
self as common,
use crate::{
Cli, Cmd, about,
controller::{Controller, ControllerRequest, CtlrTx, GuiIntegration},
deep_link,
settings::AdvancedSettings,
deep_link, logging,
settings::{self, AdvancedSettings},
updates,
};
use anyhow::{Context, Result, bail};
use firezone_logging::err_with_src;
use firezone_telemetry as telemetry;
use secrecy::{ExposeSecret as _, SecretString};
@@ -24,7 +19,7 @@ use tauri::Manager;
use tokio::sync::mpsc;
use tracing::instrument;
pub(crate) mod system_tray;
pub mod system_tray;
#[cfg(target_os = "linux")]
#[path = "gui/os_linux.rs"]
@@ -83,11 +78,11 @@ impl GuiIntegration for TauriIntegration {
Ok(())
}
fn set_tray_icon(&mut self, icon: common::system_tray::Icon) {
fn set_tray_icon(&mut self, icon: system_tray::Icon) {
self.tray.set_icon(icon);
}
fn set_tray_menu(&mut self, app_state: common::system_tray::AppState) {
fn set_tray_menu(&mut self, app_state: system_tray::AppState) {
self.tray.update(app_state)
}
@@ -99,10 +94,10 @@ impl GuiIntegration for TauriIntegration {
os::show_update_notification(&self.app, ctlr_tx, title, url)
}
fn show_window(&self, window: common::system_tray::Window) -> Result<()> {
fn show_window(&self, window: system_tray::Window) -> Result<()> {
let id = match window {
common::system_tray::Window::About => "about",
common::system_tray::Window::Settings => "settings",
system_tray::Window::About => "about",
system_tray::Window::Settings => "settings",
};
let win = self
@@ -122,7 +117,7 @@ impl GuiIntegration for TauriIntegration {
/// Runs the Tauri GUI and returns on exit or unrecoverable error
#[instrument(skip_all)]
pub(crate) fn run(
cli: client::Cli,
cli: Cli,
advanced_settings: AdvancedSettings,
reloader: firezone_logging::FilterReloadHandle,
mut telemetry: telemetry::Telemetry,
@@ -171,7 +166,7 @@ pub(crate) fn run(
settings::apply_advanced_settings,
settings::reset_advanced_settings,
settings::get_advanced_settings,
crate::client::welcome::sign_in,
crate::welcome::sign_in,
])
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
@@ -186,7 +181,7 @@ pub(crate) fn run(
}
});
if let Some(client::Cmd::SmokeTest) = &cli.command {
if let Some(Cmd::SmokeTest) = &cli.command {
let ctlr_tx = ctlr_tx.clone();
tokio::spawn(async move {
if let Err(error) = smoke_test(ctlr_tx).await {
@@ -225,7 +220,7 @@ pub(crate) fn run(
tracing::warn!("Will quit gracefully in {delay} seconds.");
tokio::time::sleep(Duration::from_secs(delay)).await;
tracing::warn!("Quitting gracefully due to `--quit-after`");
ctlr_tx.send(ControllerRequest::SystemTrayMenu(firezone_gui_client_common::system_tray::Event::Quit)).await?;
ctlr_tx.send(ControllerRequest::SystemTrayMenu(system_tray::Event::Quit)).await?;
Ok::<_, anyhow::Error>(())
});
}
@@ -341,7 +336,7 @@ async fn smoke_test(ctlr_tx: CtlrTx) -> Result<()> {
tokio::time::sleep_until(quit_time).await;
// Write the settings so we can check the path for those
common::settings::save(&AdvancedSettings::default()).await?;
settings::save(&AdvancedSettings::default()).await?;
// Check results of tests
let zip_len = tokio::fs::metadata(&path)
@@ -357,11 +352,11 @@ async fn smoke_test(ctlr_tx: CtlrTx) -> Result<()> {
tracing::info!(?path, ?zip_len, "Exported log zip looks okay");
// Check that settings file and at least one log file were written
anyhow::ensure!(tokio::fs::try_exists(common::settings::advanced_settings_path()?).await?);
anyhow::ensure!(tokio::fs::try_exists(settings::advanced_settings_path()?).await?);
tracing::info!("Quitting on purpose because of `smoke-test` subcommand");
ctlr_tx
.send(ControllerRequest::SystemTrayMenu(TrayMenuEvent::Quit))
.send(ControllerRequest::SystemTrayMenu(system_tray::Event::Quit))
.await
.context("Failed to send Quit request")?;
@@ -398,7 +393,7 @@ async fn accept_deep_links(mut server: deep_link::Server, ctlr_tx: CtlrTx) -> Re
}
}
fn handle_system_tray_event(app: &tauri::AppHandle, event: TrayMenuEvent) -> Result<()> {
fn handle_system_tray_event(app: &tauri::AppHandle, event: system_tray::Event) -> Result<()> {
app.try_state::<Managed>()
.context("can't get Managed struct from Tauri")?
.ctlr_tx

View File

@@ -1,11 +1,36 @@
//! Code for the system tray AKA notification area
//!
//! This manages the icon, menu, and tooltip.
//!
//! "Notification Area" is Microsoft's official name instead of "System tray":
//! <https://learn.microsoft.com/en-us/windows/win32/shell/notification-area?redirectedfrom=MSDN#notifications-and-the-notification-area>
use crate::updates::Release;
use anyhow::{Context as _, Result};
use compositor::Image;
use connlib_model::{ResourceId, ResourceStatus, ResourceView};
use std::collections::HashSet;
use tauri::AppHandle;
use url::Url;
use builder::item;
pub use builder::{Entry, Event, Item, Menu, Window};
mod builder;
mod compositor;
type IsMenuItem = dyn tauri::menu::IsMenuItem<tauri::Wry>;
type TauriMenu = tauri::menu::Menu<tauri::Wry>;
type TauriSubmenu = tauri::menu::Submenu<tauri::Wry>;
// 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!("../../icons/tray/Logo.png");
const LOGO_GREY_BASE: &[u8] = include_bytes!("../../icons/tray/Logo grey.png");
const BUSY_LAYER: &[u8] = include_bytes!("../../icons/tray/Busy layer.png");
const SIGNED_OUT_LAYER: &[u8] = include_bytes!("../../icons/tray/Signed out layer.png");
const UPDATE_READY_LAYER: &[u8] = include_bytes!("../../icons/tray/Update ready layer.png");
const QUIT_TEXT_SIGNED_OUT: &str = "Quit Firezone";
const NO_ACTIVITY: &str = "[-] No activity";
@@ -25,7 +50,201 @@ const DISCONNECT_AND_QUIT: &str = "Disconnect and quit Firezone";
const DISABLE: &str = "Disable this resource";
const ENABLE: &str = "Enable this resource";
mod builder;
const TOOLTIP: &str = "Firezone";
pub(crate) struct Tray {
app: AppHandle,
handle: tauri::tray::TrayIcon,
last_icon_set: Icon,
last_menu_set: Option<Menu>,
}
fn icon_to_tauri_icon(that: &Icon) -> tauri::image::Image<'static> {
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_tauri_icon(composed)
}
fn image_to_tauri_icon(val: Image) -> tauri::image::Image<'static> {
tauri::image::Image::new_owned(val.rgba, val.width, val.height)
}
impl Tray {
pub(crate) fn new(
app: AppHandle,
on_event: impl Fn(&AppHandle, Event) + Send + Sync + 'static,
) -> Result<Self> {
let tray = tauri::tray::TrayIconBuilder::new()
.icon(icon_to_tauri_icon(&Icon::default()))
.menu(&build_app_state(&app, &AppState::default().into_menu())?)
.on_menu_event(move |app, event| {
let id = &event.id.0;
tracing::debug!(?id, "SystemTrayEvent::MenuItemClick");
let event = match serde_json::from_str::<Event>(id) {
Ok(x) => x,
Err(e) => {
tracing::error!("{e}");
return;
}
};
on_event(app, event);
})
.tooltip("Firezone")
.build(&app)
.context("Cannot build Tauri tray icon")?;
Ok(Self {
app,
handle: tray,
last_icon_set: Default::default(),
last_menu_set: None,
})
}
pub(crate) fn update(&mut self, state: AppState) {
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 = state.into_menu();
let menu_clone = menu.clone();
let app = self.app.clone();
let handle = self.handle.clone();
if Some(&menu) == self.last_menu_set.as_ref() {
tracing::debug!("Skipping redundant menu update");
} else {
self.run_on_main_thread(move || {
firezone_logging::unwrap_or_debug!(
update(handle, &app, &menu),
"Error while updating tray menu: {}"
);
});
}
self.set_icon(new_icon);
self.last_menu_set = Some(menu_clone);
}
// Only needed for the stress test
// Otherwise it would be inlined
pub(crate) fn set_icon(&mut self, icon: Icon) {
if icon == self.last_icon_set {
return;
}
// Don't call `set_icon` too often. On Linux it writes a PNG to `/run/user/$UID/tao/tray-icon-*.png` every single time.
// <https://github.com/tauri-apps/tao/blob/tao-v0.16.7/src/platform_impl/linux/system_tray.rs#L119>
// Yes, even if you use `Icon::File` and tell Tauri that the icon is already
// on disk.
let handle = self.handle.clone();
self.last_icon_set = icon.clone();
self.run_on_main_thread(move || {
let result = handle
.set_icon(Some(icon_to_tauri_icon(&icon)))
.context("Failed to set tray icon");
firezone_logging::unwrap_or_debug!(result, "{}");
});
}
fn run_on_main_thread(&self, f: impl FnOnce() + Send + 'static) {
let result = self
.app
.run_on_main_thread(f)
.context("Failed to run closure on main thread");
firezone_logging::unwrap_or_debug!(result, "{}");
}
}
fn update(handle: tauri::tray::TrayIcon, app: &AppHandle, menu: &Menu) -> Result<()> {
let menu = build_app_state(app, menu).context("Failed to build tray menu")?;
handle
.set_tooltip(Some(TOOLTIP))
.context("Failed to set tooltip")?;
handle
.set_menu(Some(menu))
.context("Failed to set tray menu")?;
Ok(())
}
fn build_app_state(app: &AppHandle, menu: &Menu) -> Result<TauriMenu> {
build_menu(app, menu)
}
/// Builds this abstract `Menu` into a real menu that we can use in Tauri.
///
/// This recurses but we never go deeper than 3 or 4 levels so it's fine.
///
/// Note that Menus and Submenus are different in Tauri. Using a Submenu as a Menu
/// may crash on Windows. <https://github.com/tauri-apps/tauri/issues/11363>
fn build_menu(app: &AppHandle, that: &Menu) -> Result<TauriMenu> {
let mut menu = tauri::menu::MenuBuilder::new(app);
for entry in &that.entries {
menu = menu.item(&*build_entry(app, entry)?);
}
Ok(menu.build()?)
}
fn build_submenu(app: &AppHandle, title: &str, that: &Menu) -> Result<TauriSubmenu> {
let mut menu = tauri::menu::SubmenuBuilder::new(app, title);
for entry in &that.entries {
menu = menu.item(&*build_entry(app, entry)?);
}
Ok(menu.build()?)
}
fn build_entry(app: &AppHandle, entry: &Entry) -> Result<Box<IsMenuItem>> {
let entry = match entry {
Entry::Item(item) => build_item(app, item)?,
Entry::Separator => Box::new(tauri::menu::PredefinedMenuItem::separator(app)?),
Entry::Submenu { title, inner } => Box::new(build_submenu(app, title, inner)?),
};
Ok(entry)
}
fn build_item(app: &AppHandle, item: &Item) -> Result<Box<IsMenuItem>> {
let item: Box<IsMenuItem> = if let Some(checked) = item.checked {
let mut tauri_item = tauri::menu::CheckMenuItemBuilder::new(&item.title).checked(checked);
if let Some(event) = &item.event {
tauri_item = tauri_item.id(serde_json::to_string(event)?);
} else {
tauri_item = tauri_item.enabled(false);
}
Box::new(tauri_item.build(app)?)
} else {
let mut tauri_item = tauri::menu::MenuItemBuilder::new(&item.title);
if let Some(event) = &item.event {
tauri_item = tauri_item.id(serde_json::to_string(event)?);
} else {
tauri_item = tauri_item.enabled(false);
}
Box::new(tauri_item.build(app)?)
};
Ok(item)
}
pub struct AppState {
pub connlib: ConnlibState,

View File

@@ -0,0 +1,297 @@
//! Everything for logging to files, zipping up the files for export, and counting the files
use crate::gui::Managed;
use anyhow::{Context as _, Result, bail};
use firezone_bin_shared::known_dirs;
use firezone_logging::err_with_src;
use serde::Serialize;
use std::{
fs,
io::{self, ErrorKind::NotFound},
path::{Path, PathBuf},
};
use tauri_plugin_dialog::DialogExt as _;
use tokio::task::spawn_blocking;
use tracing_subscriber::{Layer, Registry, layer::SubscriberExt};
use super::controller::{ControllerRequest, CtlrTx};
#[tauri::command]
pub(crate) async fn clear_logs(managed: tauri::State<'_, Managed>) -> Result<(), String> {
let (tx, rx) = tokio::sync::oneshot::channel();
if let Err(error) = managed.ctlr_tx.send(ControllerRequest::ClearLogs(tx)).await {
// Tauri will only log errors to the JS console for us, so log this ourselves.
tracing::error!(
"Error while asking `Controller` to clear logs: {}",
err_with_src(&error)
);
return Err(error.to_string());
}
if let Err(error) = rx.await {
tracing::error!(
"Error while awaiting log-clearing operation: {}",
err_with_src(&error)
);
return Err(error.to_string());
}
Ok(())
}
#[tauri::command]
pub(crate) async fn export_logs(
app: tauri::AppHandle,
managed: tauri::State<'_, Managed>,
) -> Result<(), String> {
show_export_dialog(&app, managed.ctlr_tx.clone()).map_err(|e| e.to_string())
}
#[tauri::command]
pub(crate) async fn count_logs() -> Result<FileCount, String> {
count_logs_imp().await.map_err(|e| e.to_string())
}
/// Pops up the "Save File" dialog
fn show_export_dialog(app: &tauri::AppHandle, ctlr_tx: CtlrTx) -> Result<()> {
let now = chrono::Local::now();
let datetime_string = now.format("%Y_%m_%d-%H-%M");
let stem = PathBuf::from(format!("firezone_logs_{datetime_string}"));
let filename = stem.with_extension("zip");
let Some(filename) = filename.to_str() else {
bail!("zip filename isn't valid Unicode");
};
tauri_plugin_dialog::FileDialogBuilder::new(app.dialog().clone())
.add_filter("Zip", &["zip"])
.set_file_name(filename)
.save_file(move |file_path| {
let Some(file_path) = file_path else {
return;
};
let path = match file_path.clone().into_path() {
Ok(path) => path,
Err(e) => {
tracing::warn!(%file_path, "Invalid file path: {}", err_with_src(&e));
return;
}
};
// blocking_send here because we're in a sync callback within Tauri somewhere
if let Err(e) = ctlr_tx.blocking_send(ControllerRequest::ExportLogs { path, stem }) {
tracing::warn!("Failed to send `ExportLogs` command: {e}");
}
});
Ok(())
}
/// If you don't store `Handles` in a variable, the file logger handle will drop immediately,
/// resulting in empty log files.
#[must_use]
pub struct Handles {
pub logger: firezone_logging::file::Handle,
pub reloader: firezone_logging::FilterReloadHandle,
}
struct LogPath {
/// Where to find the logs on disk
///
/// e.g. `/var/log/dev.firezone.client`
src: PathBuf,
/// Where to store the logs in the zip
///
/// e.g. `connlib`
dst: PathBuf,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Couldn't create logs dir: {0}")]
CreateDirAll(std::io::Error),
#[error("Log filter couldn't be parsed")]
Parse(#[from] tracing_subscriber::filter::ParseError),
#[error(transparent)]
SetGlobalDefault(#[from] tracing::subscriber::SetGlobalDefaultError),
#[error(transparent)]
SetLogger(#[from] tracing_log::log_tracer::SetLoggerError),
}
/// Set up logs after the process has started
///
/// We need two of these filters for some reason, and `EnvFilter` doesn't implement
/// `Clone` yet, so that's why we take the directives string
/// <https://github.com/tokio-rs/tracing/issues/2360>
pub fn setup(directives: &str) -> Result<Handles> {
if let Err(error) = output_vt100::try_init() {
tracing::debug!("Failed to init terminal colors: {error}");
}
let log_path = known_dirs::logs().context("Can't compute app log dir")?;
std::fs::create_dir_all(&log_path).map_err(Error::CreateDirAll)?;
// Logfilter for stdout cannot be reloaded. This is okay because we are using it only for local dev and debugging anyway.
// Having multiple reload handles makes their type-signature quite complex so we don't bother with that.
let (stdout_filter, stdout_reloader) = firezone_logging::try_filter(directives)?;
let stdout_layer = tracing_subscriber::fmt::layer()
.with_ansi(firezone_logging::stdout_supports_ansi())
.event_format(firezone_logging::Format::new());
let (system_filter, system_reloader) = firezone_logging::try_filter(directives)?;
let system_layer = system_layer().context("Failed to init system logger")?;
#[cfg(target_os = "linux")]
let syslog_identifier = Some(system_layer.syslog_identifier().to_owned());
#[cfg(not(target_os = "linux"))]
let syslog_identifier = Option::<String>::None;
let (file_layer, logger) = firezone_logging::file::layer(&log_path, "gui-client");
let (file_filter, file_reloader) = firezone_logging::try_filter(directives)?;
let subscriber = Registry::default()
.with(file_layer.with_filter(file_filter))
.with(stdout_layer.with_filter(stdout_filter))
.with(system_layer.with_filter(system_filter))
.with(firezone_logging::sentry_layer());
firezone_logging::init(subscriber)?;
tracing::debug!(log_path = %log_path.display(), syslog_identifier = syslog_identifier.map(tracing::field::display));
Ok(Handles {
logger,
reloader: stdout_reloader.merge(file_reloader).merge(system_reloader),
})
}
#[cfg(target_os = "linux")]
fn system_layer() -> Result<tracing_journald::Layer> {
let layer = tracing_journald::layer()?;
Ok(layer)
}
#[cfg(not(target_os = "linux"))]
#[expect(clippy::unnecessary_wraps, reason = "Linux signature needs `Result`")]
fn system_layer() -> Result<tracing_subscriber::layer::Identity> {
Ok(tracing_subscriber::layer::Identity::new())
}
#[derive(Clone, Default, Serialize)]
pub struct FileCount {
bytes: u64,
files: u64,
}
/// Delete all files in the logs directory.
///
/// This includes the current log file, so we won't write any more logs to disk
/// until the file rolls over or the app restarts.
///
/// If we get an error while removing a file, we still try to remove all other
/// files, then we return the most recent error.
pub async fn clear_gui_logs() -> Result<()> {
firezone_headless_client::clear_logs(&known_dirs::logs().context("Can't compute GUI log dir")?)
.await
}
/// Exports logs to a zip file
///
/// # Arguments
///
/// * `path` - Where the zip archive will be written
/// * `stem` - A directory containing all the log files inside the zip archive, to avoid creating a ["tar bomb"](https://www.linfo.org/tarbomb.html). This comes from the automatically-generated name of the archive, even if the user changes it to e.g. `logs.zip`
pub async fn export_logs_to(path: PathBuf, stem: PathBuf) -> Result<()> {
tracing::info!("Exporting logs to {path:?}");
let start = std::time::Instant::now();
// Use a temp path so that if the export fails we don't end up with half a zip file
let temp_path = path.with_extension(".zip-partial");
// TODO: Consider https://github.com/Majored/rs-async-zip/issues instead of `spawn_blocking`
spawn_blocking(move || {
let f = fs::File::create(&temp_path).context("Failed to create zip file")?;
let mut zip = zip::ZipWriter::new(f);
for log_path in log_paths().context("Can't compute log paths")? {
add_dir_to_zip(&mut zip, &log_path.src, &stem.join(log_path.dst))?;
}
zip.finish().context("Failed to finish zip file")?;
fs::rename(&temp_path, &path)?;
Ok::<_, anyhow::Error>(())
})
.await
.context("Failed to join zip export task")??;
tracing::debug!(elapsed_s = ?start.elapsed(), "Exported logs");
Ok(())
}
/// Reads all files in a directory and adds them to a zip file
///
/// Does not recurse.
/// All files will have the same modified time. Doing otherwise seems to be difficult
fn add_dir_to_zip(
zip: &mut zip::ZipWriter<std::fs::File>,
src_dir: &Path,
dst_stem: &Path,
) -> Result<()> {
let options = zip::write::SimpleFileOptions::default();
let dir = match fs::read_dir(src_dir) {
Ok(x) => x,
Err(error) => {
if matches!(error.kind(), NotFound) {
// In smoke tests, the IPC service runs in debug mode, so it won't write any logs to disk. If the IPC service's log dir doesn't exist, we shouldn't crash, it's correct to simply not add any files to the zip
return Ok(());
}
// But any other error like permissions errors, should bubble.
return Err(error.into());
}
};
for entry in dir {
let entry = entry.context("Got bad entry from `read_dir`")?;
let Some(path) = dst_stem
.join(entry.file_name())
.to_str()
.map(|x| x.to_owned())
else {
bail!("log filename isn't valid Unicode")
};
zip.start_file(path, options)
.context("`ZipWriter::start_file` failed")?;
let mut f = fs::File::open(entry.path()).context("Failed to open log file")?;
io::copy(&mut f, zip).context("Failed to copy log file into zip")?;
}
Ok(())
}
/// Count log files and their sizes
async fn count_logs_imp() -> Result<FileCount> {
// I spent about 5 minutes on this and couldn't get it to work with `Stream`
let mut total_count = FileCount::default();
for log_path in log_paths()? {
let count = count_one_dir(&log_path.src).await?;
total_count.files += count.files;
total_count.bytes += count.bytes;
}
Ok(total_count)
}
async fn count_one_dir(path: &Path) -> Result<FileCount> {
let mut dir = tokio::fs::read_dir(path).await?;
let mut file_count = FileCount::default();
while let Some(entry) = dir.next_entry().await? {
let md = entry.metadata().await?;
file_count.files += 1;
file_count.bytes += md.len();
}
Ok(file_count)
}
fn log_paths() -> Result<Vec<LogPath>> {
Ok(vec![
LogPath {
src: known_dirs::ipc_service_logs().context("Can't compute IPC service logs dir")?,
dst: PathBuf::from("connlib"),
},
LogPath {
src: known_dirs::logs().context("Can't compute GUI log dir")?,
dst: PathBuf::from("app"),
},
])
}

View File

@@ -4,7 +4,32 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#![cfg_attr(test, allow(clippy::unwrap_used))]
mod client;
use anyhow::{Context as _, Result, bail};
use clap::{Args, Parser};
use controller::Failure;
use firezone_telemetry::Telemetry;
use settings::AdvancedSettings;
use tracing_subscriber::EnvFilter;
mod about;
mod auth;
mod controller;
mod debug_commands;
mod deep_link;
mod elevation;
mod gui;
mod ipc;
mod logging;
mod settings;
mod updates;
mod uptime;
mod welcome;
/// The Sentry "release" we are part of.
///
/// IPC service and GUI client are always bundled into a single release.
/// Hence, we have a single constant for IPC service and GUI client.
const RELEASE: &str = concat!("gui-client@", env!("CARGO_PKG_VERSION"));
fn main() -> anyhow::Result<()> {
// Mitigates a bug in Ubuntu 22.04 - Under Wayland, some features of the window decorations like minimizing, closing the windows, etc., doesn't work unless you double-click the titlebar first.
@@ -13,5 +38,260 @@ fn main() -> anyhow::Result<()> {
std::env::set_var("GDK_BACKEND", "x11");
}
client::run()
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");
match cli.command {
None => {
if cli.no_deep_links {
return run_gui(cli);
}
match elevation::gui_check() {
// Our elevation is correct (not elevated), just run the GUI
Ok(true) => run_gui(cli),
Ok(false) => bail!("The GUI should run as a normal user, not elevated"),
#[cfg(target_os = "linux")] // Windows/MacOS elevation check never fails.
Err(error) => {
show_error_dialog(&error.user_friendly_msg())?;
Err(error.into())
}
}
}
Some(Cmd::Debug { command }) => debug_commands::run(command),
// If we already tried to elevate ourselves, don't try again
Some(Cmd::Elevated) => run_gui(cli),
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 in `OpenDeepLink`: {error:#}");
}
Ok(())
}
Some(Cmd::SmokeTest) => {
// Can't check elevation here because the Windows CI is always elevated
let settings = settings::load_advanced_settings().unwrap_or_default();
let mut telemetry = Telemetry::default();
telemetry.start(
settings.api_url.as_ref(),
crate::RELEASE,
firezone_telemetry::GUI_DSN,
);
// Don't fix the log filter for smoke tests
let logging::Handles {
logger: _logger,
reloader,
} = start_logging(&settings.log_filter)?;
let result = gui::run(cli, settings, reloader, telemetry);
if let Err(error) = &result {
// In smoke-test mode, don't show the dialog, since it might be running
// unattended in CI and the dialog would hang forever
// Because of <https://github.com/firezone/firezone/issues/3567>,
// errors returned from `gui::run` may not be logged correctly
tracing::error!("{error:#}");
}
Ok(result?)
}
}
}
/// `gui::run` but wrapped in `anyhow::Result`
///
/// Automatically logs or shows error dialogs for important user-actionable errors
// Can't `instrument` this because logging isn't running when we enter it.
fn run_gui(cli: Cli) -> Result<()> {
let mut settings = settings::load_advanced_settings().unwrap_or_default();
let mut telemetry = Telemetry::default();
// In the future telemetry will be opt-in per organization, that's why this isn't just at the top of `main`
telemetry.start(
settings.api_url.as_ref(),
crate::RELEASE,
firezone_telemetry::GUI_DSN,
);
// Get the device ID before starting Tokio, so that all the worker threads will inherit the correct scope.
// Technically this means we can fail to get the device ID on a newly-installed system, since the IPC service may not have fully started up when the GUI process reaches this point, but in practice it's unlikely.
if let Ok(id) = firezone_headless_client::device_id::get() {
Telemetry::set_firezone_id(id.id);
}
fix_log_filter(&mut settings)?;
let logging::Handles {
logger: _logger,
reloader,
} = start_logging(&settings.log_filter)?;
match gui::run(cli, settings, reloader, telemetry) {
Ok(()) => Ok(()),
Err(anyhow) => {
if anyhow
.chain()
.find_map(|e| e.downcast_ref::<tauri_runtime::Error>())
.is_some_and(|e| matches!(e, tauri_runtime::Error::CreateWebview(_)))
{
show_error_dialog(
"Firezone cannot start because WebView2 is not installed. Follow the instructions at <https://www.firezone.dev/kb/client-apps/windows-gui-client>.",
)?;
return Err(anyhow);
}
if anyhow.root_cause().is::<deep_link::CantListen>() {
show_error_dialog(
"Firezone is already running. If it's not responding, force-stop it.",
)?;
return Err(anyhow);
}
if anyhow
.root_cause()
.is::<firezone_headless_client::ipc::NotFound>()
{
show_error_dialog("Couldn't find Firezone IPC service. Is the service running?")?;
return Err(anyhow);
}
show_error_dialog(
"An unexpected error occurred. Please try restarting Firezone. If the issue persists, contact your administrator.",
)?;
tracing::error!("GUI failed: {anyhow:#}");
Err(anyhow)
}
}
}
/// Parse the log filter from settings, showing an error and fixing it if needed
fn fix_log_filter(settings: &mut AdvancedSettings) -> Result<()> {
if EnvFilter::try_new(&settings.log_filter).is_ok() {
return Ok(());
}
settings.log_filter = AdvancedSettings::default().log_filter;
native_dialog::MessageDialog::new()
.set_title("Log filter error")
.set_text("The custom log filter is not parsable. Using the default log filter.")
.set_type(native_dialog::MessageType::Error)
.show_alert()
.context("Can't show log filter error dialog")?;
Ok(())
}
/// Blocks the thread and shows an error dialog
///
/// Doesn't play well with async, only use this if we're bailing out of the
/// entire process.
fn show_error_dialog(msg: &str) -> Result<()> {
// I tried the Tauri dialogs and for some reason they don't show our
// app icon.
native_dialog::MessageDialog::new()
.set_title("Firezone Error")
.set_text(msg)
.set_type(native_dialog::MessageType::Error)
.show_alert()?;
Ok(())
}
/// Starts logging
///
/// Don't drop the log handle or logging will stop.
fn start_logging(directives: &str) -> Result<logging::Handles> {
let logging_handles = logging::setup(directives)?;
let system_uptime_seconds = firezone_bin_shared::uptime::get().map(|dur| dur.as_secs());
tracing::info!(
arch = std::env::consts::ARCH,
os = std::env::consts::OS,
version = env!("CARGO_PKG_VERSION"),
?directives,
?system_uptime_seconds,
"`gui-client` started logging"
);
Ok(logging_handles)
}
/// The debug / test flags like `crash_on_purpose` and `test_update_notification`
/// don't propagate when we use `RunAs` to elevate ourselves. So those must be run
/// from an admin terminal, or with "Run as administrator" in the right-click menu.
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
/// If true, check for updates every 30 seconds and pretend our current version is 1.0.0, so we'll always show the notification dot.
#[arg(long, hide = true)]
debug_update_check: bool,
#[command(subcommand)]
command: Option<Cmd>,
/// Crash the `Controller` task to test error handling
/// Formerly `--crash-on-purpose`
#[arg(long, hide = true)]
crash: bool,
/// Error out of the `Controller` task to test error handling
#[arg(long, hide = true)]
error: bool,
/// Panic the `Controller` task to test error handling
#[arg(long, hide = true)]
panic: bool,
/// Quit gracefully after a given number of seconds
#[arg(long, hide = true)]
quit_after: Option<u64>,
/// If true, slow down I/O operations to test how the GUI handles slow I/O
#[arg(long, hide = true)]
inject_faults: bool,
/// If true, show a fake update notification that opens the Firezone release page when clicked
#[arg(long, hide = true)]
test_update_notification: bool,
/// For headless CI, disable deep links and allow the GUI to run as admin
#[arg(long, hide = true)]
no_deep_links: bool,
}
impl Cli {
fn fail_on_purpose(&self) -> Option<Failure> {
if self.crash {
Some(Failure::Crash)
} else if self.error {
Some(Failure::Error)
} else if self.panic {
Some(Failure::Panic)
} else {
None
}
}
}
#[derive(clap::Subcommand)]
enum Cmd {
Debug {
#[command(subcommand)]
command: debug_commands::Cmd,
},
Elevated,
OpenDeepLink(DeepLink),
/// SmokeTest gets its own subcommand for historical reasons.
SmokeTest,
}
#[derive(Args)]
pub struct DeepLink {
// TODO: Should be `Secret`?
pub url: url::Url,
}
#[cfg(test)]
mod tests {
use anyhow::Result;
#[test]
fn exe_path() -> Result<()> {
// e.g. `\\\\?\\C:\\cygwin64\\home\\User\\projects\\firezone\\rust\\target\\debug\\deps\\firezone_windows_client-5f44800b2dafef90.exe`
let path = tauri_utils::platform::current_exe()?.display().to_string();
assert!(path.contains("target"));
assert!(!path.contains('\"'), "`{}`", path);
Ok(())
}
}

View File

@@ -0,0 +1,162 @@
//! Everything related to the Settings window, including
//! advanced settings and code for manipulating diagnostic logs.
use crate::gui::Managed;
use anyhow::{Context as _, Result};
use connlib_model::ResourceId;
use firezone_bin_shared::known_dirs;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use std::{collections::HashSet, path::PathBuf};
use tokio::sync::oneshot;
use url::Url;
use super::controller::{ControllerRequest, CtlrTx};
/// Saves the settings to disk and then applies them in-memory (except for logging)
#[tauri::command]
pub(crate) async fn apply_advanced_settings(
managed: tauri::State<'_, Managed>,
settings: AdvancedSettings,
) -> Result<(), String> {
if managed.inner().inject_faults {
tokio::time::sleep(Duration::from_secs(2)).await;
}
apply_inner(&managed.ctlr_tx, settings)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub(crate) async fn reset_advanced_settings(
managed: tauri::State<'_, Managed>,
) -> Result<AdvancedSettings, String> {
let settings = AdvancedSettings::default();
apply_advanced_settings(managed, settings.clone()).await?;
Ok(settings)
}
/// Saves the settings to disk and then tells `Controller` to apply them in-memory
async fn apply_inner(ctlr_tx: &CtlrTx, settings: AdvancedSettings) -> Result<()> {
save(&settings).await?;
// TODO: Errors aren't handled here. But there isn't much that can go wrong
// since it's just applying a new `Settings` object in memory.
ctlr_tx
.send(ControllerRequest::ApplySettings(Box::new(settings)))
.await?;
Ok(())
}
#[tauri::command]
pub(crate) async fn get_advanced_settings(
managed: tauri::State<'_, Managed>,
) -> Result<AdvancedSettings, String> {
let (tx, rx) = oneshot::channel();
managed
.ctlr_tx
.send(ControllerRequest::GetAdvancedSettings(tx))
.await
.context("couldn't request advanced settings from controller task")
.map_err(|e| e.to_string())?;
rx.await.map_err(|_| {
"Couldn't get settings from `Controller`, maybe the program is crashing".to_string()
})
}
#[derive(Clone, Deserialize, Serialize)]
pub struct AdvancedSettings {
pub auth_base_url: Url,
pub api_url: Url,
#[serde(default)]
pub favorite_resources: HashSet<ResourceId>,
#[serde(default)]
pub internet_resource_enabled: Option<bool>,
pub log_filter: String,
}
#[cfg(debug_assertions)]
mod defaults {
pub(crate) const AUTH_BASE_URL: &str = "https://app.firez.one";
pub(crate) const API_URL: &str = "wss://api.firez.one/";
pub(crate) const LOG_FILTER: &str = "firezone_gui_client=debug,info";
}
#[cfg(not(debug_assertions))]
mod defaults {
pub(crate) const AUTH_BASE_URL: &str = "https://app.firezone.dev";
pub(crate) const API_URL: &str = "wss://api.firezone.dev/";
pub(crate) const LOG_FILTER: &str = "info";
}
impl Default for AdvancedSettings {
fn default() -> Self {
Self {
auth_base_url: Url::parse(defaults::AUTH_BASE_URL).expect("static URL is a valid URL"),
api_url: Url::parse(defaults::API_URL).expect("static URL is a valid URL"),
favorite_resources: Default::default(),
internet_resource_enabled: Default::default(),
log_filter: defaults::LOG_FILTER.to_string(),
}
}
}
impl AdvancedSettings {
pub fn internet_resource_enabled(&self) -> bool {
self.internet_resource_enabled.is_some_and(|v| v)
}
}
pub fn advanced_settings_path() -> Result<PathBuf> {
Ok(known_dirs::settings()
.context("`known_dirs::settings` failed")?
.join("advanced_settings.json"))
}
/// Saves the settings to disk
pub async fn save(settings: &AdvancedSettings) -> Result<()> {
let path = advanced_settings_path()?;
let dir = path
.parent()
.context("settings path should have a parent")?;
tokio::fs::create_dir_all(dir).await?;
tokio::fs::write(&path, serde_json::to_string(settings)?).await?;
tracing::debug!(?path, "Saved settings");
Ok(())
}
/// Return advanced settings if they're stored on disk
///
/// Uses std::fs, so stick it in `spawn_blocking` for async contexts
pub fn load_advanced_settings() -> Result<AdvancedSettings> {
let path = advanced_settings_path()?;
let text = std::fs::read_to_string(path)?;
let settings = serde_json::from_str(&text)?;
Ok(settings)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_old_formats() {
let s = r#"{
"auth_base_url": "https://example.com/",
"api_url": "wss://example.com/",
"log_filter": "info"
}"#;
let actual = serde_json::from_str::<AdvancedSettings>(s).unwrap();
// Apparently the trailing slash here matters
assert_eq!(actual.auth_base_url.to_string(), "https://example.com/");
assert_eq!(actual.api_url.to_string(), "wss://example.com/");
assert_eq!(actual.log_filter, "info");
}
}

View File

@@ -1,8 +1,9 @@
//! Everything related to the Welcome window
use crate::client::gui::Managed;
use crate::gui::Managed;
use anyhow::Context;
use firezone_gui_client_common::controller::ControllerRequest;
use super::controller::ControllerRequest;
#[tauri::command]
pub(crate) async fn sign_in(managed: tauri::State<'_, Managed>) -> Result<(), String> {