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 <ReactorScram@users.noreply.github.com>
Co-authored-by: Thomas Eizinger <thomas@eizinger.io>
This commit is contained in:
Reactor Scram
2024-09-05 12:42:45 -05:00
committed by GitHub
parent c540e163ea
commit 5eab912f60
31 changed files with 1987 additions and 1835 deletions

View File

@@ -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"

49
rust/Cargo.lock generated
View File

@@ -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",
]

View File

@@ -8,6 +8,7 @@ members = [
"connlib/snownet",
"connlib/tunnel",
"gateway",
"gui-client/src-common",
"gui-client/src-tauri",
"headless-client",
"ip-packet",

View File

@@ -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 <https://github.com/hwchen/keyring-rs/issues/132>
"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

View File

@@ -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,
}

View File

@@ -11,20 +11,10 @@
use anyhow::{ensure, Context as _, Result};
pub(crate) struct Image {
width: u32,
height: u32,
rgba: Vec<u8>,
}
impl From<Image> 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<u8>,
}
/// Builds up an image via painter's algorithm
@@ -39,7 +29,7 @@ impl From<Image> for tauri::Icon {
///
/// An `Image` with the same dimensions as the first layer.
pub(crate) fn compose<'a, I: IntoIterator<Item = &'a [u8]>>(layers: I) -> Result<Image> {
pub fn compose<'a, I: IntoIterator<Item = &'a [u8]>>(layers: I) -> Result<Image> {
let mut dst = None;
for layer in layers {

View File

@@ -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<ControllerRequest>;
pub struct Controller<I: GuiIntegration> {
/// 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<oneshot::Sender<Result<(), String>>>,
pub ctlr_tx: CtlrTx,
pub ipc_client: ipc::Client,
pub ipc_rx: mpsc::Receiver<ipc::Event>,
pub integration: I,
pub log_filter_reloader: LogFilterReloader,
/// A release that's ready to download
pub release: Option<updates::Release>,
pub rx: mpsc::Receiver<ControllerRequest>,
pub status: Status,
pub updates_rx: mpsc::Receiver<Option<updates::Notification>>,
pub uptime: crate::uptime::Tracker,
}
pub trait GuiIntegration {
fn set_welcome_window_visible(&self, visible: bool) -> Result<()>;
/// Also opens non-URLs
fn open_url<P: AsRef<str>>(&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<AdvancedSettings>),
/// Clear the GUI's logs and await the IPC service to clear its logs
ClearLogs(oneshot::Sender<Result<(), String>>),
/// The same as the arguments to `client::logging::export_logs_to`
ExportLogs {
path: PathBuf,
stem: PathBuf,
},
Fail(Failure),
GetAdvancedSettings(oneshot::Sender<AdvancedSettings>),
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<Enum>` 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<ResourceDescription> },
/// 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<ResourceDescription> {
#[allow(clippy::wildcard_enum_match_arm)]
match self {
Status::TunnelReady { resources } => {
resources.iter().find(|r| r.is_internet_resource()).cloned()
}
_ => None,
}
}
}
impl<I: GuiIntegration> Controller<I> {
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 <https://github.com/firezone/firezone/issues/6547> 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<updates::Notification>,
) -> 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(())
}
}

View File

@@ -27,7 +27,7 @@ use time::OffsetDateTime;
/// <https://github.com/EmbarkStudios/crash-handling/blob/main/minidumper/examples/diskwrite.rs>
/// 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<CrashHandler> {
pub fn attach_handler() -> Result<CrashHandler> {
// Attempt to connect to the server
let (client, _server) = start_server_and_connect()?;
@@ -55,7 +55,7 @@ pub(crate) fn attach_handler() -> Result<CrashHandler> {
///
/// <https://jake-shadle.github.io/crash-reporting/#implementation>
/// <https://chromium.googlesource.com/breakpad/breakpad/+/master/docs/getting_started_with_breakpad.md#terminology>
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)?;

View File

@@ -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<AuthResponse> {
pub fn parse_auth_callback(url_secret: &SecretString) -> Result<auth::Response> {
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<AuthRespo
}
}
Ok(AuthResponse {
Ok(auth::Response {
actor_name: actor_name.context("URL should have `actor_name`")?,
fragment: fragment.context("URL should have `fragment`")?,
state: state.context("URL should have `state`")?,
@@ -85,6 +85,7 @@ pub(crate) fn parse_auth_callback(url_secret: &SecretString) -> Result<AuthRespo
#[cfg(test)]
mod tests {
use super::*;
use anyhow::{Context, Result};
use secrecy::{ExposeSecret, SecretString};
@@ -133,7 +134,7 @@ mod tests {
Ok(())
}
fn parse_callback_wrapper(s: &str) -> Result<super::AuthResponse> {
fn parse_callback_wrapper(s: &str) -> Result<auth::Response> {
super::parse_auth_callback(&SecretString::new(s.to_owned()))
}

View File

@@ -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<Self, super::Error> {
pub async fn new() -> Result<Self, super::Error> {
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<Secret<Vec<u8>>> {
pub async fn accept(self) -> Result<Secret<Vec<u8>>> {
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 <https://wiki.archlinux.org/title/Desktop_entries>, 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

View File

@@ -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<Self, super::Error> {
pub async fn new() -> Result<Self, super::Error> {
// 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<Secret<Vec<u8>>> {
pub async fn accept(mut self) -> Result<Secret<Vec<u8>>> {
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")?;

View File

@@ -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 <https://github.com/firezone/firezone/pull/3546#discussion_r1477114789>
#[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: <https://github.com/firezone/firezone/pull/3464#discussion_r1473608415>
// 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`.

View File

@@ -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<Result<()>>,
// 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<Self> {
pub async fn new(ctlr_tx: tokio::sync::mpsc::Sender<Event>) -> Result<Self> {
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<IpAddr>) -> Result<()> {
pub async fn set_dns(&mut self, dns: Vec<IpAddr>) -> Result<()> {
self.send_msg(&IpcClientMsg::SetDns(dns))
.await
.context("Couldn't send SetDns")?;

View File

@@ -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;

View File

@@ -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
/// <https://github.com/tokio-rs/tracing/issues/2360>
pub fn setup(directives: &str) -> Result<Handles> {
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<std::fs::File>,
src_dir: &Path,
dst_stem: &Path,
) -> Result<()> {
let options = zip::write::SimpleFileOptions::default();
let dir = match fs::read_dir(src_dir) {
Ok(x) => x,
Err(error) => {
if matches!(error.kind(), NotFound) {
// In smoke tests, the IPC service runs in debug mode, so it won't write any logs to disk. If the IPC service's log dir doesn't exist, we shouldn't crash, it's correct to simply not add any files to the zip
return Ok(());
}
// But any other error like permissions errors, should bubble.
return Err(error.into());
}
};
for entry in dir {
let entry = entry.context("Got bad entry from `read_dir`")?;
let Some(path) = dst_stem
.join(entry.file_name())
.to_str()
.map(|x| x.to_owned())
else {
bail!("log filename isn't valid Unicode")
};
zip.start_file(path, options)
.context("`ZipWriter::start_file` failed")?;
let mut f = fs::File::open(entry.path()).context("Failed to open log file")?;
io::copy(&mut f, zip).context("Failed to copy log file into zip")?;
}
Ok(())
}
/// Count log files and their sizes
pub async fn count_logs() -> Result<FileCount> {
// I spent about 5 minutes on this and couldn't get it to work with `Stream`
let mut total_count = FileCount::default();
for log_path in log_paths()? {
let count = count_one_dir(&log_path.src).await?;
total_count.files += count.files;
total_count.bytes += count.bytes;
}
Ok(total_count)
}
async fn count_one_dir(path: &Path) -> Result<FileCount> {
let mut dir = tokio::fs::read_dir(path).await?;
let mut file_count = FileCount::default();
while let Some(entry) = dir.next_entry().await? {
let md = entry.metadata().await?;
file_count.files += 1;
file_count.bytes += md.len();
}
Ok(file_count)
}
fn log_paths() -> Result<Vec<LogPath>> {
Ok(vec![
LogPath {
src: known_dirs::ipc_service_logs().context("Can't compute IPC service logs dir")?,
dst: PathBuf::from("connlib"),
},
LogPath {
src: known_dirs::logs().context("Can't compute GUI log dir")?,
dst: PathBuf::from("app"),
},
])
}

View File

@@ -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<ResourceId>,
#[serde(default)]
pub internet_resource_enabled: Option<bool>,
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<PathBuf> {
Ok(known_dirs::settings()
.context("`known_dirs::settings` failed")?
.join("advanced_settings.json"))
}
/// Saves the settings to disk
pub async fn save(settings: &AdvancedSettings) -> Result<()> {
let path = advanced_settings_path()?;
let dir = path
.parent()
.context("settings path should have a parent")?;
tokio::fs::create_dir_all(dir).await?;
tokio::fs::write(&path, serde_json::to_string(settings)?).await?;
// 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<AdvancedSettings> {
let path = advanced_settings_path()?;
let text = std::fs::read_to_string(path)?;
let settings = serde_json::from_str(&text)?;
Ok(settings)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_old_formats() {
let s = r#"{
"auth_base_url": "https://example.com/",
"api_url": "wss://example.com/",
"log_filter": "info"
}"#;
let actual = serde_json::from_str::<AdvancedSettings>(s).unwrap();
// Apparently the trailing slash here matters
assert_eq!(actual.auth_base_url.to_string(), "https://example.com/");
assert_eq!(actual.api_url.to_string(), "wss://example.com/");
assert_eq!(actual.log_filter, "info");
}
}

View File

@@ -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<Release>,
}
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<ResourceId>,
pub resources: &'a [ResourceDescription],
pub internet_resource_enabled: &'a Option<bool>,
}
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<Release>, quit_text: &str) -> Self {
self = self.separator();
if let Some(release) = release {
self = self.item(
Event::Url(release.download_url),
format!("Download Firezone {}...", release.version),
)
}
self.item(Event::ShowWindow(Window::About), "About Firezone")
.item(Event::AdminPortal, "Admin Portal...")
.add_submenu(
"Help",
Menu::default()
.item(
Event::Url(utm_url("https://www.firezone.dev/kb")),
"Documentation...",
)
.item(
Event::Url(utm_url("https://www.firezone.dev/support")),
"Support...",
),
)
.item(Event::ShowWindow(Window::Settings), "Settings")
.separator()
.item(Event::Quit, quit_text)
}
}
pub(crate) fn utm_url(base_url: &str) -> Url {
Url::parse(&format!(
"{base_url}?utm_source={}-client",
std::env::consts::OS
))
.expect("Hard-coded URL should always be parsable")
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use std::str::FromStr as _;
use builder::INTERNET_RESOURCE_DESCRIPTION;
impl Menu {
fn selected_item<E: Into<Option<Event>>, S: Into<String>>(
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<ResourceId>,
internet_resource_enabled: &'a Option<bool>,
) -> AppState<'a> {
AppState {
connlib: ConnlibState::SignedIn(SignedIn {
actor_name: "Jane Doe",
favorite_resources,
resources,
internet_resource_enabled,
}),
release: None,
}
}
fn resources() -> Vec<ResourceDescription> {
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()),
"<https://gitlab.mycorp.com>",
)
.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()),
"<https://gitlab.mycorp.com>",
)
.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()),
"<https://gitlab.mycorp.com>",
)
.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(())
}
}

