From 5eab912f601ff607a37c6627c06fdc1ecb2f5a24 Mon Sep 17 00:00:00 2001 From: Reactor Scram Date: Thu, 5 Sep 2024 12:42:45 -0500 Subject: [PATCH] refactor(rust/gui-client): begin isolating Tauri from our code (#6593) This moves about 2/3rds of the code from `firezone-gui-client` to `firezone-gui-client-common`. I tested it in aarch64 Windows and cycled through sign-in and sign-out and closing and re-opening the GUI process while the IPC service stays running. IPC and updates each get their own MPSC channel in this, so I wanted to be sure it didn't break. --------- Signed-off-by: Reactor Scram Co-authored-by: Thomas Eizinger --- .github/actions/setup-rust/action.yml | 2 +- rust/Cargo.lock | 49 +- rust/Cargo.toml | 1 + rust/gui-client/src-common/Cargo.toml | 55 ++ .../src/client => src-common/src}/auth.rs | 14 +- .../src}/compositor.rs | 20 +- rust/gui-client/src-common/src/controller.rs | 666 ++++++++++++++++ .../src/controller}/ran_before.rs | 0 .../src}/crash_handling.rs | 4 +- .../client => src-common/src}/deep_link.rs | 11 +- .../src}/deep_link/linux.rs | 11 +- .../src}/deep_link/macos.rs | 0 .../src}/deep_link/windows.rs | 20 +- .../client/gui => src-common/src}/errors.rs | 8 +- .../src/client => src-common/src}/ipc.rs | 35 +- rust/gui-client/src-common/src/lib.rs | 12 + rust/gui-client/src-common/src/logging.rs | 191 +++++ rust/gui-client/src-common/src/settings.rs | 113 +++ rust/gui-client/src-common/src/system_tray.rs | 637 +++++++++++++++ .../src}/system_tray/builder.rs | 54 +- .../src/client => src-common/src}/updates.rs | 28 +- .../src/client => src-common/src}/uptime.rs | 0 rust/gui-client/src-tauri/Cargo.toml | 24 +- rust/gui-client/src-tauri/src/client.rs | 41 +- .../src-tauri/src/client/debug_commands.rs | 19 - .../src-tauri/src/client/elevation.rs | 4 +- rust/gui-client/src-tauri/src/client/gui.rs | 753 +++--------------- .../src-tauri/src/client/gui/system_tray.rs | 696 ++-------------- .../src-tauri/src/client/logging.rs | 203 +---- .../src-tauri/src/client/settings.rs | 148 +--- .../src-tauri/src/client/welcome.rs | 3 +- 31 files changed, 1987 insertions(+), 1835 deletions(-) create mode 100644 rust/gui-client/src-common/Cargo.toml rename rust/gui-client/{src-tauri/src/client => src-common/src}/auth.rs (98%) rename rust/gui-client/{src-tauri/src/client/gui/system_tray => src-common/src}/compositor.rs (88%) create mode 100644 rust/gui-client/src-common/src/controller.rs rename rust/gui-client/{src-tauri/src/client/gui => src-common/src/controller}/ran_before.rs (100%) rename rust/gui-client/{src-tauri/src/client => src-common/src}/crash_handling.rs (98%) rename rust/gui-client/{src-tauri/src/client => src-common/src}/deep_link.rs (95%) rename rust/gui-client/{src-tauri/src/client => src-common/src}/deep_link/linux.rs (93%) rename rust/gui-client/{src-tauri/src/client => src-common/src}/deep_link/macos.rs (100%) rename rust/gui-client/{src-tauri/src/client => src-common/src}/deep_link/windows.rs (92%) rename rust/gui-client/{src-tauri/src/client/gui => src-common/src}/errors.rs (95%) rename rust/gui-client/{src-tauri/src/client => src-common/src}/ipc.rs (70%) create mode 100644 rust/gui-client/src-common/src/lib.rs create mode 100644 rust/gui-client/src-common/src/logging.rs create mode 100644 rust/gui-client/src-common/src/settings.rs create mode 100644 rust/gui-client/src-common/src/system_tray.rs rename rust/gui-client/{src-tauri/src/client/gui => src-common/src}/system_tray/builder.rs (76%) rename rust/gui-client/{src-tauri/src/client => src-common/src}/updates.rs (96%) rename rust/gui-client/{src-tauri/src/client => src-common/src}/uptime.rs (100%) diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml index 004b70018..c4f59b17c 100644 --- a/.github/actions/setup-rust/action.yml +++ b/.github/actions/setup-rust/action.yml @@ -28,7 +28,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 firezone-headless-client -p firezone-gui-client -p firezone-tunnel -p gui-smoke-test -p snownet -p firezone-bin-shared') }} + (runner.os == 'Windows' && '-p connlib-client-shared -p firezone-headless-client -p firezone-gui-client -p firezone-gui-client-common -p firezone-tunnel -p gui-smoke-test -p snownet -p firezone-bin-shared') }} runs: using: "composite" diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 0308a748a..93f2c618c 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1889,12 +1889,48 @@ name = "firezone-gui-client" version = "1.3.1" dependencies = [ "anyhow", - "arboard", "atomicwrites", "chrono", "clap", "connlib-client-shared", "connlib-shared", + "dirs", + "firezone-bin-shared", + "firezone-gui-client-common", + "firezone-headless-client", + "firezone-logging", + "native-dialog", + "nix 0.29.0", + "rand 0.8.5", + "rustls", + "sadness-generator", + "secrecy", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-runtime", + "tauri-utils", + "tauri-winrt-notification 0.5.0", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "tracing-panic", + "tracing-subscriber", + "url", + "uuid", + "windows 0.58.0", +] + +[[package]] +name = "firezone-gui-client-common" +version = "1.3.1" +dependencies = [ + "anyhow", + "arboard", + "atomicwrites", + "connlib-shared", "crash-handler", "dirs", "firezone-bin-shared", @@ -1905,36 +1941,25 @@ dependencies = [ "keyring", "minidumper", "native-dialog", - "nix 0.29.0", "output_vt100", "png", "rand 0.8.5", "reqwest", - "rustls", "sadness-generator", "secrecy", "semver", "serde", "serde_json", "subtle", - "tauri", - "tauri-build", - "tauri-runtime", - "tauri-utils", - "tauri-winrt-notification 0.5.0", "thiserror", "time", "tokio", - "tokio-util", "tracing", "tracing-log", - "tracing-panic", "tracing-subscriber", "url", "uuid", - "windows 0.58.0", "winreg 0.52.0", - "wintun", "zip", ] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 0dcf5612c..7e0ae2a09 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -8,6 +8,7 @@ members = [ "connlib/snownet", "connlib/tunnel", "gateway", + "gui-client/src-common", "gui-client/src-tauri", "headless-client", "ip-packet", diff --git a/rust/gui-client/src-common/Cargo.toml b/rust/gui-client/src-common/Cargo.toml new file mode 100644 index 000000000..03988a085 --- /dev/null +++ b/rust/gui-client/src-common/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "firezone-gui-client-common" +# mark:next-gui-version +version = "1.3.1" +edition = "2021" + +[dependencies] +anyhow = { version = "1.0" } +arboard = { version = "3.4.0", default-features = false } +atomicwrites = "0.4.3" +connlib-shared = { workspace = true } +crash-handler = "0.6.2" +firezone-bin-shared = { workspace = true } +firezone-headless-client = { path = "../../headless-client" } +firezone-logging = { workspace = true } +futures = { version = "0.3", default-features = false } +hex = "0.4.3" +minidumper = "0.8.3" +native-dialog = "0.7.0" +output_vt100 = "0.1" +png = "0.17.13" # `png` is mostly free since we already need it for Tauri +rand = "0.8.5" +reqwest = { version = "0.12.5", default-features = false, features = ["stream", "rustls-tls"] } +sadness-generator = "0.5.0" +secrecy = { workspace = true } +semver = { version = "1.0.22", features = ["serde"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +subtle = "2.5.0" +thiserror = { version = "1.0", default-features = false } +time = { version = "0.3.36", features = ["formatting"] } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-log = "0.2" +tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } +url = { version = "2.5.2" } +uuid = { version = "1.10.0", features = ["v4"] } +zip = { version = "2", features = ["deflate", "time"], default-features = false } + +[dependencies.keyring] +version = "3.2.1" +features = [ + "crypto-rust", # Don't rely on OpenSSL + "sync-secret-service", # Can't use Tokio because of + "windows-native", # Yes, really, we must actually explicitly ask for every platform. Otherwise it defaults to an in-memory mock store. Really. That's really how `keyring` 3.x is designed. +] + +[target.'cfg(target_os = "linux")'.dependencies] +dirs = "5.0.1" + +[target.'cfg(target_os = "windows")'.dependencies] +winreg = "0.52.0" + +[lints] +workspace = true diff --git a/rust/gui-client/src-tauri/src/client/auth.rs b/rust/gui-client/src-common/src/auth.rs similarity index 98% rename from rust/gui-client/src-tauri/src/client/auth.rs rename to rust/gui-client/src-common/src/auth.rs index 770dff500..d1b55f4a1 100644 --- a/rust/gui-client/src-tauri/src/client/auth.rs +++ b/rust/gui-client/src-common/src/auth.rs @@ -11,7 +11,7 @@ use url::Url; const NONCE_LENGTH: usize = 32; #[derive(thiserror::Error, Debug)] -pub(crate) enum Error { +pub enum Error { #[error("`actor_name_path` has no parent, this should be impossible")] ActorNamePathWrong, #[error("`known_dirs` failed")] @@ -32,19 +32,19 @@ pub(crate) enum Error { WriteActorName(std::io::Error), } -pub(crate) struct Auth { +pub struct Auth { /// Implementation details in case we need to disable `keyring-rs` token_store: keyring::Entry, state: State, } -pub(crate) enum State { +enum State { SignedOut, NeedResponse(Request), SignedIn(Session), } -pub(crate) struct Request { +pub struct Request { nonce: SecretString, state: SecretString, } @@ -61,17 +61,17 @@ impl Request { } } -pub(crate) struct Response { +pub struct Response { pub actor_name: String, pub fragment: SecretString, pub state: SecretString, } -pub(crate) struct Session { +pub struct Session { pub actor_name: String, } -pub(crate) struct SessionAndToken { +struct SessionAndToken { session: Session, token: SecretString, } diff --git a/rust/gui-client/src-tauri/src/client/gui/system_tray/compositor.rs b/rust/gui-client/src-common/src/compositor.rs similarity index 88% rename from rust/gui-client/src-tauri/src/client/gui/system_tray/compositor.rs rename to rust/gui-client/src-common/src/compositor.rs index 1722aaff8..666e07758 100644 --- a/rust/gui-client/src-tauri/src/client/gui/system_tray/compositor.rs +++ b/rust/gui-client/src-common/src/compositor.rs @@ -11,20 +11,10 @@ use anyhow::{ensure, Context as _, Result}; -pub(crate) struct Image { - width: u32, - height: u32, - rgba: Vec, -} - -impl From for tauri::Icon { - fn from(val: Image) -> Self { - Self::Rgba { - rgba: val.rgba, - width: val.width, - height: val.height, - } - } +pub struct Image { + pub width: u32, + pub height: u32, + pub rgba: Vec, } /// Builds up an image via painter's algorithm @@ -39,7 +29,7 @@ impl From for tauri::Icon { /// /// An `Image` with the same dimensions as the first layer. -pub(crate) fn compose<'a, I: IntoIterator>(layers: I) -> Result { +pub fn compose<'a, I: IntoIterator>(layers: I) -> Result { let mut dst = None; for layer in layers { diff --git a/rust/gui-client/src-common/src/controller.rs b/rust/gui-client/src-common/src/controller.rs new file mode 100644 index 000000000..1fd8c5173 --- /dev/null +++ b/rust/gui-client/src-common/src/controller.rs @@ -0,0 +1,666 @@ +use crate::{ + auth, deep_link, + errors::Error, + ipc, logging, + settings::{self, AdvancedSettings}, + system_tray::{self, Event as TrayMenuEvent}, + updates, +}; +use anyhow::{anyhow, Context, Result}; +use connlib_shared::callbacks::ResourceDescription; +use firezone_bin_shared::{new_dns_notifier, new_network_notifier}; +use firezone_headless_client::{ + IpcClientMsg::{self, SetDisabledResources}, + IpcServerMsg, IpcServiceError, LogFilterReloader, +}; +use secrecy::{ExposeSecret as _, SecretString}; +use std::{collections::BTreeSet, path::PathBuf, time::Instant}; +use tokio::sync::{mpsc, oneshot}; +use url::Url; + +use ControllerRequest as Req; + +mod ran_before; + +pub type CtlrTx = mpsc::Sender; + +pub struct Controller { + /// Debugging-only settings like API URL, auth URL, log filter + pub advanced_settings: AdvancedSettings, + // Sign-in state with the portal / deep links + pub auth: auth::Auth, + pub clear_logs_callback: Option>>, + pub ctlr_tx: CtlrTx, + pub ipc_client: ipc::Client, + pub ipc_rx: mpsc::Receiver, + pub integration: I, + pub log_filter_reloader: LogFilterReloader, + /// A release that's ready to download + pub release: Option, + pub rx: mpsc::Receiver, + pub status: Status, + pub updates_rx: mpsc::Receiver>, + pub uptime: crate::uptime::Tracker, +} + +pub trait GuiIntegration { + fn set_welcome_window_visible(&self, visible: bool) -> Result<()>; + + /// Also opens non-URLs + fn open_url>(&self, url: P) -> Result<()>; + + fn set_tray_icon(&mut self, icon: system_tray::Icon) -> Result<()>; + fn set_tray_menu(&mut self, app_state: system_tray::AppState) -> Result<()>; + fn show_notification(&self, title: &str, body: &str) -> Result<()>; + fn show_update_notification(&self, ctlr_tx: CtlrTx, title: &str, url: url::Url) -> Result<()>; + + /// Shows a window that the system tray knows about, e.g. not Welcome. + fn show_window(&self, window: system_tray::Window) -> Result<()>; +} + +// Allow dead code because `UpdateNotificationClicked` doesn't work on Linux yet +#[allow(dead_code)] +pub enum ControllerRequest { + /// The GUI wants us to use these settings in-memory, they've already been saved to disk + ApplySettings(Box), + /// Clear the GUI's logs and await the IPC service to clear its logs + ClearLogs(oneshot::Sender>), + /// The same as the arguments to `client::logging::export_logs_to` + ExportLogs { + path: PathBuf, + stem: PathBuf, + }, + Fail(Failure), + GetAdvancedSettings(oneshot::Sender), + SchemeRequest(SecretString), + SignIn, + SystemTrayMenu(TrayMenuEvent), + UpdateNotificationClicked(Url), +} + +// The failure flags are all mutually exclusive +// TODO: I can't figure out from the `clap` docs how to do this: +// `app --fail-on-purpose crash-in-wintun-worker` +// So the failure should be an `Option` but _not_ a subcommand. +// You can only have one subcommand per container, I've tried +#[derive(Debug)] +pub enum Failure { + Crash, + Error, + Panic, +} + +pub enum Status { + /// Firezone is disconnected. + Disconnected, + /// At least one connection request has failed, due to failing to reach the Portal, and we are waiting for a network change before we try again + RetryingConnection { + /// The token to log in to the Portal, for retrying the connection request. + token: SecretString, + }, + /// Firezone is ready to use. + TunnelReady { resources: Vec }, + /// Firezone is signing in to the Portal. + WaitingForPortal { + /// The instant when we sent our most recent connect request. + start_instant: Instant, + /// The token to log in to the Portal, in case we need to retry the connection request. + token: SecretString, + }, + /// Firezone has connected to the Portal and is raising the tunnel. + WaitingForTunnel { + /// The instant when we sent our most recent connect request. + start_instant: Instant, + }, +} + +impl Default for Status { + fn default() -> Self { + Self::Disconnected + } +} + +impl Status { + /// Returns true if we want to hear about DNS and network changes. + fn needs_network_changes(&self) -> bool { + match self { + Status::Disconnected | Status::RetryingConnection { .. } => false, + Status::TunnelReady { .. } + | Status::WaitingForPortal { .. } + | Status::WaitingForTunnel { .. } => true, + } + } + + fn internet_resource(&self) -> Option { + #[allow(clippy::wildcard_enum_match_arm)] + match self { + Status::TunnelReady { resources } => { + resources.iter().find(|r| r.is_internet_resource()).cloned() + } + _ => None, + } + } +} + +impl Controller { + pub async fn main_loop(mut self) -> Result<(), Error> { + if let Some(token) = self + .auth + .token() + .context("Failed to load token from disk during app start")? + { + self.start_session(token).await?; + } else { + tracing::info!("No token / actor_name on disk, starting in signed-out state"); + self.refresh_system_tray_menu()?; + } + + if !ran_before::get().await? { + self.integration.set_welcome_window_visible(true)?; + } + + let tokio_handle = tokio::runtime::Handle::current(); + let dns_control_method = Default::default(); + + let mut dns_notifier = new_dns_notifier(tokio_handle.clone(), dns_control_method).await?; + let mut network_notifier = + new_network_notifier(tokio_handle.clone(), dns_control_method).await?; + drop(tokio_handle); + + loop { + // TODO: Add `ControllerRequest::NetworkChange` and `DnsChange` and replace + // `tokio::select!` with a `poll_*` function + tokio::select! { + result = network_notifier.notified() => { + result?; + if self.status.needs_network_changes() { + tracing::debug!("Internet up/down changed, calling `Session::reset`"); + self.ipc_client.reset().await? + } + self.try_retry_connection().await? + } + result = dns_notifier.notified() => { + result?; + if self.status.needs_network_changes() { + let resolvers = firezone_headless_client::dns_control::system_resolvers_for_gui()?; + tracing::debug!(?resolvers, "New DNS resolvers, calling `Session::set_dns`"); + self.ipc_client.set_dns(resolvers).await?; + } + self.try_retry_connection().await? + } + event = self.ipc_rx.recv() => self.handle_ipc_event(event.context("IPC task stopped")?).await?, + req = self.rx.recv() => { + let Some(req) = req else { + break; + }; + + #[allow(clippy::wildcard_enum_match_arm)] + match req { + // SAFETY: Crashing is unsafe + Req::Fail(Failure::Crash) => { + tracing::error!("Crashing on purpose"); + unsafe { sadness_generator::raise_segfault() } + }, + Req::Fail(Failure::Error) => Err(anyhow!("Test error"))?, + Req::Fail(Failure::Panic) => panic!("Test panic"), + Req::SystemTrayMenu(TrayMenuEvent::Quit) => { + tracing::info!("User clicked Quit in the menu"); + break + } + // TODO: Should we really skip cleanup if a request fails? + req => self.handle_request(req).await?, + } + } + notification = self.updates_rx.recv() => self.handle_update_notification(notification.context("Update checker task stopped")?)?, + } + // Code down here may not run because the `select` sometimes `continue`s. + } + + tracing::debug!("Closing..."); + + if let Err(error) = dns_notifier.close() { + tracing::error!(?error, "dns_notifier"); + } + if let Err(error) = network_notifier.close() { + tracing::error!(?error, "network_notifier"); + } + if let Err(error) = self.ipc_client.disconnect_from_ipc().await { + tracing::error!(?error, "ipc_client"); + } + + Ok(()) + } + + async fn start_session(&mut self, token: SecretString) -> Result<(), Error> { + match self.status { + Status::Disconnected | Status::RetryingConnection { .. } => {} + Status::TunnelReady { .. } => Err(anyhow!( + "Can't connect to Firezone, we're already connected." + ))?, + Status::WaitingForPortal { .. } | Status::WaitingForTunnel { .. } => Err(anyhow!( + "Can't connect to Firezone, we're already connecting." + ))?, + } + + let api_url = self.advanced_settings.api_url.clone(); + tracing::info!(api_url = api_url.to_string(), "Starting connlib..."); + + // Count the start instant from before we connect + let start_instant = Instant::now(); + self.ipc_client + .connect_to_firezone(api_url.as_str(), token.expose_secret().clone().into()) + .await?; + // Change the status after we begin connecting + self.status = Status::WaitingForPortal { + start_instant, + token, + }; + self.refresh_system_tray_menu()?; + Ok(()) + } + + async fn handle_deep_link(&mut self, url: &SecretString) -> Result<(), Error> { + let auth_response = + deep_link::parse_auth_callback(url).context("Couldn't parse scheme request")?; + + tracing::info!("Received deep link over IPC"); + // Uses `std::fs` + let token = self + .auth + .handle_response(auth_response) + .context("Couldn't handle auth response")?; + self.start_session(token).await?; + Ok(()) + } + + async fn handle_request(&mut self, req: ControllerRequest) -> Result<(), Error> { + match req { + Req::ApplySettings(settings) => { + let filter = firezone_logging::try_filter(&self.advanced_settings.log_filter) + .context("Couldn't parse new log filter directives")?; + self.advanced_settings = *settings; + self.log_filter_reloader + .reload(filter) + .context("Couldn't reload log filter")?; + self.ipc_client.send_msg(&IpcClientMsg::ReloadLogFilter).await?; + tracing::debug!( + "Applied new settings. Log level will take effect immediately." + ); + // Refresh the menu in case the favorites were reset. + self.refresh_system_tray_menu()?; + } + Req::ClearLogs(completion_tx) => { + if self.clear_logs_callback.is_some() { + tracing::error!("Can't clear logs, we're already waiting on another log-clearing operation"); + } + if let Err(error) = logging::clear_gui_logs().await { + tracing::error!(?error, "Failed to clear GUI logs"); + } + self.ipc_client.send_msg(&IpcClientMsg::ClearLogs).await?; + self.clear_logs_callback = Some(completion_tx); + } + Req::ExportLogs { path, stem } => logging::export_logs_to(path, stem) + .await + .context("Failed to export logs to zip")?, + Req::Fail(_) => Err(anyhow!( + "Impossible error: `Fail` should be handled before this" + ))?, + Req::GetAdvancedSettings(tx) => { + tx.send(self.advanced_settings.clone()).ok(); + } + Req::SchemeRequest(url) => { + if let Err(error) = self.handle_deep_link(&url).await { + tracing::error!(?error, "`handle_deep_link` failed"); + } + } + Req::SignIn | Req::SystemTrayMenu(TrayMenuEvent::SignIn) => { + if let Some(req) = self + .auth + .start_sign_in() + .context("Couldn't start sign-in flow")? + { + let url = req.to_url(&self.advanced_settings.auth_base_url); + self.refresh_system_tray_menu()?; + self.integration.open_url(url.expose_secret()) + .context("Couldn't open auth page")?; + self.integration.set_welcome_window_visible(false)?; + } + } + Req::SystemTrayMenu(TrayMenuEvent::AddFavorite(resource_id)) => { + self.advanced_settings.favorite_resources.insert(resource_id); + self.refresh_favorite_resources().await?; + }, + Req::SystemTrayMenu(TrayMenuEvent::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() + .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 { + Status::Disconnected | Status::RetryingConnection { .. } | Status::WaitingForPortal { .. } => { + tracing::info!("Calling `sign_out` to cancel sign-in"); + self.sign_out().await?; + } + Status::TunnelReady{..} => tracing::error!("Can't cancel sign-in, the tunnel is already up. This is a logic error in the code."), + Status::WaitingForTunnel { .. } => { + tracing::warn!( + "Connlib is already raising the tunnel, calling `sign_out` anyway" + ); + self.sign_out().await?; + } + } + } + Req::SystemTrayMenu(TrayMenuEvent::RemoveFavorite(resource_id)) => { + self.advanced_settings.favorite_resources.remove(&resource_id); + self.refresh_favorite_resources().await?; + } + Req::SystemTrayMenu(TrayMenuEvent::RetryPortalConnection) => self.try_retry_connection().await?, + Req::SystemTrayMenu(TrayMenuEvent::EnableInternetResource) => { + self.advanced_settings.internet_resource_enabled = Some(true); + self.update_disabled_resources().await?; + } + Req::SystemTrayMenu(TrayMenuEvent::DisableInternetResource) => { + self.advanced_settings.internet_resource_enabled = Some(false); + self.update_disabled_resources().await?; + } + Req::SystemTrayMenu(TrayMenuEvent::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 + // dev or test systems without parsing the whole log file. + let uptime_info = self.uptime.info(); + tracing::debug!( + uptime_s = uptime_info.uptime.as_secs(), + run_id = uptime_info.run_id.to_string(), + "Uptime info" + ); + } + Req::SystemTrayMenu(TrayMenuEvent::SignOut) => { + tracing::info!("User asked to sign out"); + self.sign_out().await?; + } + Req::SystemTrayMenu(TrayMenuEvent::Url(url)) => { + self.integration.open_url(&url) + .context("Couldn't open URL from system tray")? + } + Req::SystemTrayMenu(TrayMenuEvent::Quit) => Err(anyhow!( + "Impossible error: `Quit` should be handled before this" + ))?, + Req::UpdateNotificationClicked(download_url) => { + tracing::info!("UpdateNotificationClicked in run_controller!"); + self.integration.open_url(&download_url) + .context("Couldn't open update page")?; + } + } + Ok(()) + } + + async fn handle_ipc_event(&mut self, event: ipc::Event) -> Result<(), Error> { + match event { + ipc::Event::Message(msg) => match self.handle_ipc_msg(msg).await { + Ok(()) => Ok(()), + // Handles more gracefully so we can still export logs even if we crashed right after sign-in + Err(Error::ConnectToFirezoneFailed(error)) => { + tracing::error!(?error, "Failed to connect to Firezone"); + self.sign_out().await?; + Ok(()) + } + Err(error) => Err(error)?, + }, + ipc::Event::ReadFailed(error) => { + // IPC errors are always fatal + tracing::error!(?error, "IPC read failure"); + Err(Error::IpcRead)? + } + ipc::Event::Closed => Err(Error::IpcClosed)?, + } + } + + async fn handle_ipc_msg(&mut self, msg: IpcServerMsg) -> Result<(), Error> { + match msg { + IpcServerMsg::ClearedLogs(result) => { + let Some(tx) = self.clear_logs_callback.take() else { + return Err(Error::Other(anyhow!("Can't handle `IpcClearedLogs` when there's no callback waiting for a `ClearLogs` result"))); + }; + tx.send(result).map_err(|_| { + Error::Other(anyhow!("Couldn't send `ClearLogs` result to Tauri task")) + })?; + Ok(()) + } + IpcServerMsg::ConnectResult(result) => self.handle_connect_result(result).await, + IpcServerMsg::OnDisconnect { + error_msg, + is_authentication_error, + } => { + self.sign_out().await?; + if is_authentication_error { + tracing::info!(?error_msg, "Auth error"); + self.integration.show_notification( + "Firezone disconnected", + "To access resources, sign in again.", + )?; + } else { + tracing::error!(?error_msg, "Disconnected"); + native_dialog::MessageDialog::new() + .set_title("Firezone Error") + .set_text(&error_msg) + .set_type(native_dialog::MessageType::Error) + .show_alert() + .context("Couldn't show Disconnected alert")?; + } + Ok(()) + } + IpcServerMsg::OnUpdateResources(resources) => { + tracing::debug!(len = resources.len(), "Got new Resources"); + self.status = Status::TunnelReady { resources }; + if let Err(error) = self.refresh_system_tray_menu() { + tracing::error!(?error, "Failed to refresh menu"); + } + + self.update_disabled_resources().await?; + + Ok(()) + } + IpcServerMsg::TerminatingGracefully => { + tracing::info!("Caught TerminatingGracefully"); + self.integration + .set_tray_icon(system_tray::icon_terminating()) + .ok(); + Err(Error::IpcServiceTerminating) + } + IpcServerMsg::TunnelReady => { + if self.auth.session().is_none() { + // This could maybe happen if the user cancels the sign-in + // before it completes. This is because the state machine + // between the GUI, the IPC service, and connlib isn't perfectly synced. + tracing::error!("Got `UpdateResources` while signed out"); + return Ok(()); + } + if let Status::WaitingForTunnel { start_instant } = + std::mem::replace(&mut self.status, Status::TunnelReady { resources: vec![] }) + { + tracing::info!(elapsed = ?start_instant.elapsed(), "Tunnel ready"); + self.integration.show_notification( + "Firezone connected", + "You are now signed in and able to access resources.", + )?; + } + if let Err(error) = self.refresh_system_tray_menu() { + tracing::error!(?error, "Failed to refresh menu"); + } + + Ok(()) + } + } + } + + async fn handle_connect_result( + &mut self, + result: Result<(), IpcServiceError>, + ) -> Result<(), Error> { + let (start_instant, token) = match &self.status { + Status::Disconnected + | Status::RetryingConnection { .. } + | Status::TunnelReady { .. } + | Status::WaitingForTunnel { .. } => { + tracing::error!("Impossible logic error, received `ConnectResult` when we weren't waiting on the Portal connection."); + return Ok(()); + } + Status::WaitingForPortal { + start_instant, + token, + } => (*start_instant, token.expose_secret().clone().into()), + }; + + match result { + Ok(()) => { + ran_before::set().await?; + self.status = Status::WaitingForTunnel { start_instant }; + if let Err(error) = self.refresh_system_tray_menu() { + tracing::error!(?error, "Failed to refresh menu"); + } + Ok(()) + } + Err(IpcServiceError::PortalConnection(error)) => { + // This is typically something like, we don't have Internet access so we can't + // open the PhoenixChannel's WebSocket. + tracing::warn!( + ?error, + "Failed to connect to Firezone Portal, will try again when the network changes" + ); + self.status = Status::RetryingConnection { token }; + if let Err(error) = self.refresh_system_tray_menu() { + tracing::error!(?error, "Failed to refresh menu"); + } + Ok(()) + } + Err(msg) => Err(Error::ConnectToFirezoneFailed(msg)), + } + } + + /// Set (or clear) update notification + fn handle_update_notification( + &mut self, + notification: Option, + ) -> Result<()> { + let Some(notification) = notification else { + self.release = None; + self.refresh_system_tray_menu()?; + return Ok(()); + }; + + let release = notification.release; + self.release = Some(release.clone()); + self.refresh_system_tray_menu()?; + + if notification.tell_user { + let title = format!("Firezone {} available for download", release.version); + + // We don't need to route through the controller here either, we could + // use the `open` crate directly instead of Tauri's wrapper + // `tauri::api::shell::open` + self.integration.show_update_notification( + self.ctlr_tx.clone(), + &title, + release.download_url, + )?; + } + Ok(()) + } + + async fn update_disabled_resources(&mut self) -> Result<()> { + settings::save(&self.advanced_settings).await?; + + let internet_resource = self + .status + .internet_resource() + .context("Tunnel not ready")?; + + let mut disabled_resources = BTreeSet::new(); + + if !self.advanced_settings.internet_resource_enabled() { + disabled_resources.insert(internet_resource.id()); + } + + self.ipc_client + .send_msg(&SetDisabledResources(disabled_resources)) + .await?; + self.refresh_system_tray_menu()?; + + Ok(()) + } + + /// Saves the current settings (including favorites) to disk and refreshes the tray menu + async fn refresh_favorite_resources(&mut self) -> Result<()> { + settings::save(&self.advanced_settings).await?; + self.refresh_system_tray_menu()?; + Ok(()) + } + + /// Builds a new system tray menu and applies it to the app + fn refresh_system_tray_menu(&mut self) -> Result<()> { + // TODO: Refactor `Controller` and the auth module so that "Are we logged in?" + // doesn't require such complicated control flow to answer. + let connlib = if let Some(auth_session) = self.auth.session() { + match &self.status { + Status::Disconnected => { + tracing::error!("We have an auth session but no connlib session"); + system_tray::ConnlibState::SignedOut + } + Status::RetryingConnection { .. } => system_tray::ConnlibState::RetryingConnection, + Status::TunnelReady { resources } => { + system_tray::ConnlibState::SignedIn(system_tray::SignedIn { + actor_name: &auth_session.actor_name, + favorite_resources: &self.advanced_settings.favorite_resources, + internet_resource_enabled: &self + .advanced_settings + .internet_resource_enabled, + resources, + }) + } + Status::WaitingForPortal { .. } => system_tray::ConnlibState::WaitingForPortal, + Status::WaitingForTunnel { .. } => system_tray::ConnlibState::WaitingForTunnel, + } + } else if self.auth.ongoing_request().is_ok() { + // Signing in, waiting on deep link callback + system_tray::ConnlibState::WaitingForBrowser + } else { + system_tray::ConnlibState::SignedOut + }; + self.integration.set_tray_menu(system_tray::AppState { + connlib, + release: self.release.clone(), + })?; + Ok(()) + } + + /// If we're in the `RetryingConnection` state, use the token to retry the Portal connection + async fn try_retry_connection(&mut self) -> Result<()> { + let token = match &self.status { + Status::Disconnected + | Status::TunnelReady { .. } + | Status::WaitingForPortal { .. } + | Status::WaitingForTunnel { .. } => return Ok(()), + Status::RetryingConnection { token } => token, + }; + tracing::debug!("Retrying Portal connection..."); + self.start_session(token.expose_secret().clone().into()) + .await?; + Ok(()) + } + + /// Deletes the auth token, stops connlib, and refreshes the tray menu + async fn sign_out(&mut self) -> Result<()> { + self.auth.sign_out()?; + self.status = Status::Disconnected; + tracing::debug!("disconnecting connlib"); + // This is redundant if the token is expired, in that case + // connlib already disconnected itself. + self.ipc_client.disconnect_from_firezone().await?; + self.refresh_system_tray_menu()?; + Ok(()) + } +} diff --git a/rust/gui-client/src-tauri/src/client/gui/ran_before.rs b/rust/gui-client/src-common/src/controller/ran_before.rs similarity index 100% rename from rust/gui-client/src-tauri/src/client/gui/ran_before.rs rename to rust/gui-client/src-common/src/controller/ran_before.rs diff --git a/rust/gui-client/src-tauri/src/client/crash_handling.rs b/rust/gui-client/src-common/src/crash_handling.rs similarity index 98% rename from rust/gui-client/src-tauri/src/client/crash_handling.rs rename to rust/gui-client/src-common/src/crash_handling.rs index e0ca0e7fc..4d71be6d2 100755 --- a/rust/gui-client/src-tauri/src/client/crash_handling.rs +++ b/rust/gui-client/src-common/src/crash_handling.rs @@ -27,7 +27,7 @@ use time::OffsetDateTime; /// /// Linux has a special `set_ptracer` call that is handy /// MacOS needs a special `ping` call to flush messages inside the crash handler -pub(crate) fn attach_handler() -> Result { +pub fn attach_handler() -> Result { // Attempt to connect to the server let (client, _server) = start_server_and_connect()?; @@ -55,7 +55,7 @@ pub(crate) fn attach_handler() -> Result { /// /// /// -pub(crate) fn server(socket_path: PathBuf) -> Result<()> { +pub fn server(socket_path: PathBuf) -> Result<()> { let mut server = minidumper::Server::with_name(&*socket_path)?; let ab = std::sync::atomic::AtomicBool::new(false); server.run(Box::new(Handler::default()), &ab, None)?; diff --git a/rust/gui-client/src-tauri/src/client/deep_link.rs b/rust/gui-client/src-common/src/deep_link.rs similarity index 95% rename from rust/gui-client/src-tauri/src/client/deep_link.rs rename to rust/gui-client/src-common/src/deep_link.rs index 04e97649e..c80da81f2 100644 --- a/rust/gui-client/src-tauri/src/client/deep_link.rs +++ b/rust/gui-client/src-common/src/deep_link.rs @@ -3,7 +3,7 @@ // The IPC parts use the same primitives as the IPC service, UDS on Linux // and named pipes on Windows, so TODO de-dupe the IPC code -use crate::client::auth::Response as AuthResponse; +use crate::auth; use anyhow::{bail, Context as _, Result}; use secrecy::{ExposeSecret, SecretString}; use url::Url; @@ -34,9 +34,9 @@ pub enum Error { Other(#[from] anyhow::Error), } -pub(crate) use imp::{open, register, Server}; +pub use imp::{open, register, Server}; -pub(crate) fn parse_auth_callback(url_secret: &SecretString) -> Result { +pub fn parse_auth_callback(url_secret: &SecretString) -> Result { let url = Url::parse(url_secret.expose_secret())?; if Some(url::Host::Domain("handle_client_sign_in_callback")) != url.host() { bail!("URL host should be `handle_client_sign_in_callback`"); @@ -76,7 +76,7 @@ pub(crate) fn parse_auth_callback(url_secret: &SecretString) -> Result Result Result { + fn parse_callback_wrapper(s: &str) -> Result { super::parse_auth_callback(&SecretString::new(s.to_owned())) } diff --git a/rust/gui-client/src-tauri/src/client/deep_link/linux.rs b/rust/gui-client/src-common/src/deep_link/linux.rs similarity index 93% rename from rust/gui-client/src-tauri/src/client/deep_link/linux.rs rename to rust/gui-client/src-common/src/deep_link/linux.rs index 54344096e..557cb93db 100644 --- a/rust/gui-client/src-tauri/src/client/deep_link/linux.rs +++ b/rust/gui-client/src-common/src/deep_link/linux.rs @@ -9,7 +9,7 @@ use tokio::{ const SOCK_NAME: &str = "deep_link.sock"; -pub(crate) struct Server { +pub struct Server { listener: UnixListener, } @@ -25,7 +25,7 @@ impl Server { /// Still uses `thiserror` so we can catch the deep_link `CantListen` error /// On Windows this uses async because of #5143 and #5566. #[allow(clippy::unused_async)] - pub(crate) async fn new() -> Result { + pub async fn new() -> Result { let path = sock_path()?; let dir = path .parent() @@ -58,7 +58,7 @@ impl Server { /// Await one incoming deep link /// /// To match the Windows API, this consumes the `Server`. - pub(crate) async fn accept(self) -> Result>> { + pub async fn accept(self) -> Result>> { tracing::debug!("deep_link::accept"); let (mut stream, _) = self.listener.accept().await?; tracing::debug!("Accepted Unix domain socket connection"); @@ -82,7 +82,7 @@ impl Server { } } -pub(crate) async fn open(url: &url::Url) -> Result<()> { +pub async fn open(url: &url::Url) -> Result<()> { firezone_headless_client::setup_stdout_logging()?; let path = sock_path()?; @@ -96,7 +96,7 @@ pub(crate) async fn open(url: &url::Url) -> Result<()> { /// Register a URI scheme so that browser can deep link into our app for auth /// /// Performs blocking I/O (Waits on `xdg-desktop-menu` subprocess) -pub(crate) fn register() -> Result<()> { +pub fn register(exe: PathBuf) -> Result<()> { // Write `$HOME/.local/share/applications/firezone-client.desktop` // According to , that's the place to put // per-user desktop entries. @@ -108,7 +108,6 @@ pub(crate) fn register() -> Result<()> { // Don't use atomic writes here - If we lose power, we'll just rewrite this file on // the next boot anyway. let path = dir.join("firezone-client.desktop"); - let exe = std::env::current_exe().context("failed to find our own exe path")?; let content = format!( "[Desktop Entry] Version=1.0 diff --git a/rust/gui-client/src-tauri/src/client/deep_link/macos.rs b/rust/gui-client/src-common/src/deep_link/macos.rs similarity index 100% rename from rust/gui-client/src-tauri/src/client/deep_link/macos.rs rename to rust/gui-client/src-common/src/deep_link/macos.rs diff --git a/rust/gui-client/src-tauri/src/client/deep_link/windows.rs b/rust/gui-client/src-common/src/deep_link/windows.rs similarity index 92% rename from rust/gui-client/src-tauri/src/client/deep_link/windows.rs rename to rust/gui-client/src-common/src/deep_link/windows.rs index b1f0fb1c6..fb2b16741 100644 --- a/rust/gui-client/src-tauri/src/client/deep_link/windows.rs +++ b/rust/gui-client/src-common/src/deep_link/windows.rs @@ -5,12 +5,16 @@ use super::FZ_SCHEME; use anyhow::{Context, Result}; use firezone_bin_shared::BUNDLE_ID; use secrecy::Secret; -use std::{io, path::Path, time::Duration}; +use std::{ + io, + path::{Path, PathBuf}, + time::Duration, +}; use tokio::{io::AsyncReadExt, io::AsyncWriteExt, net::windows::named_pipe}; /// A server for a named pipe, so we can receive deep links from other instances /// of the client launched by web browsers -pub(crate) struct Server { +pub struct Server { inner: named_pipe::NamedPipeServer, } @@ -19,7 +23,7 @@ impl Server { /// /// Panics if there is no Tokio runtime /// Still uses `thiserror` so we can catch the deep_link `CantListen` error - pub(crate) async fn new() -> Result { + pub async fn new() -> Result { // This isn't air-tight - We recreate the whole server on each loop, // rather than binding 1 socket and accepting many streams like a normal socket API. // Tokio appears to be following Windows' underlying API here, so not @@ -35,7 +39,7 @@ impl Server { /// I assume this is based on the underlying Windows API. /// I tried re-using the server and it acted strange. The official Tokio /// examples are not clear on this. - pub(crate) async fn accept(mut self) -> Result>> { + pub async fn accept(mut self) -> Result>> { self.inner .connect() .await @@ -105,12 +109,8 @@ fn pipe_path() -> String { /// /// This is copied almost verbatim from tauri-plugin-deep-link's `register` fn, with an improvement /// that we send the deep link to a subcommand so the URL won't confuse `clap` -pub fn register() -> Result<()> { - let exe = tauri_utils::platform::current_exe() - .context("Can't find our own exe path")? - .display() - .to_string() - .replace("\\\\?\\", ""); +pub fn register(exe: PathBuf) -> Result<()> { + let exe = exe.display().to_string().replace("\\\\?\\", ""); set_registry_values(BUNDLE_ID, &exe).context("Can't set Windows Registry values")?; diff --git a/rust/gui-client/src-tauri/src/client/gui/errors.rs b/rust/gui-client/src-common/src/errors.rs similarity index 95% rename from rust/gui-client/src-tauri/src/client/gui/errors.rs rename to rust/gui-client/src-common/src/errors.rs index 7a94d803f..2bd06883a 100644 --- a/rust/gui-client/src-tauri/src/client/gui/errors.rs +++ b/rust/gui-client/src-common/src/errors.rs @@ -1,17 +1,17 @@ -use super::{deep_link, logging}; +use crate::{self as common, deep_link}; use anyhow::Result; use firezone_headless_client::{ipc, IpcServiceError, FIREZONE_GROUP}; // TODO: Replace with `anyhow` gradually per #[allow(dead_code)] #[derive(Debug, thiserror::Error)] -pub(crate) enum Error { +pub enum Error { #[error("Failed to connect to Firezone for non-Portal-related reason")] ConnectToFirezoneFailed(IpcServiceError), #[error("Deep-link module error: {0}")] DeepLink(#[from] deep_link::Error), #[error("Logging module error: {0}")] - Logging(#[from] logging::Error), + Logging(#[from] common::logging::Error), // `client.rs` provides a more user-friendly message when showing the error dialog box for certain variants #[error("IPC")] @@ -36,7 +36,7 @@ pub(crate) enum Error { /// /// Doesn't play well with async, only use this if we're bailing out of the /// entire process. -pub(crate) fn show_error_dialog(error: &Error) -> Result<()> { +pub fn show_error_dialog(error: &Error) -> Result<()> { // Decision to put the error strings here: // This message gets shown to users in the GUI and could be localized, unlike // messages in the log which only need to be used for `git grep`. diff --git a/rust/gui-client/src-tauri/src/client/ipc.rs b/rust/gui-client/src-common/src/ipc.rs similarity index 70% rename from rust/gui-client/src-tauri/src/client/ipc.rs rename to rust/gui-client/src-common/src/ipc.rs index d691ceec8..703669896 100644 --- a/rust/gui-client/src-tauri/src/client/ipc.rs +++ b/rust/gui-client/src-common/src/ipc.rs @@ -1,14 +1,19 @@ -use crate::client::gui::{ControllerRequest, CtlrTx}; use anyhow::{Context as _, Result}; use firezone_headless_client::{ ipc::{self, Error}, - IpcClientMsg, + IpcClientMsg, IpcServerMsg, }; use futures::{SinkExt, StreamExt}; use secrecy::{ExposeSecret, SecretString}; use std::net::IpAddr; -pub(crate) struct Client { +pub enum Event { + Closed, + Message(IpcServerMsg), + ReadFailed(anyhow::Error), +} + +pub struct Client { task: tokio::task::JoinHandle>, // Needed temporarily to avoid a big refactor. We can remove this in the future. tx: ipc::ClientWrite, @@ -22,7 +27,7 @@ impl Drop for Client { } impl Client { - pub(crate) async fn new(ctlr_tx: CtlrTx) -> Result { + pub async fn new(ctlr_tx: tokio::sync::mpsc::Sender) -> Result { tracing::info!( client_pid = std::process::id(), "Connecting to IPC service..." @@ -30,32 +35,32 @@ impl Client { let (mut rx, tx) = ipc::connect_to_service(ipc::ServiceId::Prod).await?; let task = tokio::task::spawn(async move { while let Some(result) = rx.next().await { - let msg = match result { - Ok(msg) => ControllerRequest::Ipc(msg), - Err(e) => ControllerRequest::IpcReadFailed(e), + let event = match result { + Ok(msg) => Event::Message(msg), + Err(e) => Event::ReadFailed(e), }; - ctlr_tx.send(msg).await?; + ctlr_tx.send(event).await?; } - ctlr_tx.send(ControllerRequest::IpcClosed).await?; + ctlr_tx.send(Event::Closed).await?; Ok(()) }); Ok(Self { task, tx }) } - pub(crate) async fn disconnect_from_ipc(mut self) -> Result<()> { + pub async fn disconnect_from_ipc(mut self) -> Result<()> { self.task.abort(); self.tx.close().await?; Ok(()) } - pub(crate) async fn disconnect_from_firezone(&mut self) -> Result<()> { + pub async fn disconnect_from_firezone(&mut self) -> Result<()> { self.send_msg(&IpcClientMsg::Disconnect) .await .context("Couldn't send Disconnect")?; Ok(()) } - pub(crate) async fn send_msg(&mut self, msg: &IpcClientMsg) -> Result<()> { + pub async fn send_msg(&mut self, msg: &IpcClientMsg) -> Result<()> { self.tx .send(msg) .await @@ -63,7 +68,7 @@ impl Client { Ok(()) } - pub(crate) async fn connect_to_firezone( + pub async fn connect_to_firezone( &mut self, api_url: &str, token: SecretString, @@ -79,7 +84,7 @@ impl Client { Ok(()) } - pub(crate) async fn reset(&mut self) -> Result<()> { + pub async fn reset(&mut self) -> Result<()> { self.send_msg(&IpcClientMsg::Reset) .await .context("Couldn't send Reset")?; @@ -87,7 +92,7 @@ impl Client { } /// Tell connlib about the system's default resolvers - pub(crate) async fn set_dns(&mut self, dns: Vec) -> Result<()> { + pub async fn set_dns(&mut self, dns: Vec) -> Result<()> { self.send_msg(&IpcClientMsg::SetDns(dns)) .await .context("Couldn't send SetDns")?; diff --git a/rust/gui-client/src-common/src/lib.rs b/rust/gui-client/src-common/src/lib.rs new file mode 100644 index 000000000..b6d622825 --- /dev/null +++ b/rust/gui-client/src-common/src/lib.rs @@ -0,0 +1,12 @@ +pub mod auth; +pub mod compositor; +pub mod controller; +pub mod crash_handling; +pub mod deep_link; +pub mod errors; +pub mod ipc; +pub mod logging; +pub mod settings; +pub mod system_tray; +pub mod updates; +pub mod uptime; diff --git a/rust/gui-client/src-common/src/logging.rs b/rust/gui-client/src-common/src/logging.rs new file mode 100644 index 000000000..b496696b0 --- /dev/null +++ b/rust/gui-client/src-common/src/logging.rs @@ -0,0 +1,191 @@ +//! Everything for logging to files, zipping up the files for export, and counting the files + +use anyhow::{bail, Context as _, Result}; +use firezone_headless_client::{known_dirs, LogFilterReloader}; +use serde::Serialize; +use std::{ + fs, + io::{self, ErrorKind::NotFound}, + path::{Path, PathBuf}, +}; +use tokio::task::spawn_blocking; +use tracing::subscriber::set_global_default; +use tracing_log::LogTracer; +use tracing_subscriber::{fmt, layer::SubscriberExt, reload, Layer, Registry}; + +/// 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: LogFilterReloader, +} + +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 +/// +pub fn setup(directives: &str) -> Result { + let log_path = known_dirs::logs().context("Can't compute app log dir")?; + + std::fs::create_dir_all(&log_path).map_err(Error::CreateDirAll)?; + let (layer, logger) = firezone_logging::file::layer(&log_path); + let layer = layer.and_then(fmt::layer()); + let (filter, reloader) = reload::Layer::new(firezone_logging::try_filter(directives)?); + let subscriber = Registry::default().with(layer.with_filter(filter)); + set_global_default(subscriber)?; + if let Err(error) = output_vt100::try_init() { + tracing::warn!( + ?error, + "Failed to init vt100 terminal colors (expected in release builds and in CI)" + ); + } + LogTracer::init()?; + tracing::debug!(?log_path, "Log path"); + Ok(Handles { logger, reloader }) +} + +#[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:?}"); + // 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")??; + 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, + 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 +pub async fn count_logs() -> Result { + // 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 { + 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> { + 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"), + }, + ]) +} diff --git a/rust/gui-client/src-common/src/settings.rs b/rust/gui-client/src-common/src/settings.rs new file mode 100644 index 000000000..23d99d514 --- /dev/null +++ b/rust/gui-client/src-common/src/settings.rs @@ -0,0 +1,113 @@ +//! Everything related to the Settings window, including +//! advanced settings and code for manipulating diagnostic logs. + +use anyhow::{Context as _, Result}; +use atomicwrites::{AtomicFile, OverwriteBehavior}; +use connlib_shared::messages::ResourceId; +use firezone_headless_client::known_dirs; +use serde::{Deserialize, Serialize}; +use std::{collections::HashSet, io::Write, path::PathBuf}; +use url::Url; + +#[derive(Clone, Deserialize, Serialize)] +pub struct AdvancedSettings { + pub auth_base_url: Url, + pub api_url: Url, + #[serde(default)] + pub favorite_resources: HashSet, + #[serde(default)] + pub internet_resource_enabled: Option, + pub log_filter: String, +} + +#[cfg(debug_assertions)] +impl Default for AdvancedSettings { + fn default() -> Self { + Self { + auth_base_url: Url::parse("https://app.firez.one").unwrap(), + api_url: Url::parse("wss://api.firez.one").unwrap(), + favorite_resources: Default::default(), + internet_resource_enabled: Default::default(), + log_filter: "firezone_gui_client=debug,info".to_string(), + } + } +} + +#[cfg(not(debug_assertions))] +impl Default for AdvancedSettings { + fn default() -> Self { + Self { + auth_base_url: Url::parse("https://app.firezone.dev").unwrap(), + api_url: Url::parse("wss://api.firezone.dev").unwrap(), + favorite_resources: Default::default(), + internet_resource_enabled: Default::default(), + log_filter: "info".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 { + 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?; + // Don't create the dir for the log filter file, that's the IPC service's job. + // If it isn't there for some reason yet, just log an error and move on. + let log_filter_path = known_dirs::ipc_log_filter().context("`ipc_log_filter` failed")?; + let f = AtomicFile::new(&log_filter_path, OverwriteBehavior::AllowOverwrite); + // Note: Blocking file write in async function + if let Err(error) = f.write(|f| f.write_all(settings.log_filter.as_bytes())) { + tracing::error!( + ?error, + ?log_filter_path, + "Couldn't write log filter file for IPC service" + ); + } + 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 { + 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::(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"); + } +} diff --git a/rust/gui-client/src-common/src/system_tray.rs b/rust/gui-client/src-common/src/system_tray.rs new file mode 100644 index 000000000..577f6f786 --- /dev/null +++ b/rust/gui-client/src-common/src/system_tray.rs @@ -0,0 +1,637 @@ +use crate::updates::Release; +use connlib_shared::{ + callbacks::{ResourceDescription, Status}, + messages::ResourceId, +}; +use std::collections::HashSet; +use url::Url; + +use builder::item; +pub use builder::{Entry, Event, Item, Menu, Window}; + +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"; + +mod builder; + +pub struct AppState<'a> { + pub connlib: ConnlibState<'a>, + pub release: Option, +} + +impl<'a> AppState<'a> { + pub fn into_menu(self) -> Menu { + let quit_text = match &self.connlib { + ConnlibState::Loading + | 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::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<'a> { + Loading, + RetryingConnection, + SignedIn(SignedIn<'a>), + SignedOut, + WaitingForBrowser, + WaitingForPortal, + WaitingForTunnel, +} + +pub struct SignedIn<'a> { + pub actor_name: &'a str, + pub favorite_resources: &'a HashSet, + pub resources: &'a [ResourceDescription], + pub internet_resource_enabled: &'a Option, +} + +impl<'a> SignedIn<'a> { + 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).selected()); + } else { + submenu.add_item(item(Event::AddFavorite(resource), ADD_FAVORITE)); + } + } + + /// Builds the submenu that has the resource address, name, desc, + /// sites online, etc. + fn resource_submenu(&self, res: &ResourceDescription) -> 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() { + Status::Unknown => NO_ACTIVITY, + Status::Online => GATEWAY_CONNECTED, + Status::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(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(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, 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 selected_item>, S: Into>( + mut self, + id: E, + title: S, + ) -> Self { + self.add_item(item(id, title).selected()); + self + } + } + + fn signed_in<'a>( + resources: &'a [ResourceDescription], + favorite_resources: &'a HashSet, + internet_resource_enabled: &'a Option, + ) -> AppState<'a> { + AppState { + connlib: ConnlibState::SignedIn(SignedIn { + actor_name: "Jane Doe", + favorite_resources, + resources, + internet_resource_enabled, + }), + release: None, + } + } + + fn resources() -> Vec { + 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") + .item( + Event::AddFavorite( + ResourceId::from_str("73037362-715d-4a83-a749-f18eadd970e6").unwrap(), + ), + ADD_FAVORITE, + ) + .separator() + .disabled("Site") + .copyable("test") + .copyable(NO_ACTIVITY), + ) + .add_submenu( + "MyCorp GitLab", + Menu::default() + .item( + Event::Url("https://gitlab.mycorp.com".parse().unwrap()), + "", + ) + .separator() + .disabled("Resource") + .copyable("MyCorp GitLab") + .copyable("gitlab.mycorp.com") + .item( + Event::AddFavorite( + ResourceId::from_str("03000143-e25e-45c7-aafb-144990e57dcd").unwrap(), + ), + ADD_FAVORITE, + ) + .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()), + "", + ) + .separator() + .disabled("Resource") + .copyable("MyCorp GitLab") + .copyable("gitlab.mycorp.com") + .selected_item( + Event::RemoveFavorite(ResourceId::from_str( + "03000143-e25e-45c7-aafb-144990e57dcd", + )?), + REMOVE_FAVORITE, + ) + .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") + .item( + Event::AddFavorite(ResourceId::from_str( + "73037362-715d-4a83-a749-f18eadd970e6", + )?), + ADD_FAVORITE, + ) + .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") + .item( + Event::AddFavorite(ResourceId::from_str( + "73037362-715d-4a83-a749-f18eadd970e6", + )?), + ADD_FAVORITE, + ) + .separator() + .disabled("Site") + .copyable("test") + .copyable(NO_ACTIVITY), + ) + .add_submenu( + "MyCorp GitLab", + Menu::default() + .item( + Event::Url("https://gitlab.mycorp.com".parse().unwrap()), + "", + ) + .separator() + .disabled("Resource") + .copyable("MyCorp GitLab") + .copyable("gitlab.mycorp.com") + .item( + Event::AddFavorite(ResourceId::from_str( + "03000143-e25e-45c7-aafb-144990e57dcd", + )?), + ADD_FAVORITE, + ) + .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(()) + } +} diff --git a/rust/gui-client/src-tauri/src/client/gui/system_tray/builder.rs b/rust/gui-client/src-common/src/system_tray/builder.rs similarity index 76% rename from rust/gui-client/src-tauri/src/client/gui/system_tray/builder.rs rename to rust/gui-client/src-common/src/system_tray/builder.rs index d2f8169a5..32e1ab597 100644 --- a/rust/gui-client/src-tauri/src/client/gui/system_tray/builder.rs +++ b/rust/gui-client/src-common/src/system_tray/builder.rs @@ -4,21 +4,21 @@ use connlib_shared::{callbacks::ResourceDescription, messages::ResourceId}; use serde::{Deserialize, Serialize}; use url::Url; -use super::INTERNET_RESOURCE_DESCRIPTION; +pub const INTERNET_RESOURCE_DESCRIPTION: &str = "All network traffic"; /// A menu that can either be assigned to the system tray directly or used as a submenu in another menu. /// /// Equivalent to `tauri::SystemTrayMenu` #[derive(Debug, Default, PartialEq, Serialize)] -pub(crate) struct Menu { - pub(crate) entries: Vec, +pub struct Menu { + pub entries: Vec, } /// Something that can be shown in a menu, including text items, separators, and submenus /// /// Equivalent to `tauri::SystemTrayMenuEntry` #[derive(Debug, PartialEq, Serialize)] -pub(crate) enum Entry { +pub enum Entry { Item(Item), Separator, Submenu { title: String, inner: Menu }, @@ -28,20 +28,20 @@ pub(crate) enum Entry { /// /// Equivalent to `tauri::CustomMenuItem` #[derive(Debug, PartialEq, Serialize)] -pub(crate) struct Item { +pub struct Item { /// An event to send to the app when the item is clicked. /// /// If `None`, then the item is disabled and greyed out. - pub(crate) event: Option, + pub event: Option, /// The text displayed to the user - pub(crate) title: String, + pub title: String, /// If true, show a checkmark next to the item - pub(crate) selected: bool, + pub selected: bool, } /// Events that the menu can send to the app #[derive(Debug, Deserialize, PartialEq, Serialize)] -pub(crate) enum Event { +pub enum Event { /// Marks this Resource as favorite AddFavorite(ResourceId), /// Opens the admin portal in the default web browser @@ -74,7 +74,7 @@ pub(crate) enum Event { } #[derive(Debug, Deserialize, PartialEq, Serialize)] -pub(crate) enum Window { +pub enum Window { About, Settings, } @@ -112,23 +112,6 @@ impl Menu { self } - /// 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. - pub(crate) fn build(&self) -> tauri::SystemTrayMenu { - let mut menu = tauri::SystemTrayMenu::new(); - for entry in &self.entries { - menu = match entry { - Entry::Item(item) => menu.add_item(item.build()), - Entry::Separator => menu.add_native_item(tauri::SystemTrayMenuItem::Separator), - Entry::Submenu { title, inner } => { - menu.add_submenu(tauri::SystemTraySubmenu::new(title, inner.build())) - } - }; - } - menu - } - /// Appends a menu item that copies its title when clicked pub(crate) fn copyable(mut self, s: &str) -> Self { self.add_item(copyable(s)); @@ -175,23 +158,6 @@ impl Menu { } impl Item { - /// Builds this abstract `Item` into a real item that we can use in Tauri. - fn build(&self) -> tauri::CustomMenuItem { - let mut item = tauri::CustomMenuItem::new( - serde_json::to_string(&self.event) - .expect("`serde_json` should always be able to serialize tray menu events"), - &self.title, - ); - - if self.event.is_none() { - item = item.disabled(); - } - if self.selected { - item = item.selected(); - } - item - } - fn disabled(mut self) -> Self { self.event = None; self diff --git a/rust/gui-client/src-tauri/src/client/updates.rs b/rust/gui-client/src-common/src/updates.rs similarity index 96% rename from rust/gui-client/src-tauri/src/client/updates.rs rename to rust/gui-client/src-common/src/updates.rs index d4d1b71a7..bbdab3eb8 100644 --- a/rust/gui-client/src-tauri/src/client/updates.rs +++ b/rust/gui-client/src-common/src/updates.rs @@ -1,33 +1,33 @@ //! Module to check the Github repo for new releases -use crate::client::{ - about::get_cargo_version, - gui::{ControllerRequest, CtlrTx}, -}; use anyhow::{Context, Result}; use rand::{thread_rng, Rng as _}; use semver::Version; use serde::{Deserialize, Serialize}; use std::{io::Write, path::PathBuf, str::FromStr, time::Duration}; +use tokio::sync::mpsc; use url::Url; #[derive(Clone, Debug, PartialEq)] -pub(crate) struct Notification { - pub(crate) release: Release, +pub struct Notification { + pub release: Release, /// If true, show a pop-up notification and set the dot. If false, only set the dot. - pub(crate) tell_user: bool, + pub tell_user: bool, } /// GUI-friendly release struct /// /// Serialize is derived for debugging #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub(crate) struct Release { - pub(crate) download_url: url::Url, - pub(crate) version: Version, +pub struct Release { + pub download_url: url::Url, + pub version: Version, } -pub(crate) async fn checker_task(ctlr_tx: CtlrTx, debug_mode: bool) -> Result<()> { +pub async fn checker_task( + ctlr_tx: mpsc::Sender>, + debug_mode: bool, +) -> Result<()> { let (current_version, interval_in_seconds) = if debug_mode { (Version::new(1, 0, 0), 30) } else { @@ -63,9 +63,7 @@ pub(crate) async fn checker_task(ctlr_tx: CtlrTx, debug_mode: bool) -> Result<() Event::Notify(notification) => { tracing::debug!("Notify"); write_latest_release_file(notification.as_ref().map(|n| &n.release)).await?; - ctlr_tx - .send(ControllerRequest::SetUpdateNotification(notification)) - .await?; + ctlr_tx.send(notification).await?; } } } @@ -275,7 +273,7 @@ fn parse_version_from_url(url: &Url) -> Result { } pub(crate) fn current_version() -> Result { - Version::from_str(&get_cargo_version()).context("Impossible, our version is invalid") + Version::from_str(env!("CARGO_PKG_VERSION")).context("Impossible, our version is invalid") } #[cfg(test)] diff --git a/rust/gui-client/src-tauri/src/client/uptime.rs b/rust/gui-client/src-common/src/uptime.rs similarity index 100% rename from rust/gui-client/src-tauri/src/client/uptime.rs rename to rust/gui-client/src-common/src/uptime.rs diff --git a/rust/gui-client/src-tauri/Cargo.toml b/rust/gui-client/src-tauri/Cargo.toml index b61dba4da..3e6bd5ed7 100644 --- a/rust/gui-client/src-tauri/Cargo.toml +++ b/rust/gui-client/src-tauri/Cargo.toml @@ -13,52 +13,32 @@ tauri-build = { version = "1.5", features = [] } [dependencies] anyhow = { version = "1.0" } -arboard = { version = "3.4.0", default-features = false } atomicwrites = "0.4.3" chrono = { workspace = true } clap = { version = "4.5", features = ["derive", "env"] } connlib-client-shared = { workspace = true } connlib-shared = { workspace = true } -crash-handler = "0.6.2" firezone-bin-shared = { workspace = true } +firezone-gui-client-common = { path = "../src-common" } firezone-headless-client = { path = "../../headless-client" } firezone-logging = { workspace = true } -futures = { version = "0.3", default-features = false } -hex = "0.4.3" -minidumper = "0.8.3" native-dialog = "0.7.0" -output_vt100 = "0.1" -png = "0.17.13" # `png` is free since we already need it for Tauri rand = "0.8.5" -reqwest = { version = "0.12.5", default-features = false, features = ["stream", "rustls-tls"] } rustls = { workspace = true } sadness-generator = "0.5.0" secrecy = { workspace = true } -semver = { version = "1.0.22", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -subtle = "2.5.0" tauri-runtime = "0.14.2" tauri-utils = "1.6.0" thiserror = { version = "1.0", default-features = false } -time = { version = "0.3.36", features = ["formatting"] } tokio = { workspace = true, features = ["signal", "time", "macros", "rt", "rt-multi-thread"] } tokio-util = { version = "0.7.11", features = ["codec"] } tracing = { workspace = true } -tracing-log = "0.2" tracing-panic = "0.1.2" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } url = { version = "2.5.2", features = ["serde"] } uuid = { version = "1.10.0", features = ["v4"] } -zip = { version = "2", features = ["deflate", "time"], default-features = false } - -[dependencies.keyring] -version = "3.2.1" -features = [ - "crypto-rust", # Don't rely on OpenSSL - "sync-secret-service", # Can't use Tokio because of - "windows-native", # Yes, really, we must actually explicitly ask for every platform. Otherwise it defaults to an in-memory mock store. Really. That's really how `keyring` 3.x is designed. -] [target.'cfg(target_os = "linux")'.dependencies] dirs = "5.0.1" @@ -70,8 +50,6 @@ tauri = { version = "1.7.1", features = [ "dialog", "icon-png", "notification", [target.'cfg(target_os = "windows")'.dependencies] tauri = { version = "1.7.1", features = [ "dialog", "icon-png", "shell-open-api", "system-tray" ] } tauri-winrt-notification = "0.5.0" -winreg = "0.52.0" -wintun = "0.4.0" [target.'cfg(target_os = "windows")'.dependencies.windows] version = "0.58.0" diff --git a/rust/gui-client/src-tauri/src/client.rs b/rust/gui-client/src-tauri/src/client.rs index 23f8b738e..64292a654 100644 --- a/rust/gui-client/src-tauri/src/client.rs +++ b/rust/gui-client/src-tauri/src/client.rs @@ -1,29 +1,24 @@ use anyhow::{bail, Context as _, Result}; use clap::{Args, Parser}; +use firezone_gui_client_common::{ + self as common, controller::Failure, crash_handling, deep_link, settings::AdvancedSettings, +}; use std::path::PathBuf; use tracing::instrument; use tracing_subscriber::EnvFilter; mod about; -mod auth; -mod crash_handling; mod debug_commands; -mod deep_link; mod elevation; mod gui; -mod ipc; mod logging; mod settings; -mod updates; -mod uptime; mod welcome; -use settings::AdvancedSettings; - #[derive(Debug, thiserror::Error)] pub(crate) enum Error { #[error("GUI module error: {0}")] - Gui(#[from] gui::Error), + Gui(#[from] common::errors::Error), } /// The program's entry point, equivalent to `main` @@ -46,7 +41,7 @@ pub(crate) fn run() -> Result<()> { Ok(true) => run_gui(cli), Ok(false) => bail!("The GUI should run as a normal user, not elevated"), Err(error) => { - gui::show_error_dialog(&error)?; + common::errors::show_error_dialog(&error)?; Err(error.into()) } } @@ -64,9 +59,9 @@ pub(crate) fn run() -> Result<()> { } 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 settings = common::settings::load_advanced_settings().unwrap_or_default(); // Don't fix the log filter for smoke tests - let logging::Handles { + let common::logging::Handles { logger: _logger, reloader, } = start_logging(&settings.log_filter)?; @@ -89,9 +84,9 @@ 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 = settings::load_advanced_settings().unwrap_or_default(); + let mut settings = common::settings::load_advanced_settings().unwrap_or_default(); fix_log_filter(&mut settings)?; - let logging::Handles { + let common::logging::Handles { logger: _logger, reloader, } = start_logging(&settings.log_filter)?; @@ -100,7 +95,7 @@ fn run_gui(cli: Cli) -> Result<()> { // Make sure errors get logged, at least to stderr if let Err(error) = &result { tracing::error!(?error, error_msg = %error); - gui::show_error_dialog(error)?; + common::errors::show_error_dialog(error)?; } Ok(result?) @@ -126,8 +121,8 @@ fn fix_log_filter(settings: &mut AdvancedSettings) -> Result<()> { /// Starts logging /// /// Don't drop the log handle or logging will stop. -fn start_logging(directives: &str) -> Result { - let logging_handles = logging::setup(directives)?; +fn start_logging(directives: &str) -> Result { + let logging_handles = common::logging::setup(directives)?; tracing::info!( arch = std::env::consts::ARCH, os = std::env::consts::OS, @@ -188,18 +183,6 @@ impl Cli { } } -// The failure flags are all mutually exclusive -// TODO: I can't figure out from the `clap` docs how to do this: -// `app --fail-on-purpose crash-in-wintun-worker` -// So the failure should be an `Option` but _not_ a subcommand. -// You can only have one subcommand per container, I've tried -#[derive(Debug)] -enum Failure { - Crash, - Error, - Panic, -} - #[derive(clap::Subcommand)] pub enum Cmd { CrashHandlerServer { diff --git a/rust/gui-client/src-tauri/src/client/debug_commands.rs b/rust/gui-client/src-tauri/src/client/debug_commands.rs index 24ac32653..c7163f782 100644 --- a/rust/gui-client/src-tauri/src/client/debug_commands.rs +++ b/rust/gui-client/src-tauri/src/client/debug_commands.rs @@ -6,11 +6,6 @@ use anyhow::Result; #[derive(clap::Subcommand)] pub(crate) enum Cmd { SetAutostart(SetAutostartArgs), - - // Store and check a bogus debug token to make sure `keyring-rs` - // is behaving. - CheckToken(CheckTokenArgs), - StoreToken(StoreTokenArgs), } #[derive(clap::Parser)] @@ -29,23 +24,9 @@ pub(crate) struct StoreTokenArgs { token: String, } -const CRED_NAME: &str = "dev.firezone.client/test_BYKPFT6P/token"; - pub fn run(cmd: Cmd) -> Result<()> { match cmd { Cmd::SetAutostart(SetAutostartArgs { enabled }) => set_autostart(enabled), - - Cmd::CheckToken(CheckTokenArgs { token: expected }) => { - assert_eq!( - keyring::Entry::new_with_target(CRED_NAME, "", "")?.get_password()?, - expected - ); - Ok(()) - } - Cmd::StoreToken(StoreTokenArgs { token }) => { - keyring::Entry::new_with_target(CRED_NAME, "", "")?.set_password(&token)?; - Ok(()) - } } } diff --git a/rust/gui-client/src-tauri/src/client/elevation.rs b/rust/gui-client/src-tauri/src/client/elevation.rs index 6e9feb788..e304829cf 100644 --- a/rust/gui-client/src-tauri/src/client/elevation.rs +++ b/rust/gui-client/src-tauri/src/client/elevation.rs @@ -2,8 +2,8 @@ pub(crate) use platform::gui_check; #[cfg(target_os = "linux")] mod platform { - use crate::client::gui::Error; use anyhow::{Context as _, Result}; + use firezone_gui_client_common::errors::Error; use firezone_headless_client::FIREZONE_GROUP; /// Returns true if all permissions are correct for the GUI to run @@ -36,8 +36,8 @@ mod platform { #[cfg(target_os = "windows")] mod platform { - use crate::client::gui::Error; use anyhow::Result; + use firezone_gui_client_common::errors::Error; // Returns true on Windows /// diff --git a/rust/gui-client/src-tauri/src/client/gui.rs b/rust/gui-client/src-tauri/src/client/gui.rs index 1e9af17cb..edfc82704 100644 --- a/rust/gui-client/src-tauri/src/client/gui.rs +++ b/rust/gui-client/src-tauri/src/client/gui.rs @@ -4,34 +4,27 @@ //! The real macOS Client is in `swift/apple` use crate::client::{ - self, about, deep_link, ipc, logging, - settings::{self, AdvancedSettings}, - updates::Release, - Failure, + self, about, logging, + settings::{self}, }; use anyhow::{anyhow, bail, Context, Result}; -use firezone_bin_shared::{new_dns_notifier, new_network_notifier}; -use firezone_headless_client::{ - IpcClientMsg::{self, SetDisabledResources}, - IpcServerMsg, IpcServiceError, LogFilterReloader, +use common::system_tray::Event as TrayMenuEvent; +use firezone_gui_client_common::{ + self as common, auth, + controller::{Controller, ControllerRequest, CtlrTx, GuiIntegration}, + crash_handling, deep_link, + errors::{self, Error}, + ipc, + settings::AdvancedSettings, + updates, }; -use secrecy::{ExposeSecret, SecretString}; -use std::{ - collections::BTreeSet, - path::PathBuf, - str::FromStr, - time::{Duration, Instant}, -}; -use system_tray::Event as TrayMenuEvent; +use firezone_headless_client::LogFilterReloader; +use secrecy::{ExposeSecret as _, SecretString}; +use std::{path::PathBuf, str::FromStr, time::Duration}; use tauri::{Manager, SystemTrayEvent}; use tokio::sync::{mpsc, oneshot}; use tracing::instrument; -use url::Url; -use ControllerRequest as Req; - -mod errors; -mod ran_before; pub(crate) mod system_tray; #[cfg(target_os = "linux")] @@ -50,12 +43,8 @@ mod os; #[allow(clippy::unnecessary_wraps)] mod os; -use connlib_shared::callbacks::ResourceDescription; -pub(crate) use errors::{show_error_dialog, Error}; pub(crate) use os::set_autostart; -pub(crate) type CtlrTx = mpsc::Sender; - /// All managed state that we might need to access from odd places like Tauri commands. /// /// Note that this never gets Dropped because of @@ -65,17 +54,77 @@ pub(crate) struct Managed { pub inject_faults: bool, } +struct TauriIntegration { + app: tauri::AppHandle, + tray: system_tray::Tray, +} + +impl GuiIntegration for TauriIntegration { + fn set_welcome_window_visible(&self, visible: bool) -> Result<()> { + let win = self + .app + .get_window("welcome") + .context("Couldn't get handle to Welcome window")?; + + if visible { + win.show().context("Couldn't show Welcome window")?; + } else { + win.hide().context("Couldn't hide Welcome window")?; + } + Ok(()) + } + + fn open_url>(&self, url: P) -> Result<()> { + Ok(tauri::api::shell::open(&self.app.shell_scope(), url, None)?) + } + + fn set_tray_icon(&mut self, icon: common::system_tray::Icon) -> Result<()> { + self.tray.set_icon(icon) + } + + fn set_tray_menu(&mut self, app_state: common::system_tray::AppState) -> Result<()> { + self.tray.update(app_state) + } + + fn show_notification(&self, title: &str, body: &str) -> Result<()> { + os::show_notification(title, body) + } + + fn show_update_notification(&self, ctlr_tx: CtlrTx, title: &str, url: url::Url) -> Result<()> { + os::show_update_notification(ctlr_tx, title, url) + } + + fn show_window(&self, window: common::system_tray::Window) -> Result<()> { + let id = match window { + common::system_tray::Window::About => "about", + common::system_tray::Window::Settings => "settings", + }; + + let win = self + .app + .get_window(id) + .context("Couldn't get handle to `{id}` window")?; + + // Needed to bring shown windows to the front + // `request_user_attention` and `set_focus` don't work, at least on Linux + win.hide()?; + // Needed to show windows that are completely hidden + win.show()?; + Ok(()) + } +} + /// Runs the Tauri GUI and returns on exit or unrecoverable error /// /// Still uses `thiserror` so we can catch the deep_link `CantListen` error #[instrument(skip_all)] pub(crate) fn run( cli: client::Cli, - advanced_settings: settings::AdvancedSettings, + advanced_settings: AdvancedSettings, reloader: LogFilterReloader, ) -> Result<(), Error> { // Need to keep this alive so crashes will be handled. Dropping detaches it. - let _crash_handler = match client::crash_handling::attach_handler() { + let _crash_handler = match crash_handling::attach_handler() { Ok(x) => Some(x), Err(error) => { // TODO: None of these logs are actually written yet @@ -95,6 +144,7 @@ pub(crate) fn run( let deep_link_server = rt.block_on(async { deep_link::Server::new().await })?; let (ctlr_tx, ctlr_rx) = mpsc::channel(5); + let (updates_tx, updates_rx) = mpsc::channel(1); let managed = Managed { ctlr_tx: ctlr_tx.clone(), @@ -146,9 +196,8 @@ pub(crate) fn run( .setup(move |app| { let setup_inner = move || { // Check for updates - let ctlr_tx_clone = ctlr_tx.clone(); tokio::spawn(async move { - if let Err(error) = crate::client::updates::checker_task(ctlr_tx_clone, cli.debug_update_check).await + if let Err(error) = updates::checker_task(updates_tx, cli.debug_update_check).await { tracing::error!(?error, "Error in updates::checker_task"); } @@ -168,7 +217,8 @@ pub(crate) fn run( if !cli.no_deep_links { // The single-instance check is done, so register our exe // to handle deep links - deep_link::register().context("Failed to register deep link handler")?; + let exe = tauri_utils::platform::current_exe().context("Can't find our own exe path")?; + deep_link::register(exe).context("Failed to register deep link handler")?; tokio::spawn(accept_deep_links(deep_link_server, ctlr_tx.clone())); } @@ -203,6 +253,7 @@ pub(crate) fn run( ctlr_rx, advanced_settings, reloader, + updates_rx, ) .await }); @@ -317,7 +368,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 - settings::save(&settings::AdvancedSettings::default()).await?; + common::settings::save(&AdvancedSettings::default()).await?; // Check results of tests let zip_len = tokio::fs::metadata(&path) @@ -333,7 +384,7 @@ 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(settings::advanced_settings_path()?).await?); + anyhow::ensure!(tokio::fs::try_exists(common::settings::advanced_settings_path()?).await?); tracing::info!("Quitting on purpose because of `smoke-test` subcommand"); ctlr_tx @@ -377,651 +428,37 @@ fn handle_system_tray_event(app: &tauri::AppHandle, event: TrayMenuEvent) -> Res Ok(()) } -// Allow dead code because `UpdateNotificationClicked` doesn't work on Linux yet -#[allow(dead_code)] -pub(crate) enum ControllerRequest { - /// The GUI wants us to use these settings in-memory, they've already been saved to disk - ApplySettings(AdvancedSettings), - /// Clear the GUI's logs and await the IPC service to clear its logs - ClearLogs(oneshot::Sender>), - /// The same as the arguments to `client::logging::export_logs_to` - ExportLogs { - path: PathBuf, - stem: PathBuf, - }, - Fail(Failure), - GetAdvancedSettings(oneshot::Sender), - Ipc(IpcServerMsg), - IpcClosed, - IpcReadFailed(anyhow::Error), - SchemeRequest(SecretString), - SignIn, - SystemTrayMenu(TrayMenuEvent), - /// Set (or clear) update notification - SetUpdateNotification(Option), - UpdateNotificationClicked(Url), -} - -enum Status { - /// Firezone is disconnected. - Disconnected, - /// At least one connection request has failed, due to failing to reach the Portal, and we are waiting for a network change before we try again - RetryingConnection { - /// The token to log in to the Portal, for retrying the connection request. - token: SecretString, - }, - /// Firezone is ready to use. - TunnelReady { resources: Vec }, - /// Firezone is signing in to the Portal. - WaitingForPortal { - /// The instant when we sent our most recent connect request. - start_instant: Instant, - /// The token to log in to the Portal, in case we need to retry the connection request. - token: SecretString, - }, - /// Firezone has connected to the Portal and is raising the tunnel. - WaitingForTunnel { - /// The instant when we sent our most recent connect request. - start_instant: Instant, - }, -} - -impl Default for Status { - fn default() -> Self { - Self::Disconnected - } -} - -impl Status { - /// Returns true if we want to hear about DNS and network changes. - fn needs_network_changes(&self) -> bool { - match self { - Status::Disconnected | Status::RetryingConnection { .. } => false, - Status::TunnelReady { .. } - | Status::WaitingForPortal { .. } - | Status::WaitingForTunnel { .. } => true, - } - } - - fn internet_resource(&self) -> Option { - #[allow(clippy::wildcard_enum_match_arm)] - match self { - Status::TunnelReady { resources } => { - resources.iter().find(|r| r.is_internet_resource()).cloned() - } - _ => None, - } - } -} - -struct Controller { - /// Debugging-only settings like API URL, auth URL, log filter - advanced_settings: AdvancedSettings, - app: tauri::AppHandle, - // Sign-in state with the portal / deep links - auth: client::auth::Auth, - clear_logs_callback: Option>>, - ctlr_tx: CtlrTx, - ipc_client: ipc::Client, - log_filter_reloader: LogFilterReloader, - /// A release that's ready to download - release: Option, - status: Status, - tray: system_tray::Tray, - uptime: client::uptime::Tracker, -} - -impl Controller { - async fn start_session(&mut self, token: SecretString) -> Result<(), Error> { - match self.status { - Status::Disconnected | Status::RetryingConnection { .. } => {} - Status::TunnelReady { .. } => Err(anyhow!( - "Can't connect to Firezone, we're already connected." - ))?, - Status::WaitingForPortal { .. } | Status::WaitingForTunnel { .. } => Err(anyhow!( - "Can't connect to Firezone, we're already connecting." - ))?, - } - - let api_url = self.advanced_settings.api_url.clone(); - tracing::info!(api_url = api_url.to_string(), "Starting connlib..."); - - // Count the start instant from before we connect - let start_instant = Instant::now(); - self.ipc_client - .connect_to_firezone(api_url.as_str(), token.expose_secret().clone().into()) - .await?; - // Change the status after we begin connecting - self.status = Status::WaitingForPortal { - start_instant, - token, - }; - self.refresh_system_tray_menu()?; - Ok(()) - } - - async fn handle_deep_link(&mut self, url: &SecretString) -> Result<(), Error> { - let auth_response = - client::deep_link::parse_auth_callback(url).context("Couldn't parse scheme request")?; - - tracing::info!("Received deep link over IPC"); - // Uses `std::fs` - let token = self - .auth - .handle_response(auth_response) - .context("Couldn't handle auth response")?; - self.start_session(token).await?; - Ok(()) - } - - async fn handle_request(&mut self, req: ControllerRequest) -> Result<(), Error> { - match req { - Req::ApplySettings(settings) => { - let filter = firezone_logging::try_filter(&self.advanced_settings.log_filter) - .context("Couldn't parse new log filter directives")?; - self.advanced_settings = settings; - self.log_filter_reloader - .reload(filter) - .context("Couldn't reload log filter")?; - self.ipc_client.send_msg(&IpcClientMsg::ReloadLogFilter).await?; - tracing::debug!( - "Applied new settings. Log level will take effect immediately." - ); - // Refresh the menu in case the favorites were reset. - self.refresh_system_tray_menu()?; - } - Req::ClearLogs(completion_tx) => { - if self.clear_logs_callback.is_some() { - tracing::error!("Can't clear logs, we're already waiting on another log-clearing operation"); - } - if let Err(error) = logging::clear_gui_logs().await { - tracing::error!(?error, "Failed to clear GUI logs"); - } - self.ipc_client.send_msg(&IpcClientMsg::ClearLogs).await?; - self.clear_logs_callback = Some(completion_tx); - } - Req::ExportLogs { path, stem } => logging::export_logs_to(path, stem) - .await - .context("Failed to export logs to zip")?, - Req::Fail(_) => Err(anyhow!( - "Impossible error: `Fail` should be handled before this" - ))?, - Req::GetAdvancedSettings(tx) => { - tx.send(self.advanced_settings.clone()).ok(); - } - Req::Ipc(msg) => match self.handle_ipc(msg).await { - Ok(()) => {} - // Handles more gracefully so we can still export logs even if we crashed right after sign-in - Err(Error::ConnectToFirezoneFailed(error)) => { - tracing::error!(?error, "Failed to connect to Firezone"); - self.sign_out().await?; - } - Err(error) => Err(error)?, - } - Req::IpcReadFailed(error) => { - // IPC errors are always fatal - tracing::error!(?error, "IPC read failure"); - Err(Error::IpcRead)? - } - Req::IpcClosed => Err(Error::IpcClosed)?, - Req::SchemeRequest(url) => { - if let Err(error) = self.handle_deep_link(&url).await { - tracing::error!(?error, "`handle_deep_link` failed"); - } - } - Req::SetUpdateNotification(notification) => { - let Some(notification) = notification else { - self.release = None; - self.refresh_system_tray_menu()?; - return Ok(()); - }; - - let release = notification.release; - self.release = Some(release.clone()); - self.refresh_system_tray_menu()?; - - if notification.tell_user { - let title = format!("Firezone {} available for download", release.version); - - // We don't need to route through the controller here either, we could - // use the `open` crate directly instead of Tauri's wrapper - // `tauri::api::shell::open` - os::show_update_notification(self.ctlr_tx.clone(), &title, release.download_url)?; - } - } - Req::SignIn | Req::SystemTrayMenu(TrayMenuEvent::SignIn) => { - if let Some(req) = self - .auth - .start_sign_in() - .context("Couldn't start sign-in flow")? - { - let url = req.to_url(&self.advanced_settings.auth_base_url); - self.refresh_system_tray_menu()?; - tauri::api::shell::open(&self.app.shell_scope(), url.expose_secret(), None) - .context("Couldn't open auth page")?; - self.app - .get_window("welcome") - .context("Couldn't get handle to Welcome window")? - .hide() - .context("Couldn't hide Welcome window")?; - } - } - Req::SystemTrayMenu(TrayMenuEvent::AddFavorite(resource_id)) => { - self.advanced_settings.favorite_resources.insert(resource_id); - self.refresh_favorite_resources().await?; - }, - Req::SystemTrayMenu(TrayMenuEvent::AdminPortal) => tauri::api::shell::open( - &self.app.shell_scope(), - &self.advanced_settings.auth_base_url, - None, - ) - .context("Couldn't open auth page")?, - Req::SystemTrayMenu(TrayMenuEvent::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 { - Status::Disconnected | Status::RetryingConnection { .. } | Status::WaitingForPortal { .. } => { - tracing::info!("Calling `sign_out` to cancel sign-in"); - self.sign_out().await?; - } - Status::TunnelReady{..} => tracing::error!("Can't cancel sign-in, the tunnel is already up. This is a logic error in the code."), - Status::WaitingForTunnel { .. } => { - tracing::warn!( - "Connlib is already raising the tunnel, calling `sign_out` anyway" - ); - self.sign_out().await?; - } - } - } - Req::SystemTrayMenu(TrayMenuEvent::RemoveFavorite(resource_id)) => { - self.advanced_settings.favorite_resources.remove(&resource_id); - self.refresh_favorite_resources().await?; - } - Req::SystemTrayMenu(TrayMenuEvent::RetryPortalConnection) => self.try_retry_connection().await?, - Req::SystemTrayMenu(TrayMenuEvent::EnableInternetResource) => { - self.advanced_settings.internet_resource_enabled = Some(true); - self.update_disabled_resources().await?; - } - Req::SystemTrayMenu(TrayMenuEvent::DisableInternetResource) => { - self.advanced_settings.internet_resource_enabled = Some(false); - self.update_disabled_resources().await?; - } - Req::SystemTrayMenu(TrayMenuEvent::ShowWindow(window)) => { - self.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 - // dev or test systems without parsing the whole log file. - let uptime_info = self.uptime.info(); - tracing::debug!( - uptime_s = uptime_info.uptime.as_secs(), - run_id = uptime_info.run_id.to_string(), - "Uptime info" - ); - } - Req::SystemTrayMenu(TrayMenuEvent::SignOut) => { - tracing::info!("User asked to sign out"); - self.sign_out().await?; - } - Req::SystemTrayMenu(TrayMenuEvent::Url(url)) => { - tauri::api::shell::open(&self.app.shell_scope(), url, None) - .context("Couldn't open URL from system tray")? - } - Req::SystemTrayMenu(TrayMenuEvent::Quit) => Err(anyhow!( - "Impossible error: `Quit` should be handled before this" - ))?, - Req::UpdateNotificationClicked(download_url) => { - tracing::info!("UpdateNotificationClicked in run_controller!"); - tauri::api::shell::open(&self.app.shell_scope(), download_url, None) - .context("Couldn't open update page")?; - } - } - Ok(()) - } - - async fn handle_ipc(&mut self, msg: IpcServerMsg) -> Result<(), Error> { - match msg { - IpcServerMsg::ClearedLogs(result) => { - let Some(tx) = self.clear_logs_callback.take() else { - return Err(Error::Other(anyhow!("Can't handle `IpcClearedLogs` when there's no callback waiting for a `ClearLogs` result"))); - }; - tx.send(result).map_err(|_| { - Error::Other(anyhow!("Couldn't send `ClearLogs` result to Tauri task")) - })?; - Ok(()) - } - IpcServerMsg::ConnectResult(result) => self.handle_connect_result(result).await, - IpcServerMsg::OnDisconnect { - error_msg, - is_authentication_error, - } => { - self.sign_out().await?; - if is_authentication_error { - tracing::info!(?error_msg, "Auth error"); - os::show_notification( - "Firezone disconnected", - "To access resources, sign in again.", - )?; - } else { - tracing::error!(?error_msg, "Disconnected"); - native_dialog::MessageDialog::new() - .set_title("Firezone Error") - .set_text(&error_msg) - .set_type(native_dialog::MessageType::Error) - .show_alert() - .context("Couldn't show Disconnected alert")?; - } - Ok(()) - } - IpcServerMsg::OnUpdateResources(resources) => { - tracing::debug!(len = resources.len(), "Got new Resources"); - self.status = Status::TunnelReady { resources }; - if let Err(error) = self.refresh_system_tray_menu() { - tracing::error!(?error, "Failed to refresh menu"); - } - - self.update_disabled_resources().await?; - - Ok(()) - } - IpcServerMsg::TerminatingGracefully => { - tracing::info!("Caught TerminatingGracefully"); - self.tray.set_icon(system_tray::Icon::terminating()).ok(); - Err(Error::IpcServiceTerminating) - } - IpcServerMsg::TunnelReady => { - if self.auth.session().is_none() { - // This could maybe happen if the user cancels the sign-in - // before it completes. This is because the state machine - // between the GUI, the IPC service, and connlib isn't perfectly synced. - tracing::error!("Got `UpdateResources` while signed out"); - return Ok(()); - } - if let Status::WaitingForTunnel { start_instant } = - std::mem::replace(&mut self.status, Status::TunnelReady { resources: vec![] }) - { - tracing::info!(elapsed = ?start_instant.elapsed(), "Tunnel ready"); - os::show_notification( - "Firezone connected", - "You are now signed in and able to access resources.", - )?; - } - if let Err(error) = self.refresh_system_tray_menu() { - tracing::error!(?error, "Failed to refresh menu"); - } - - Ok(()) - } - } - } - - async fn handle_connect_result( - &mut self, - result: Result<(), IpcServiceError>, - ) -> Result<(), Error> { - let (start_instant, token) = match &self.status { - Status::Disconnected - | Status::RetryingConnection { .. } - | Status::TunnelReady { .. } - | Status::WaitingForTunnel { .. } => { - tracing::error!("Impossible logic error, received `ConnectResult` when we weren't waiting on the Portal connection."); - return Ok(()); - } - Status::WaitingForPortal { - start_instant, - token, - } => (*start_instant, token.expose_secret().clone().into()), - }; - - match result { - Ok(()) => { - ran_before::set().await?; - self.status = Status::WaitingForTunnel { start_instant }; - if let Err(error) = self.refresh_system_tray_menu() { - tracing::error!(?error, "Failed to refresh menu"); - } - Ok(()) - } - Err(IpcServiceError::PortalConnection(error)) => { - // This is typically something like, we don't have Internet access so we can't - // open the PhoenixChannel's WebSocket. - tracing::warn!( - ?error, - "Failed to connect to Firezone Portal, will try again when the network changes" - ); - self.status = Status::RetryingConnection { token }; - if let Err(error) = self.refresh_system_tray_menu() { - tracing::error!(?error, "Failed to refresh menu"); - } - Ok(()) - } - Err(msg) => Err(Error::ConnectToFirezoneFailed(msg)), - } - } - - async fn update_disabled_resources(&mut self) -> Result<()> { - settings::save(&self.advanced_settings).await?; - - let internet_resource = self - .status - .internet_resource() - .context("Tunnel not ready")?; - - let mut disabled_resources = BTreeSet::new(); - - if !self.advanced_settings.internet_resource_enabled() { - disabled_resources.insert(internet_resource.id()); - } - - self.ipc_client - .send_msg(&SetDisabledResources(disabled_resources)) - .await?; - self.refresh_system_tray_menu()?; - - Ok(()) - } - - /// Saves the current settings (including favorites) to disk and refreshes the tray menu - async fn refresh_favorite_resources(&mut self) -> Result<()> { - settings::save(&self.advanced_settings).await?; - self.refresh_system_tray_menu()?; - Ok(()) - } - - /// Builds a new system tray menu and applies it to the app - fn refresh_system_tray_menu(&mut self) -> Result<()> { - // TODO: Refactor `Controller` and the auth module so that "Are we logged in?" - // doesn't require such complicated control flow to answer. - let connlib = if let Some(auth_session) = self.auth.session() { - match &self.status { - Status::Disconnected => { - tracing::error!("We have an auth session but no connlib session"); - system_tray::ConnlibState::SignedOut - } - Status::RetryingConnection { .. } => system_tray::ConnlibState::RetryingConnection, - Status::TunnelReady { resources } => { - system_tray::ConnlibState::SignedIn(system_tray::SignedIn { - actor_name: &auth_session.actor_name, - favorite_resources: &self.advanced_settings.favorite_resources, - internet_resource_enabled: &self - .advanced_settings - .internet_resource_enabled, - resources, - }) - } - Status::WaitingForPortal { .. } => system_tray::ConnlibState::WaitingForPortal, - Status::WaitingForTunnel { .. } => system_tray::ConnlibState::WaitingForTunnel, - } - } else if self.auth.ongoing_request().is_ok() { - // Signing in, waiting on deep link callback - system_tray::ConnlibState::WaitingForBrowser - } else { - system_tray::ConnlibState::SignedOut - }; - self.tray.update(system_tray::AppState { - connlib, - release: self.release.clone(), - })?; - Ok(()) - } - - /// If we're in the `RetryingConnection` state, use the token to retry the Portal connection - async fn try_retry_connection(&mut self) -> Result<()> { - let token = match &self.status { - Status::Disconnected - | Status::TunnelReady { .. } - | Status::WaitingForPortal { .. } - | Status::WaitingForTunnel { .. } => return Ok(()), - Status::RetryingConnection { token } => token, - }; - tracing::debug!("Retrying Portal connection..."); - self.start_session(token.expose_secret().clone().into()) - .await?; - Ok(()) - } - - /// Deletes the auth token, stops connlib, and refreshes the tray menu - async fn sign_out(&mut self) -> Result<()> { - self.auth.sign_out()?; - self.status = Status::Disconnected; - tracing::debug!("disconnecting connlib"); - // This is redundant if the token is expired, in that case - // connlib already disconnected itself. - self.ipc_client.disconnect_from_firezone().await?; - self.refresh_system_tray_menu()?; - Ok(()) - } - - fn show_window(&self, window: system_tray::Window) -> Result<()> { - let id = match window { - system_tray::Window::About => "about", - system_tray::Window::Settings => "settings", - }; - - let win = self - .app - .get_window(id) - .context("Couldn't get handle to `{id}` window")?; - - // Needed to bring shown windows to the front - // `request_user_attention` and `set_focus` don't work, at least on Linux - win.hide()?; - // Needed to show windows that are completely hidden - win.show()?; - Ok(()) - } -} - // TODO: Move this into `impl Controller` async fn run_controller( app: tauri::AppHandle, ctlr_tx: CtlrTx, - mut rx: mpsc::Receiver, + rx: mpsc::Receiver, advanced_settings: AdvancedSettings, log_filter_reloader: LogFilterReloader, + updates_rx: mpsc::Receiver>, ) -> Result<(), Error> { tracing::info!("Entered `run_controller`"); - let ipc_client = ipc::Client::new(ctlr_tx.clone()).await?; + let (ipc_tx, ipc_rx) = mpsc::channel(1); + let ipc_client = ipc::Client::new(ipc_tx).await?; let tray = system_tray::Tray::new(app.tray_handle()); - let mut controller = Controller { + let integration = TauriIntegration { app, tray }; + let controller = Controller { advanced_settings, - app: app.clone(), - auth: client::auth::Auth::new()?, + auth: auth::Auth::new()?, clear_logs_callback: None, ctlr_tx, ipc_client, + ipc_rx, + integration, log_filter_reloader, release: None, + rx, status: Default::default(), - tray, + updates_rx, uptime: Default::default(), }; - if let Some(token) = controller - .auth - .token() - .context("Failed to load token from disk during app start")? - { - controller.start_session(token).await?; - } else { - tracing::info!("No token / actor_name on disk, starting in signed-out state"); - controller.refresh_system_tray_menu()?; - } - - if !ran_before::get().await? { - let win = app - .get_window("welcome") - .context("Couldn't get handle to Welcome window")?; - win.show().context("Couldn't show Welcome window")?; - } - - let tokio_handle = tokio::runtime::Handle::current(); - let dns_control_method = Default::default(); - - let mut dns_notifier = new_dns_notifier(tokio_handle.clone(), dns_control_method).await?; - let mut network_notifier = - new_network_notifier(tokio_handle.clone(), dns_control_method).await?; - drop(tokio_handle); - - loop { - // TODO: Add `ControllerRequest::NetworkChange` and `DnsChange` and replace - // `tokio::select!` with a `poll_*` function - tokio::select! { - result = network_notifier.notified() => { - result?; - if controller.status.needs_network_changes() { - tracing::debug!("Internet up/down changed, calling `Session::reset`"); - controller.ipc_client.reset().await? - } - controller.try_retry_connection().await? - }, - result = dns_notifier.notified() => { - result?; - if controller.status.needs_network_changes() { - let resolvers = firezone_headless_client::dns_control::system_resolvers_for_gui()?; - tracing::debug!(?resolvers, "New DNS resolvers, calling `Session::set_dns`"); - controller.ipc_client.set_dns(resolvers).await?; - } - controller.try_retry_connection().await? - }, - req = rx.recv() => { - let Some(req) = req else { - break; - }; - - #[allow(clippy::wildcard_enum_match_arm)] - match req { - // SAFETY: Crashing is unsafe - Req::Fail(Failure::Crash) => { - tracing::error!("Crashing on purpose"); - unsafe { sadness_generator::raise_segfault() } - }, - Req::Fail(Failure::Error) => Err(anyhow!("Test error"))?, - Req::Fail(Failure::Panic) => panic!("Test panic"), - Req::SystemTrayMenu(TrayMenuEvent::Quit) => { - tracing::info!("User clicked Quit in the menu"); - break - } - // TODO: Should we really skip cleanup if a request fails? - req => controller.handle_request(req).await?, - } - }, - } - // Code down here may not run because the `select` sometimes `continue`s. - } - - tracing::debug!("Closing..."); - - if let Err(error) = dns_notifier.close() { - tracing::error!(?error, "dns_notifier"); - } - if let Err(error) = network_notifier.close() { - tracing::error!(?error, "network_notifier"); - } - if let Err(error) = controller.ipc_client.disconnect_from_ipc().await { - tracing::error!(?error, "ipc_client"); - } + controller.main_loop().await?; // Last chance to do any drops / cleanup before the process crashes. diff --git a/rust/gui-client/src-tauri/src/client/gui/system_tray.rs b/rust/gui-client/src-tauri/src/client/gui/system_tray.rs index 7f5e3905e..b7e229376 100644 --- a/rust/gui-client/src-tauri/src/client/gui/system_tray.rs +++ b/rust/gui-client/src-tauri/src/client/gui/system_tray.rs @@ -5,20 +5,12 @@ //! "Notification Area" is Microsoft's official name instead of "System tray": //! -use crate::client::updates::Release; use anyhow::Result; -use connlib_shared::{ - callbacks::{ResourceDescription, Status}, - messages::ResourceId, +use firezone_gui_client_common::{ + compositor::{self, Image}, + system_tray::{AppState, ConnlibState, Entry, Icon, IconBase, Item, Menu}, }; -use std::collections::HashSet; use tauri::{SystemTray, SystemTrayHandle}; -use url::Url; - -mod builder; -pub(crate) mod compositor; - -pub(crate) use builder::{item, Event, Menu, Window}; // Figma is the source of truth for the tray icon layers // @@ -29,26 +21,6 @@ const SIGNED_OUT_LAYER: &[u8] = include_bytes!("../../../icons/tray/Signed out l const UPDATE_READY_LAYER: &[u8] = include_bytes!("../../../icons/tray/Update ready layer.png"); const TOOLTIP: &str = "Firezone"; -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"; - -pub(crate) const INTERNET_RESOURCE_DESCRIPTION: &str = "All network traffic"; pub(crate) fn loading() -> SystemTray { let state = AppState { @@ -56,8 +28,8 @@ pub(crate) fn loading() -> SystemTray { release: None, }; SystemTray::new() - .with_icon(Icon::default().tauri_icon()) - .with_menu(state.build()) + .with_icon(icon_to_tauri_icon(&Icon::default())) + .with_menu(build_app_state(state)) .with_tooltip(TOOLTIP) } @@ -66,126 +38,25 @@ pub(crate) struct Tray { last_icon_set: Icon, } -pub(crate) struct AppState<'a> { - pub(crate) connlib: ConnlibState<'a>, - pub(crate) release: Option, -} - -pub(crate) enum ConnlibState<'a> { - Loading, - RetryingConnection, - SignedIn(SignedIn<'a>), - SignedOut, - WaitingForBrowser, - WaitingForPortal, - WaitingForTunnel, -} - -pub(crate) struct SignedIn<'a> { - pub(crate) actor_name: &'a str, - pub(crate) favorite_resources: &'a HashSet, - pub(crate) resources: &'a [ResourceDescription], - pub(crate) internet_resource_enabled: &'a Option, -} - -impl<'a> SignedIn<'a> { - 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).selected()); - } else { - submenu.add_item(item(Event::AddFavorite(resource), ADD_FAVORITE)); - } - } - - /// Builds the submenu that has the resource address, name, desc, - /// sites online, etc. - fn resource_submenu(&self, res: &ResourceDescription) -> 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() { - Status::Unknown => NO_ACTIVITY, - Status::Online => GATEWAY_CONNECTED, - Status::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() +fn icon_to_tauri_icon(that: &Icon) -> tauri::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_tauri_icon(composed) } -#[derive(PartialEq)] -pub(crate) struct Icon { - base: IconBase, - update_ready: bool, -} - -#[derive(PartialEq)] -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, - } - } -} - -impl Icon { - fn tauri_icon(&self) -> tauri::Icon { - let layers = match self.base { - IconBase::Busy => &[LOGO_GREY_BASE, BUSY_LAYER][..], - IconBase::SignedIn => &[LOGO_BASE][..], - IconBase::SignedOut => &[LOGO_GREY_BASE, SIGNED_OUT_LAYER][..], - } - .iter() - .copied() - .chain(self.update_ready.then_some(UPDATE_READY_LAYER)); - let composed = compositor::compose(layers) - .expect("PNG decoding should always succeed for baked-in PNGs"); - composed.into() - } - - /// Generic icon for unusual terminating cases like if the IPC service stops running - pub(crate) fn terminating() -> Self { - Self { - base: IconBase::SignedOut, - update_ready: false, - } +fn image_to_tauri_icon(val: Image) -> tauri::Icon { + tauri::Icon::Rgba { + rgba: val.rgba, + width: val.width, + height: val.height, } } @@ -213,7 +84,7 @@ impl Tray { }; self.handle.set_tooltip(TOOLTIP)?; - self.handle.set_menu(state.build())?; + self.handle.set_menu(build_app_state(state))?; self.set_icon(new_icon)?; Ok(()) @@ -227,510 +98,47 @@ impl Tray { // // Yes, even if you use `Icon::File` and tell Tauri that the icon is already // on disk. - self.handle.set_icon(icon.tauri_icon())?; + self.handle.set_icon(icon_to_tauri_icon(&icon))?; self.last_icon_set = icon; } Ok(()) } } -impl<'a> AppState<'a> { - fn build(self) -> tauri::SystemTrayMenu { - self.into_menu().build() - } - - fn into_menu(self) -> Menu { - let quit_text = match &self.connlib { - ConnlibState::Loading - | 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::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) - } +fn build_app_state(that: AppState) -> tauri::SystemTrayMenu { + build_menu(&that.into_menu()) } -fn append_status(name: &str, enabled: bool) -> String { - let symbol = if enabled { - ENABLED_SYMBOL - } else { - DISABLED_SYMBOL - }; - - format!("{symbol} {name}") -} - -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()); +/// 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. +pub(crate) fn build_menu(that: &Menu) -> tauri::SystemTrayMenu { + let mut menu = tauri::SystemTrayMenu::new(); + for entry in &that.entries { + menu = match entry { + Entry::Item(item) => menu.add_item(build_item(item)), + Entry::Separator => menu.add_native_item(tauri::SystemTrayMenuItem::Separator), + Entry::Submenu { title, inner } => { + menu.add_submenu(tauri::SystemTraySubmenu::new(title, build_menu(inner))) } - - 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") -} - -impl Menu { - /// Appends things that always show, like About, Settings, Help, Quit, etc. - pub(crate) fn add_bottom_section(mut self, release: Option, 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 _; - - impl Menu { - fn selected_item>, S: Into>( - mut self, - id: E, - title: S, - ) -> Self { - self.add_item(item(id, title).selected()); - self - } - } - - fn signed_in<'a>( - resources: &'a [ResourceDescription], - favorite_resources: &'a HashSet, - internet_resource_enabled: &'a Option, - ) -> AppState<'a> { - AppState { - connlib: ConnlibState::SignedIn(SignedIn { - actor_name: "Jane Doe", - favorite_resources, - resources, - internet_resource_enabled, - }), - release: None, - } - } - - fn resources() -> Vec { - 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") - .item( - Event::AddFavorite( - ResourceId::from_str("73037362-715d-4a83-a749-f18eadd970e6").unwrap(), - ), - ADD_FAVORITE, - ) - .separator() - .disabled("Site") - .copyable("test") - .copyable(NO_ACTIVITY), - ) - .add_submenu( - "MyCorp GitLab", - Menu::default() - .item( - Event::Url("https://gitlab.mycorp.com".parse().unwrap()), - "", - ) - .separator() - .disabled("Resource") - .copyable("MyCorp GitLab") - .copyable("gitlab.mycorp.com") - .item( - Event::AddFavorite( - ResourceId::from_str("03000143-e25e-45c7-aafb-144990e57dcd").unwrap(), - ), - ADD_FAVORITE, - ) - .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()), - "", - ) - .separator() - .disabled("Resource") - .copyable("MyCorp GitLab") - .copyable("gitlab.mycorp.com") - .selected_item( - Event::RemoveFavorite(ResourceId::from_str( - "03000143-e25e-45c7-aafb-144990e57dcd", - )?), - REMOVE_FAVORITE, - ) - .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") - .item( - Event::AddFavorite(ResourceId::from_str( - "73037362-715d-4a83-a749-f18eadd970e6", - )?), - ADD_FAVORITE, - ) - .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") - .item( - Event::AddFavorite(ResourceId::from_str( - "73037362-715d-4a83-a749-f18eadd970e6", - )?), - ADD_FAVORITE, - ) - .separator() - .disabled("Site") - .copyable("test") - .copyable(NO_ACTIVITY), - ) - .add_submenu( - "MyCorp GitLab", - Menu::default() - .item( - Event::Url("https://gitlab.mycorp.com".parse().unwrap()), - "", - ) - .separator() - .disabled("Resource") - .copyable("MyCorp GitLab") - .copyable("gitlab.mycorp.com") - .item( - Event::AddFavorite(ResourceId::from_str( - "03000143-e25e-45c7-aafb-144990e57dcd", - )?), - ADD_FAVORITE, - ) - .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(()) - } +/// Builds this abstract `Item` into a real item that we can use in Tauri. +fn build_item(that: &Item) -> tauri::CustomMenuItem { + let mut item = tauri::CustomMenuItem::new( + serde_json::to_string(&that.event) + .expect("`serde_json` should always be able to serialize tray menu events"), + &that.title, + ); + + if that.event.is_none() { + item = item.disabled(); + } + if that.selected { + item = item.selected(); + } + item } diff --git a/rust/gui-client/src-tauri/src/client/logging.rs b/rust/gui-client/src-tauri/src/client/logging.rs index 700904494..17b61c5dc 100644 --- a/rust/gui-client/src-tauri/src/client/logging.rs +++ b/rust/gui-client/src-tauri/src/client/logging.rs @@ -1,78 +1,14 @@ -//! Everything for logging to files, zipping up the files for export, and counting the files - -use crate::client::gui::{ControllerRequest, CtlrTx, Managed}; -use anyhow::{bail, Context, Result}; -use firezone_headless_client::{known_dirs, LogFilterReloader}; -use serde::Serialize; -use std::{ - fs, - io::{self, ErrorKind::NotFound}, - path::{Path, PathBuf}, +use crate::client::gui::Managed; +use anyhow::{bail, Result}; +use firezone_gui_client_common::{ + controller::{ControllerRequest, CtlrTx}, + logging as common, }; -use tokio::{sync::oneshot, task::spawn_blocking}; -use tracing::subscriber::set_global_default; -use tracing_log::LogTracer; -use tracing_subscriber::{fmt, layer::SubscriberExt, reload, Layer, Registry}; - -/// If you don't store `Handles` in a variable, the file logger handle will drop immediately, -/// resulting in empty log files. -#[must_use] -pub(crate) struct Handles { - pub logger: firezone_logging::file::Handle, - pub reloader: LogFilterReloader, -} - -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(crate) 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 -/// -pub(crate) fn setup(directives: &str) -> Result { - let log_path = known_dirs::logs().context("Can't compute app log dir")?; - - std::fs::create_dir_all(&log_path).map_err(Error::CreateDirAll)?; - let (layer, logger) = firezone_logging::file::layer(&log_path); - let layer = layer.and_then(fmt::layer()); - let (filter, reloader) = reload::Layer::new(firezone_logging::try_filter(directives)?); - let subscriber = Registry::default().with(layer.with_filter(filter)); - set_global_default(subscriber)?; - if let Err(error) = output_vt100::try_init() { - tracing::warn!( - ?error, - "Failed to init vt100 terminal colors (expected in release builds and in CI)" - ); - } - LogTracer::init()?; - tracing::debug!(?log_path, "Log path"); - Ok(Handles { logger, reloader }) -} +use std::path::PathBuf; #[tauri::command] pub(crate) async fn clear_logs(managed: tauri::State<'_, Managed>) -> Result<(), String> { - let (tx, rx) = oneshot::channel(); + 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, "Error while asking `Controller` to clear logs"); @@ -90,27 +26,9 @@ pub(crate) async fn export_logs(managed: tauri::State<'_, Managed>) -> Result<() show_export_dialog(managed.ctlr_tx.clone()).map_err(|e| e.to_string()) } -#[derive(Clone, Default, Serialize)] -pub(crate) struct FileCount { - bytes: u64, - files: u64, -} - #[tauri::command] -pub(crate) async fn count_logs() -> Result { - count_logs_inner().await.map_err(|e| e.to_string()) -} - -/// 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(crate) async fn clear_gui_logs() -> Result<()> { - firezone_headless_client::clear_logs(&known_dirs::logs().context("Can't compute GUI log dir")?) - .await +pub(crate) async fn count_logs() -> Result { + common::count_logs().await.map_err(|e| e.to_string()) } /// Pops up the "Save File" dialog @@ -137,106 +55,3 @@ fn show_export_dialog(ctlr_tx: CtlrTx) -> Result<()> { }); Ok(()) } - -/// 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(crate) async fn export_logs_to(path: PathBuf, stem: PathBuf) -> Result<()> { - tracing::info!("Exporting logs to {path:?}"); - // 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")??; - 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, - 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 -pub(crate) async fn count_logs_inner() -> Result { - // 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 { - 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> { - 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"), - }, - ]) -} diff --git a/rust/gui-client/src-tauri/src/client/settings.rs b/rust/gui-client/src-tauri/src/client/settings.rs index 328ab36e7..af2df5c4b 100644 --- a/rust/gui-client/src-tauri/src/client/settings.rs +++ b/rust/gui-client/src-tauri/src/client/settings.rs @@ -1,64 +1,14 @@ //! Everything related to the Settings window, including //! advanced settings and code for manipulating diagnostic logs. -use crate::client::gui::{self, ControllerRequest, Managed}; -use anyhow::{Context, Result}; -use atomicwrites::{AtomicFile, OverwriteBehavior}; -use connlib_shared::messages::ResourceId; -use firezone_headless_client::known_dirs; -use serde::{Deserialize, Serialize}; -use std::{collections::HashSet, io::Write, path::PathBuf, time::Duration}; +use crate::client::gui::Managed; +use anyhow::Result; +use firezone_gui_client_common::{ + controller::{ControllerRequest, CtlrTx}, + settings::{save, AdvancedSettings}, +}; +use std::time::Duration; use tokio::sync::oneshot; -use url::Url; - -#[derive(Clone, Deserialize, Serialize)] -pub(crate) struct AdvancedSettings { - pub auth_base_url: Url, - pub api_url: Url, - #[serde(default)] - pub favorite_resources: HashSet, - #[serde(default)] - pub internet_resource_enabled: Option, - pub log_filter: String, -} - -#[cfg(debug_assertions)] -impl Default for AdvancedSettings { - fn default() -> Self { - Self { - auth_base_url: Url::parse("https://app.firez.one").unwrap(), - api_url: Url::parse("wss://api.firez.one").unwrap(), - favorite_resources: Default::default(), - internet_resource_enabled: Default::default(), - log_filter: "firezone_gui_client=debug,info".to_string(), - } - } -} - -#[cfg(not(debug_assertions))] -impl Default for AdvancedSettings { - fn default() -> Self { - Self { - auth_base_url: Url::parse("https://app.firezone.dev").unwrap(), - api_url: Url::parse("wss://api.firezone.dev").unwrap(), - favorite_resources: Default::default(), - internet_resource_enabled: Default::default(), - log_filter: "info".to_string(), - } - } -} - -impl AdvancedSettings { - pub fn internet_resource_enabled(&self) -> bool { - self.internet_resource_enabled.is_some_and(|v| v) - } -} - -pub(crate) fn advanced_settings_path() -> Result { - Ok(known_dirs::settings() - .context("`known_dirs::settings` failed")? - .join("advanced_settings.json")) -} /// Saves the settings to disk and then applies them in-memory (except for logging) #[tauri::command] @@ -81,16 +31,21 @@ pub(crate) async fn reset_advanced_settings( managed: tauri::State<'_, Managed>, ) -> Result { let settings = AdvancedSettings::default(); - if managed.inner().inject_faults { - tokio::time::sleep(Duration::from_secs(2)).await; - } - apply_inner(&managed.ctlr_tx, settings.clone()) - .await - .map_err(|e| e.to_string())?; - + 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>, @@ -110,68 +65,3 @@ pub(crate) async fn get_advanced_settings( "Couldn't get settings from `Controller`, maybe the program is crashing".to_string() }) } - -/// Saves the settings to disk and then tells `Controller` to apply them in-memory -pub(crate) async fn apply_inner(ctlr_tx: &gui::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(settings)) - .await?; - Ok(()) -} - -/// Saves the settings to disk -pub(crate) 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?; - // Don't create the dir for the log filter file, that's the IPC service's job. - // If it isn't there for some reason yet, just log an error and move on. - let log_filter_path = known_dirs::ipc_log_filter().context("`ipc_log_filter` failed")?; - let f = AtomicFile::new(&log_filter_path, OverwriteBehavior::AllowOverwrite); - // Note: Blocking file write in async function - if let Err(error) = f.write(|f| f.write_all(settings.log_filter.as_bytes())) { - tracing::error!( - ?error, - ?log_filter_path, - "Couldn't write log filter file for IPC service" - ); - } - 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(crate) fn load_advanced_settings() -> Result { - 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::(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"); - } -} diff --git a/rust/gui-client/src-tauri/src/client/welcome.rs b/rust/gui-client/src-tauri/src/client/welcome.rs index 607e80130..dd04f860f 100644 --- a/rust/gui-client/src-tauri/src/client/welcome.rs +++ b/rust/gui-client/src-tauri/src/client/welcome.rs @@ -1,6 +1,7 @@ //! Everything related to the Welcome window -use crate::client::gui::{ControllerRequest, Managed}; +use crate::client::gui::Managed; +use firezone_gui_client_common::controller::ControllerRequest; // Tauri requires a `Result` here, maybe in case the managed state can't be retrieved #[tauri::command]