diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 1186cc5b5..83bc59ed8 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1892,6 +1892,7 @@ dependencies = [ "native-dialog", "nix 0.29.0", "output_vt100", + "png", "rand 0.8.5", "reqwest", "rustls", diff --git a/rust/gui-client/src-tauri/Cargo.toml b/rust/gui-client/src-tauri/Cargo.toml index 7ce2c4290..8edeff306 100644 --- a/rust/gui-client/src-tauri/Cargo.toml +++ b/rust/gui-client/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ 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 } diff --git a/rust/gui-client/src-tauri/icons/tray/Busy layer.png b/rust/gui-client/src-tauri/icons/tray/Busy layer.png new file mode 100644 index 000000000..2e2e5cf6d Binary files /dev/null and b/rust/gui-client/src-tauri/icons/tray/Busy layer.png differ diff --git a/rust/gui-client/src-tauri/icons/tray/Busy.png b/rust/gui-client/src-tauri/icons/tray/Busy.png deleted file mode 100644 index 1dc7be702..000000000 Binary files a/rust/gui-client/src-tauri/icons/tray/Busy.png and /dev/null differ diff --git a/rust/gui-client/src-tauri/icons/tray/Logo grey.png b/rust/gui-client/src-tauri/icons/tray/Logo grey.png new file mode 100644 index 000000000..1b2349de6 Binary files /dev/null and b/rust/gui-client/src-tauri/icons/tray/Logo grey.png differ diff --git a/rust/gui-client/src-tauri/icons/tray/Signed in.png b/rust/gui-client/src-tauri/icons/tray/Logo.png similarity index 100% rename from rust/gui-client/src-tauri/icons/tray/Signed in.png rename to rust/gui-client/src-tauri/icons/tray/Logo.png diff --git a/rust/gui-client/src-tauri/icons/tray/Signed out layer.png b/rust/gui-client/src-tauri/icons/tray/Signed out layer.png new file mode 100644 index 000000000..788063d80 Binary files /dev/null and b/rust/gui-client/src-tauri/icons/tray/Signed out layer.png differ diff --git a/rust/gui-client/src-tauri/icons/tray/Signed out.png b/rust/gui-client/src-tauri/icons/tray/Signed out.png deleted file mode 100644 index fe695010e..000000000 Binary files a/rust/gui-client/src-tauri/icons/tray/Signed out.png and /dev/null differ diff --git a/rust/gui-client/src-tauri/icons/tray/Update ready layer.png b/rust/gui-client/src-tauri/icons/tray/Update ready layer.png new file mode 100644 index 000000000..194681ddf Binary files /dev/null and b/rust/gui-client/src-tauri/icons/tray/Update ready layer.png differ diff --git a/rust/gui-client/src-tauri/src/client/gui.rs b/rust/gui-client/src-tauri/src/client/gui.rs index 6eea11404..e055c9834 100644 --- a/rust/gui-client/src-tauri/src/client/gui.rs +++ b/rust/gui-client/src-tauri/src/client/gui.rs @@ -315,23 +315,6 @@ async fn smoke_test(ctlr_tx: CtlrTx) -> Result<()> { .map_err(|s| anyhow!(s)) .context("`ClearLogs` failed")?; - // Tray icon stress test - let num_icon_cycles = 100; - for _ in 0..num_icon_cycles { - ctlr_tx - .send(ControllerRequest::TestTrayIcon(system_tray::Icon::Busy)) - .await?; - ctlr_tx - .send(ControllerRequest::TestTrayIcon(system_tray::Icon::SignedIn)) - .await?; - ctlr_tx - .send(ControllerRequest::TestTrayIcon( - system_tray::Icon::SignedOut, - )) - .await?; - } - tracing::debug!(?num_icon_cycles, "Completed tray icon test"); - // Give the app some time to export the zip and reach steady state tokio::time::sleep_until(quit_time).await; @@ -443,8 +426,6 @@ pub(crate) enum ControllerRequest { SchemeRequest(SecretString), SignIn, SystemTrayMenu(TrayMenuEvent), - /// Forces the tray icon to a specific icon to stress-test the tray code - TestTrayIcon(system_tray::Icon), UpdateAvailable(crate::client::updates::Release), UpdateNotificationClicked(Url), } @@ -503,6 +484,8 @@ struct Controller { log_filter_reloader: LogFilterReloader, status: Status, tray: system_tray::Tray, + /// URL of a release that's ready to download + update_url: Option, uptime: client::uptime::Tracker, } @@ -678,14 +661,14 @@ impl Controller { Req::SystemTrayMenu(TrayMenuEvent::Quit) => Err(anyhow!( "Impossible error: `Quit` should be handled before this" ))?, - Req::TestTrayIcon(icon) => self.tray.set_icon(icon)?, Req::UpdateAvailable(release) => { 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)?; + os::show_update_notification(self.ctlr_tx.clone(), &title, release.download_url.clone())?; + self.update_url = Some(release.download_url); } Req::UpdateNotificationClicked(download_url) => { tracing::info!("UpdateNotificationClicked in run_controller!"); @@ -743,7 +726,7 @@ impl Controller { } IpcServerMsg::TerminatingGracefully => { tracing::info!("Caught TerminatingGracefully"); - self.tray.set_icon(system_tray::Icon::SignedOut).ok(); + self.tray.set_icon(system_tray::Icon::terminating()).ok(); Err(Error::IpcServiceTerminating) } IpcServerMsg::TunnelReady => { @@ -851,31 +834,34 @@ impl Controller { 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 menu = if let Some(auth_session) = self.auth.session() { + 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::AppState::SignedOut + system_tray::ConnlibState::SignedOut } - Status::RetryingConnection { .. } => system_tray::AppState::RetryingConnection, + Status::RetryingConnection { .. } => system_tray::ConnlibState::RetryingConnection, Status::TunnelReady { resources } => { - system_tray::AppState::SignedIn(system_tray::SignedIn { + system_tray::ConnlibState::SignedIn(system_tray::SignedIn { actor_name: &auth_session.actor_name, favorite_resources: &self.advanced_settings.favorite_resources, disabled_resources: &self.advanced_settings.disabled_resources, resources, }) } - Status::WaitingForPortal { .. } => system_tray::AppState::WaitingForPortal, - Status::WaitingForTunnel { .. } => system_tray::AppState::WaitingForTunnel, + 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::AppState::WaitingForBrowser + system_tray::ConnlibState::WaitingForBrowser } else { - system_tray::AppState::SignedOut + system_tray::ConnlibState::SignedOut }; - self.tray.update(menu)?; + self.tray.update(system_tray::AppState { + connlib, + update_url: self.update_url.clone(), + })?; Ok(()) } @@ -947,6 +933,7 @@ async fn run_controller( log_filter_reloader, status: Default::default(), tray, + update_url: None, uptime: Default::default(), }; diff --git a/rust/gui-client/src-tauri/src/client/gui/system_tray.rs b/rust/gui-client/src-tauri/src/client/gui/system_tray.rs index 8d2a9b29f..326f3551d 100644 --- a/rust/gui-client/src-tauri/src/client/gui/system_tray.rs +++ b/rust/gui-client/src-tauri/src/client/gui/system_tray.rs @@ -12,16 +12,21 @@ use connlib_shared::{ }; 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 icons -// -const BUSY_ICON: &[u8] = include_bytes!("../../../icons/tray/Busy.png"); -const SIGNED_IN_ICON: &[u8] = include_bytes!("../../../icons/tray/Signed in.png"); -const SIGNED_OUT_ICON: &[u8] = include_bytes!("../../../icons/tray/Signed out.png"); +// Figma is the source of truth for the tray icon layers +// +const LOGO_BASE: &[u8] = include_bytes!("../../../icons/tray/Logo.png"); +const LOGO_GREY_BASE: &[u8] = include_bytes!("../../../icons/tray/Logo grey.png"); +const BUSY_LAYER: &[u8] = include_bytes!("../../../icons/tray/Busy layer.png"); +const SIGNED_OUT_LAYER: &[u8] = include_bytes!("../../../icons/tray/Signed out layer.png"); +const UPDATE_READY_LAYER: &[u8] = include_bytes!("../../../icons/tray/Update ready layer.png"); + const TOOLTIP: &str = "Firezone"; const QUIT_TEXT_SIGNED_OUT: &str = "Quit Firezone"; @@ -42,9 +47,13 @@ const ENABLE: &str = "Enable this resource"; pub(crate) const INTERNET_RESOURCE_DESCRIPTION: &str = "All network traffic"; pub(crate) fn loading() -> SystemTray { + let state = AppState { + connlib: ConnlibState::Loading, + update_url: None, + }; SystemTray::new() - .with_icon(tauri::Icon::Raw(BUSY_ICON.into())) - .with_menu(AppState::Loading.build()) + .with_icon(Icon::default().tauri_icon()) + .with_menu(state.build()) .with_tooltip(TOOLTIP) } @@ -53,7 +62,12 @@ pub(crate) struct Tray { last_icon_set: Icon, } -pub(crate) enum AppState<'a> { +pub(crate) struct AppState<'a> { + pub(crate) connlib: ConnlibState<'a>, + pub(crate) update_url: Option, +} + +pub(crate) enum ConnlibState<'a> { Loading, RetryingConnection, SignedIn(SignedIn<'a>), @@ -125,7 +139,13 @@ impl<'a> SignedIn<'a> { } #[derive(PartialEq)] -pub(crate) enum Icon { +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, @@ -134,7 +154,34 @@ pub(crate) enum Icon { impl Default for Icon { fn default() -> Self { - Self::Busy + 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, + } } } @@ -147,13 +194,18 @@ impl Tray { } pub(crate) fn update(&mut self, state: AppState) -> Result<()> { - let new_icon = match &state { - AppState::Loading - | AppState::RetryingConnection - | AppState::SignedOut - | AppState::WaitingForBrowser => Icon::SignedOut, - AppState::SignedIn { .. } => Icon::SignedIn, - AppState::WaitingForPortal | AppState::WaitingForTunnel => Icon::Busy, + let base = match &state.connlib { + ConnlibState::Loading + | ConnlibState::RetryingConnection + | ConnlibState::WaitingForBrowser + | ConnlibState::WaitingForPortal + | ConnlibState::WaitingForTunnel => IconBase::Busy, + ConnlibState::SignedOut => IconBase::SignedOut, + ConnlibState::SignedIn { .. } => IconBase::SignedIn, + }; + let new_icon = Icon { + base, + update_ready: state.update_url.is_some(), }; self.handle.set_tooltip(TOOLTIP)?; @@ -163,7 +215,8 @@ impl Tray { Ok(()) } - // Normally only needed for the stress test + // Only needed for the stress test + // Otherwise it would be inlined pub(crate) fn set_icon(&mut self, icon: Icon) -> Result<()> { if icon != self.last_icon_set { // Don't call `set_icon` too often. On Linux it writes a PNG to `/run/user/$UID/tao/tray-icon-*.png` every single time. @@ -177,34 +230,31 @@ impl Tray { } } -impl Icon { - fn tauri_icon(&self) -> tauri::Icon { - let bytes = match self { - Self::Busy => BUSY_ICON, - Self::SignedIn => SIGNED_IN_ICON, - Self::SignedOut => SIGNED_OUT_ICON, - }; - tauri::Icon::Raw(bytes.into()) - } -} - impl<'a> AppState<'a> { fn build(self) -> tauri::SystemTrayMenu { self.into_menu().build() } fn into_menu(self) -> Menu { - match self { - Self::Loading => Menu::default().disabled("Loading..."), - Self::RetryingConnection => retrying_sign_in("Waiting for Internet access..."), - Self::SignedIn(x) => signed_in(&x), - Self::SignedOut => Menu::default() - .item(Event::SignIn, "Sign In") - .add_bottom_section(QUIT_TEXT_SIGNED_OUT), - Self::WaitingForBrowser => signing_in("Waiting for browser..."), - Self::WaitingForPortal => signing_in("Connecting to Firezone Portal..."), - Self::WaitingForTunnel => signing_in("Raising tunnel..."), - } + 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.update_url, quit_text) } } @@ -261,7 +311,7 @@ fn signed_in(signed_in: &SignedIn) -> Menu { menu = menu.separator().add_submenu(OTHER_RESOURCES, submenu); } - menu.add_bottom_section(DISCONNECT_AND_QUIT) + menu } fn retrying_sign_in(waiting_message: &str) -> Menu { @@ -269,14 +319,12 @@ fn retrying_sign_in(waiting_message: &str) -> Menu { .disabled(waiting_message) .item(Event::RetryPortalConnection, "Retry sign-in") .item(Event::CancelSignIn, "Cancel sign-in") - .add_bottom_section(QUIT_TEXT_SIGNED_OUT) } fn signing_in(waiting_message: &str) -> Menu { Menu::default() .disabled(waiting_message) .item(Event::CancelSignIn, "Cancel sign-in") - .add_bottom_section(QUIT_TEXT_SIGNED_OUT) } #[cfg(test)] @@ -301,12 +349,15 @@ mod tests { favorite_resources: &'a HashSet, disabled_resources: &'a HashSet, ) -> AppState<'a> { - AppState::SignedIn(SignedIn { - actor_name: "Jane Doe", - favorite_resources, - resources, - disabled_resources, - }) + AppState { + connlib: ConnlibState::SignedIn(SignedIn { + actor_name: "Jane Doe", + favorite_resources, + resources, + disabled_resources, + }), + update_url: None, + } } fn resources() -> Vec { @@ -357,7 +408,7 @@ mod tests { .item(Event::SignOut, SIGN_OUT) .separator() .disabled(RESOURCES) - .add_bottom_section(DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple + .add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple assert_eq!( actual, @@ -379,7 +430,7 @@ mod tests { .item(Event::SignOut, SIGN_OUT) .separator() .disabled(RESOURCES) - .add_bottom_section(DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple + .add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple assert_eq!( actual, @@ -451,7 +502,7 @@ mod tests { .copyable("test") .copyable(ALL_GATEWAYS_OFFLINE), ) - .add_bottom_section(DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple + .add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple assert_eq!( actual, expected, @@ -528,7 +579,7 @@ mod tests { .copyable(NO_ACTIVITY), ), ) - .add_bottom_section(DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple + .add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple assert_eq!( actual, @@ -604,7 +655,7 @@ mod tests { .copyable("test") .copyable(ALL_GATEWAYS_OFFLINE), ) - .add_bottom_section(DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple + .add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple assert_eq!( actual, diff --git a/rust/gui-client/src-tauri/src/client/gui/system_tray/builder.rs b/rust/gui-client/src-tauri/src/client/gui/system_tray/builder.rs index 73d25f7a6..600fc0db5 100644 --- a/rust/gui-client/src-tauri/src/client/gui/system_tray/builder.rs +++ b/rust/gui-client/src-tauri/src/client/gui/system_tray/builder.rs @@ -97,9 +97,13 @@ fn resource_header(res: &ResourceDescription) -> Item { impl Menu { /// Appends things that always show, like About, Settings, Help, Quit, etc. - pub(crate) fn add_bottom_section(self, quit_text: &str) -> Self { - self.separator() - .item(Event::ShowWindow(Window::About), "About Firezone") + pub(crate) fn add_bottom_section(mut self, update_url: Option, quit_text: &str) -> Self { + self = self.separator(); + if let Some(url) = update_url { + self = self.item(Event::Url(url), "Download update...") + } + + self.item(Event::ShowWindow(Window::About), "About Firezone") .item(Event::AdminPortal, "Admin Portal...") .add_submenu( "Help", diff --git a/rust/gui-client/src-tauri/src/client/gui/system_tray/compositor.rs b/rust/gui-client/src-tauri/src/client/gui/system_tray/compositor.rs new file mode 100644 index 000000000..1722aaff8 --- /dev/null +++ b/rust/gui-client/src-tauri/src/client/gui/system_tray/compositor.rs @@ -0,0 +1,97 @@ +//! A minimal graphics compositor for the little 32x32 tray icons +//! +//! It's common for web apps like Discord, Element, and Slack to composite +//! their favicons in an offscreen HTML5 canvas, so that they can layer +//! notification dots and other stuff over the logo, without maintaining +//! multiple nearly-identical graphics assets. +//! +//! Using the HTML5 canvas in Tauri would make us depend on it too much, +//! and the math for compositing RGBA images in a mostly-gamma-correct way +//! is simple enough to just replicate it here. + +use anyhow::{ensure, Context as _, Result}; + +pub(crate) struct Image { + width: u32, + height: u32, + rgba: Vec, +} + +impl From for tauri::Icon { + fn from(val: Image) -> Self { + Self::Rgba { + rgba: val.rgba, + width: val.width, + height: val.height, + } + } +} + +/// Builds up an image via painter's algorithm +/// +/// +/// +/// # Args +/// +/// - `layers` - An iterator of PNG-compressed layers. We assume alpha is NOT pre-multiplied because Figma doesn't seem to export pre-multiplied images. +/// +/// # Returns +/// +/// An `Image` with the same dimensions as the first layer. + +pub(crate) fn compose<'a, I: IntoIterator>(layers: I) -> Result { + let mut dst = None; + + for layer in layers { + // Decode the metadata for this PNG layer + let decoder = png::Decoder::new(layer); + let mut reader = decoder.read_info()?; + let info = reader.info(); + + // Create the output buffer if needed + let dst = dst.get_or_insert_with(|| Image { + width: info.width, + height: info.height, + rgba: vec![0; reader.output_buffer_size()], + }); + + ensure!(info.width == dst.width); + ensure!(info.height == dst.height); + + // Decompress the PNG layer + let mut rgba = vec![0; dst.rgba.len()]; + let info = reader.next_frame(&mut rgba)?; + ensure!(info.buffer_size() == rgba.len()); + + // Do the actual composite + // Do all the math with floats so it's easier to write and read the code + let gamma = 2.2; + let inv_gamma = 1.0 / gamma; + + for (src, dst) in rgba.chunks_exact(4).zip(dst.rgba.chunks_exact_mut(4)) { + let src_a = src[3] as f32 / 255.0; + + for (src_int, dst_int) in (src[0..3]).iter().zip(&mut dst[0..3]) { + let src_c = *src_int as f32 / 255.0; + let dst_c = *dst_int as f32 / 255.0; + + // Convert from gamma to linear space + let src_c = src_c.powf(gamma); + let dst_c = dst_c.powf(gamma); + + // Linear interp between the src and dst colors depending on the src alpha + let dst_c = src_c * src_a + dst_c * (1.0 - src_a); + + // Convert back to gamma space and clamp / saturate + let dst_c = dst_c.powf(inv_gamma).clamp(0.0, 1.0); + + *dst_int = (dst_c * 255.0) as u8; + } + + // Add the source alpha into the dest alpha + dst[3] = dst[3].saturating_add(src[3]); + } + } + + dst.context("No layers") +} diff --git a/rust/gui-client/src-tauri/tauri.conf.json b/rust/gui-client/src-tauri/tauri.conf.json index 3c689b38f..0f4f2518a 100644 --- a/rust/gui-client/src-tauri/tauri.conf.json +++ b/rust/gui-client/src-tauri/tauri.conf.json @@ -50,7 +50,7 @@ "csp": null }, "systemTray": { - "iconPath": "icons/tray/Busy.png", + "iconPath": "icons/tray/Busy layer.png", "iconAsTemplate": true }, "windows": [ diff --git a/website/src/components/Changelog/GUI.tsx b/website/src/components/Changelog/GUI.tsx index 6b876dcd7..9aee1debc 100644 --- a/website/src/components/Changelog/GUI.tsx +++ b/website/src/components/Changelog/GUI.tsx @@ -16,6 +16,9 @@ export default function GUI({ title }: { title: string }) { {/*
    + + Shows an orange dot on the tray icon when an update is ready to download. +
*/}