View File

@@ -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<Entry>,
pub struct Menu {
pub entries: Vec<Entry>,
}
/// 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<Event>,
pub event: Option<Event>,
/// 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

View File

@@ -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<Option<Notification>>,
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<Version> {
}
pub(crate) fn current_version() -> Result<Version> {
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)]

View File

@@ -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 <https://github.com/hwchen/keyring-rs/issues/132>
"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"

View File

@@ -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<logging::Handles> {
let logging_handles = logging::setup(directives)?;
fn start_logging(directives: &str) -> Result<common::logging::Handles> {
let logging_handles = common::logging::setup(directives)?;
tracing::info!(
arch = std::env::consts::ARCH,
os = std::env::consts::OS,
@@ -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<Enum>` 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 {

View File

@@ -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(())
}
}
}

View File

@@ -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
///

View File

@@ -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<ControllerRequest>;
/// 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<P: AsRef<str>>(&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<Result<(), String>>),
/// The same as the arguments to `client::logging::export_logs_to`
ExportLogs {
path: PathBuf,
stem: PathBuf,
},
Fail(Failure),
GetAdvancedSettings(oneshot::Sender<AdvancedSettings>),
Ipc(IpcServerMsg),
IpcClosed,
IpcReadFailed(anyhow::Error),
SchemeRequest(SecretString),
SignIn,
SystemTrayMenu(TrayMenuEvent),
/// Set (or clear) update notification
SetUpdateNotification(Option<crate::client::updates::Notification>),
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<ResourceDescription> },
/// 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<ResourceDescription> {
#[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<oneshot::Sender<Result<(), String>>>,
ctlr_tx: CtlrTx,
ipc_client: ipc::Client,
log_filter_reloader: LogFilterReloader,
/// A release that's ready to download
release: Option<Release>,
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 <https://github.com/firezone/firezone/issues/6547> 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<ControllerRequest>,
rx: mpsc::Receiver<ControllerRequest>,
advanced_settings: AdvancedSettings,
log_filter_reloader: LogFilterReloader,
updates_rx: mpsc::Receiver<Option<updates::Notification>>,
) -> 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.

View File

@@ -5,20 +5,12 @@
//! "Notification Area" is Microsoft's official name instead of "System tray":
//! <https://learn.microsoft.com/en-us/windows/win32/shell/notification-area?redirectedfrom=MSDN#notifications-and-the-notification-area>
use crate::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
// <https://www.figma.com/design/THvQQ1QxKlsk47H9DZ2bhN/Core-Library?node-id=1250-772&t=nHBOzOnSY5Ol4asV-0>
@@ -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<Release>,
}
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<ResourceId>,
pub(crate) resources: &'a [ResourceDescription],
pub(crate) internet_resource_enabled: &'a Option<bool>,
}
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 {
// <https://github.com/tauri-apps/tao/blob/tao-v0.16.7/src/platform_impl/linux/system_tray.rs#L119>
// Yes, even if you use `Icon::File` and tell Tauri that the icon is already
// on disk.
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<Release>, quit_text: &str) -> Self {
self = self.separator();
if let Some(release) = release {
self = self.item(
Event::Url(release.download_url),
format!("Download Firezone {}...", release.version),
)
}
self.item(Event::ShowWindow(Window::About), "About Firezone")
.item(Event::AdminPortal, "Admin Portal...")
.add_submenu(
"Help",
Menu::default()
.item(
Event::Url(utm_url("https://www.firezone.dev/kb")),
"Documentation...",
)
.item(
Event::Url(utm_url("https://www.firezone.dev/support")),
"Support...",
),
)
.item(Event::ShowWindow(Window::Settings), "Settings")
.separator()
.item(Event::Quit, quit_text)
}
}
pub(crate) fn utm_url(base_url: &str) -> Url {
Url::parse(&format!(
"{base_url}?utm_source={}-client",
std::env::consts::OS
))
.expect("Hard-coded URL should always be parsable")
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use std::str::FromStr as _;
impl Menu {
fn selected_item<E: Into<Option<Event>>, S: Into<String>>(
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<ResourceId>,
internet_resource_enabled: &'a Option<bool>,
) -> AppState<'a> {
AppState {
connlib: ConnlibState::SignedIn(SignedIn {
actor_name: "Jane Doe",
favorite_resources,
resources,
internet_resource_enabled,
}),
release: None,
}
}
fn resources() -> Vec<ResourceDescription> {
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()),
"<https://gitlab.mycorp.com>",
)
.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()),
"<https://gitlab.mycorp.com>",
)
.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()),
"<https://gitlab.mycorp.com>",
)
.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
}

View File

@@ -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
/// <https://github.com/tokio-rs/tracing/issues/2360>
pub(crate) fn setup(directives: &str) -> Result<Handles> {
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<FileCount, String> {
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::FileCount, String> {
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<std::fs::File>,
src_dir: &Path,
dst_stem: &Path,
) -> Result<()> {
let options = zip::write::SimpleFileOptions::default();
let dir = match fs::read_dir(src_dir) {
Ok(x) => x,
Err(error) => {
if matches!(error.kind(), NotFound) {
// In smoke tests, the IPC service runs in debug mode, so it won't write any logs to disk. If the IPC service's log dir doesn't exist, we shouldn't crash, it's correct to simply not add any files to the zip
return Ok(());
}
// But any other error like permissions errors, should bubble.
return Err(error.into());
}
};
for entry in dir {
let entry = entry.context("Got bad entry from `read_dir`")?;
let Some(path) = dst_stem
.join(entry.file_name())
.to_str()
.map(|x| x.to_owned())
else {
bail!("log filename isn't valid Unicode")
};
zip.start_file(path, options)
.context("`ZipWriter::start_file` failed")?;
let mut f = fs::File::open(entry.path()).context("Failed to open log file")?;
io::copy(&mut f, zip).context("Failed to copy log file into zip")?;
}
Ok(())
}
/// Count log files and their sizes
pub(crate) async fn count_logs_inner() -> Result<FileCount> {
// I spent about 5 minutes on this and couldn't get it to work with `Stream`
let mut total_count = FileCount::default();
for log_path in log_paths()? {
let count = count_one_dir(&log_path.src).await?;
total_count.files += count.files;
total_count.bytes += count.bytes;
}
Ok(total_count)
}
async fn count_one_dir(path: &Path) -> Result<FileCount> {
let mut dir = tokio::fs::read_dir(path).await?;
let mut file_count = FileCount::default();
while let Some(entry) = dir.next_entry().await? {
let md = entry.metadata().await?;
file_count.files += 1;
file_count.bytes += md.len();
}
Ok(file_count)
}
fn log_paths() -> Result<Vec<LogPath>> {
Ok(vec![
LogPath {
src: known_dirs::ipc_service_logs().context("Can't compute IPC service logs dir")?,
dst: PathBuf::from("connlib"),
},
LogPath {
src: known_dirs::logs().context("Can't compute GUI log dir")?,
dst: PathBuf::from("app"),
},
])
}

View File

@@ -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<ResourceId>,
#[serde(default)]
pub internet_resource_enabled: Option<bool>,
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<PathBuf> {
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<AdvancedSettings, String> {
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<AdvancedSettings> {
let path = advanced_settings_path()?;
let text = std::fs::read_to_string(path)?;
let settings = serde_json::from_str(&text)?;
Ok(settings)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_old_formats() {
let s = r#"{
"auth_base_url": "https://example.com/",
"api_url": "wss://example.com/",
"log_filter": "info"
}"#;
let actual = serde_json::from_str::<AdvancedSettings>(s).unwrap();
// Apparently the trailing slash here matters
assert_eq!(actual.auth_base_url.to_string(), "https://example.com/");
assert_eq!(actual.api_url.to_string(), "wss://example.com/");
assert_eq!(actual.log_filter, "info");
}
}

View File

@@ -1,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]