feat(rust/gui-client): add "update ready" notification dot to the tray icon using a runtime compositor (#6432)

Also adds a "Download update" button in the bottom section of the tray
menu when an update is ready.

<img width="258" alt="image"
src="https://github.com/user-attachments/assets/73d31ad2-5eb8-4cfd-9164-39fcad2ba031">

---------

Signed-off-by: Reactor Scram <ReactorScram@users.noreply.github.com>
This commit is contained in:
Reactor Scram
2024-08-28 09:59:52 -05:00
committed by GitHub
parent fe952e634a
commit 176ef052a5
15 changed files with 233 additions and 89 deletions

1
rust/Cargo.lock generated
View File

@@ -1892,6 +1892,7 @@ dependencies = [
"native-dialog",
"nix 0.29.0",
"output_vt100",
"png",
"rand 0.8.5",
"reqwest",
"rustls",

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 B

View File

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

View File

@@ -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
// <https://www.figma.com/design/THvQQ1QxKlsk47H9DZ2bhN/Core-Library?node-id=1250-772&t=OGFabKWPx7PRUZmq-0>
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
// <https://www.figma.com/design/THvQQ1QxKlsk47H9DZ2bhN/Core-Library?node-id=1250-772&t=nHBOzOnSY5Ol4asV-0>
const LOGO_BASE: &[u8] = include_bytes!("../../../icons/tray/Logo.png");
const LOGO_GREY_BASE: &[u8] = include_bytes!("../../../icons/tray/Logo grey.png");
const BUSY_LAYER: &[u8] = include_bytes!("../../../icons/tray/Busy layer.png");
const SIGNED_OUT_LAYER: &[u8] = include_bytes!("../../../icons/tray/Signed out layer.png");
const UPDATE_READY_LAYER: &[u8] = include_bytes!("../../../icons/tray/Update ready layer.png");
const 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<Url>,
}
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<ResourceId>,
disabled_resources: &'a HashSet<ResourceId>,
) -> 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<ResourceDescription> {
@@ -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,

View File

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

View File

@@ -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<u8>,
}
impl From<Image> 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
///
/// <https://en.wikipedia.org/wiki/Painter%27s_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<Item = &'a [u8]>>(layers: I) -> Result<Image> {
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")
}

View File

@@ -50,7 +50,7 @@
"csp": null
},
"systemTray": {
"iconPath": "icons/tray/Busy.png",
"iconPath": "icons/tray/Busy layer.png",
"iconAsTemplate": true
},
"windows": [

View File

@@ -16,6 +16,9 @@ export default function GUI({ title }: { title: string }) {
{/*
<Entry version="1.2.2" date={new Date(todo)}>
<ul className="list-disc space-y-2 pl-4 mb-4">
<ChangeItem pull="6432">
Shows an orange dot on the tray icon when an update is ready to download.
</ChangeItem>
</ul>
</Entry>
*/}