From 9210ed2a975df323f0649544d52194472f3d322f Mon Sep 17 00:00:00 2001 From: Thomas Eizinger Date: Mon, 9 Jun 2025 11:41:18 +0200 Subject: [PATCH] fix(gui-client): don't say "signed in" without a connlib session (#9477) With the introduction of the "connect on start" configuration option, we introduced a bug where the GUI client said "Signed in as ..." even though we did not have a `connlib` session. The tray-menu handles this state correctly and clicking sign out and sign in restores Firezone to a functional state. This disparity happened because we assumed that having a token means we must have a session. To fix this, we introduce a new `SessionViewModel` that combines the state of the auth session and the `connlib` state. Only if we have both do we infer that we are "signed in". This also requires us to introduce an intermediary state where we are "loading". This is represented as a spinner in the UI. Last but not least, this also removes the automated hiding of the client window. In a prior design, the only job of this window was to show the "Sign in" button so it wasn't useful beyond clicking that. Now that we show more things in this window, automatically hiding it might confuse the user. Here is what this new design looks like: [Login flow](https://github.com/user-attachments/assets/276e390b-4837-48e2-aaf1-eea007472816) As a result of other improvements around "zero-click sign-in", the user often doesn't even have to switch to the browser window because sign-in happens in the background. Unfortunately, the tab still remains open but that is outside of our control (at least on Linux). --- .../src-frontend/components/App.tsx | 15 +- .../src-frontend/components/OverviewPage.tsx | 154 ++++++++++++------ .../src-frontend/generated/Session.ts | 4 - .../generated/SessionViewModel.ts | 9 + rust/gui-client/src-frontend/main.tsx | 5 + rust/gui-client/src-tauri/Cargo.toml | 3 + rust/gui-client/src-tauri/src/auth.rs | 1 - rust/gui-client/src-tauri/src/controller.rs | 112 ++++++++----- rust/gui-client/src-tauri/src/gui.rs | 25 +-- rust/gui-client/src-tauri/src/view.rs | 11 ++ website/src/components/Changelog/GUI.tsx | 4 + 11 files changed, 217 insertions(+), 126 deletions(-) delete mode 100644 rust/gui-client/src-frontend/generated/Session.ts create mode 100644 rust/gui-client/src-frontend/generated/SessionViewModel.ts diff --git a/rust/gui-client/src-frontend/components/App.tsx b/rust/gui-client/src-frontend/components/App.tsx index bd2df2693..db2ff51dc 100644 --- a/rust/gui-client/src-frontend/components/App.tsx +++ b/rust/gui-client/src-frontend/components/App.tsx @@ -20,7 +20,7 @@ import React, { useEffect, useState } from "react"; import { NavLink, Route, Routes } from "react-router"; import { AdvancedSettingsViewModel } from "../generated/AdvancedSettingsViewModel"; import { FileCount } from "../generated/FileCount"; -import { Session } from "../generated/Session"; +import { SessionViewModel } from "../generated/SessionViewModel"; import About from "./AboutPage"; import AdvancedSettingsPage from "./AdvancedSettingsPage"; import ColorPalette from "./ColorPalettePage"; @@ -30,7 +30,7 @@ import Overview from "./OverviewPage"; import { GeneralSettingsViewModel } from "../generated/GeneralSettingsViewModel"; export default function App() { - let [session, setSession] = useState(null); + let [session, setSession] = useState(null); let [logCount, setLogCount] = useState(null); let [generalSettings, setGeneralSettings] = useState(null); @@ -38,16 +38,12 @@ export default function App() { useState(null); useEffect(() => { - const signedInUnlisten = listen("signed_in", (e) => { + const sessionChanged = listen("session_changed", (e) => { let session = e.payload; - console.log("signed_in", { session }); + console.log("session_changed", { session }); setSession(session); }); - const signedOutUnlisten = listen("signed_out", (_e) => { - console.log("signed_out"); - setSession(null); - }); const generalSettingsChangedUnlisten = listen( "general_settings_changed", (e) => { @@ -78,8 +74,7 @@ export default function App() { invoke("update_state"); // Let the backend know that we (re)-initialised return () => { - signedInUnlisten.then((unlistenFn) => unlistenFn()); - signedOutUnlisten.then((unlistenFn) => unlistenFn()); + sessionChanged.then((unlistenFn) => unlistenFn()); generalSettingsChangedUnlisten.then((unlistenFn) => unlistenFn()); advancedSettingsChangedUnlisten.then((unlistenFn) => unlistenFn()); logsRecountedUnlisten.then((unlistenFn) => unlistenFn()); diff --git a/rust/gui-client/src-frontend/components/OverviewPage.tsx b/rust/gui-client/src-frontend/components/OverviewPage.tsx index 3e93e44eb..08289d557 100644 --- a/rust/gui-client/src-frontend/components/OverviewPage.tsx +++ b/rust/gui-client/src-frontend/components/OverviewPage.tsx @@ -1,68 +1,120 @@ import React from "react"; import logo from "../logo.png"; -import { Session } from "./App"; -import { Button } from "flowbite-react"; +import { SessionViewModel } from "../generated/SessionViewModel"; +import { Button, Spinner } from "flowbite-react"; interface OverviewPageProps { - session: Session | null; + session: SessionViewModel | null; signOut: () => void; signIn: () => void; } -export default function Overview({ - session, - signOut, - signIn, -}: OverviewPageProps) { +export default function Overview(props: OverviewPageProps) { return (
Firezone Logo

Firezone

- {!session ? ( -
-
-

- You can sign in by clicking the Firezone icon in the taskbar or by - clicking 'Sign in' below. -

- -

- Firezone will continue running after this window is closed. -
- It is always available from the taskbar. -

-
-
- ) : ( -
-
-

- You are currently signed into  - - {session.account_slug} - -  as  - - {session.actor_name} - - .
- Click the Firezone icon in the taskbar to see the list of - Resources. -

- -

- Firezone will continue running in the taskbar after this window is - closed. -

-
-
- )} + +
+ ); +} + +function Session(props: OverviewPageProps) { + if (!props.session) { + return ; + } + + switch (props.session) { + case "SignedOut": { + return ; + } + case "Loading": { + return ; + } + default: + let { account_slug, actor_name } = props.session.SignedIn; + + return ( + + ); + } +} + +interface SignedOutProps { + signIn: () => void; +} + +function SignedOut({ signIn }: SignedOutProps) { + return ( +
+
+

+ You can sign in by clicking the Firezone icon in the taskbar or by + clicking 'Sign in' below. +

+ +

+ Firezone will continue running after this window is closed. +
+ It is always available from the taskbar. +

+
+
+ ); +} + +interface SignedInProps { + accountSlug: string; + actorName: string; + signOut: () => void; +} + +function SignedIn({ actorName, accountSlug, signOut }: SignedInProps) { + return ( +
+
+

+ You are currently signed into  + + {accountSlug} + +  as  + + {actorName} + + .
+ Click the Firezone icon in the taskbar to see the list of Resources. +

+ +

+ Firezone will continue running in the taskbar after this window is + closed. +

+
+
+ ); +} + +function Loading() { + return ( +
+
+ +

+ Firezone will continue running in the taskbar after this window is + closed. +

+
); } diff --git a/rust/gui-client/src-frontend/generated/Session.ts b/rust/gui-client/src-frontend/generated/Session.ts deleted file mode 100644 index aea319b37..000000000 --- a/rust/gui-client/src-frontend/generated/Session.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Session { - account_slug: string; - actor_name: string; -} diff --git a/rust/gui-client/src-frontend/generated/SessionViewModel.ts b/rust/gui-client/src-frontend/generated/SessionViewModel.ts new file mode 100644 index 000000000..06d7da608 --- /dev/null +++ b/rust/gui-client/src-frontend/generated/SessionViewModel.ts @@ -0,0 +1,9 @@ +export type SessionViewModel = + { + SignedIn: { + account_slug: string; + actor_name: string + } + } | + "Loading" | + "SignedOut"; diff --git a/rust/gui-client/src-frontend/main.tsx b/rust/gui-client/src-frontend/main.tsx index 6db09a61c..e00722bd3 100644 --- a/rust/gui-client/src-frontend/main.tsx +++ b/rust/gui-client/src-frontend/main.tsx @@ -34,6 +34,11 @@ const customTheme = createTheme({ }, }, }, + spinner: { + color: { + default: "fill-accent-500", + }, + }, }); ReactDOM.createRoot(document.getElementById("root") as HTMLElement, { diff --git a/rust/gui-client/src-tauri/Cargo.toml b/rust/gui-client/src-tauri/Cargo.toml index 532acd466..42a458790 100644 --- a/rust/gui-client/src-tauri/Cargo.toml +++ b/rust/gui-client/src-tauri/Cargo.toml @@ -98,3 +98,6 @@ tempfile = { workspace = true } [lints] workspace = true + +[tslink] +enum_representation = "discriminated" diff --git a/rust/gui-client/src-tauri/src/auth.rs b/rust/gui-client/src-tauri/src/auth.rs index fad3c070c..e4c1ea26c 100644 --- a/rust/gui-client/src-tauri/src/auth.rs +++ b/rust/gui-client/src-tauri/src/auth.rs @@ -82,7 +82,6 @@ pub(crate) struct Response { pub(crate) state: SecretString, } -#[tslink::tslink(target = "./gui-client/src-frontend/generated/Session.ts")] #[derive(Default, Clone, Deserialize, Serialize)] pub struct Session { pub(crate) account_slug: String, diff --git a/rust/gui-client/src-tauri/src/controller.rs b/rust/gui-client/src-tauri/src/controller.rs index 994cbb6cd..7bd8602a3 100644 --- a/rust/gui-client/src-tauri/src/controller.rs +++ b/rust/gui-client/src-tauri/src/controller.rs @@ -6,7 +6,7 @@ use crate::{ service, settings::{self, AdvancedSettings, GeneralSettings, MdmSettings}, updates, uptime, - view::GeneralSettingsForm, + view::{GeneralSettingsForm, SessionViewModel}, }; use anyhow::{Context, Result, anyhow, bail}; use connlib_model::ResourceView; @@ -65,8 +65,7 @@ pub struct Controller { } pub trait GuiIntegration { - fn notify_signed_in(&self, session: &auth::Session) -> Result<()>; - fn notify_signed_out(&self) -> Result<()>; + fn notify_session_changed(&self, session: &SessionViewModel) -> Result<()>; fn notify_settings_changed( &self, mdm_settings: MdmSettings, @@ -84,7 +83,7 @@ pub trait GuiIntegration { fn show_update_notification(&self, ctlr_tx: CtlrTx, title: &str, url: url::Url) -> Result<()>; fn set_window_visible(&self, visible: bool) -> Result<()>; - fn show_overview_page(&self, current_session: Option<&auth::Session>) -> Result<()>; + fn show_overview_page(&self, session: &SessionViewModel) -> Result<()>; fn show_settings_page( &self, mdm_settings: MdmSettings, @@ -288,10 +287,12 @@ impl Controller { tracing::info!("No token / actor_name on disk, starting in signed-out state"); } - self.refresh_system_tray_menu(); + self.refresh_ui_state(); if !ran_before::get().await? || !self.general_settings.start_minimized { - self.integration.show_overview_page(self.auth.session())?; + let (_, session_view_model) = self.build_ui_state(); + + self.integration.show_overview_page(&session_view_model)?; } loop { @@ -432,13 +433,12 @@ impl Controller { }; let session = self.auth.session().context("Missing session")?; - self.integration.notify_signed_in(session)?; self.general_settings.account_slug = Some(session.account_slug.clone()); settings::save_general(&self.general_settings).await?; self.notify_settings_changed()?; - self.refresh_system_tray_menu(); + self.refresh_ui_state(); Ok(()) } @@ -505,7 +505,7 @@ impl Controller { 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(); + self.refresh_ui_state(); self.integration.show_notification("Settings saved", "")? } @@ -563,11 +563,10 @@ impl Controller { .context("Couldn't start sign-in flow")?; let url = req.to_url(&auth_url, account_slug.as_deref()); - self.refresh_system_tray_menu(); + self.refresh_ui_state(); self.integration .open_url(url.expose_secret()) .context("Couldn't open auth page")?; - self.integration.set_window_visible(false)?; } SystemTrayMenu(system_tray::Event::AddFavorite(resource_id)) => { self.general_settings.favorite_resources.insert(resource_id); @@ -648,7 +647,7 @@ impl Controller { tracing::info!("User clicked Quit in the menu"); self.status = Status::Quitting; self.send_ipc(&service::ClientMsg::Disconnect).await?; - self.refresh_system_tray_menu(); + self.refresh_ui_state(); } UpdateNotificationClicked(download_url) => { tracing::info!("UpdateNotificationClicked in run_controller!"); @@ -658,13 +657,11 @@ impl Controller { } UpdateState => { self.notify_settings_changed()?; - match self.auth.session() { - Some(session) => self.integration.notify_signed_in(session)?, - None => self.integration.notify_signed_out()?, - }; let file_count = logging::count_logs().await?; self.integration.notify_logs_recounted(&file_count)?; + + self.refresh_ui_state(); } } Ok(()) @@ -732,7 +729,7 @@ impl Controller { } tracing::debug!(len = resources.len(), "Got new Resources"); self.status = Status::TunnelReady { resources }; - self.refresh_system_tray_menu(); + self.refresh_ui_state(); self.update_disabled_resources().await?; } @@ -759,7 +756,7 @@ impl Controller { "Firezone connected", "You are now signed in and able to access resources.", )?; - self.refresh_system_tray_menu(); + self.refresh_ui_state(); } service::ServerMsg::Hello => {} } @@ -790,7 +787,9 @@ impl Controller { } }, gui::ClientMsg::NewInstance => { - self.integration.show_overview_page(self.auth.session())?; + let (_, session_view_model) = self.build_ui_state(); + + self.integration.show_overview_page(&session_view_model)?; } } @@ -817,7 +816,7 @@ impl Controller { self.status = Status::WaitingForTunnel { start_instant: *start_instant, }; - self.refresh_system_tray_menu(); + self.refresh_ui_state(); Ok(()) } Err(service::ConnectError::Io(error)) => { @@ -830,7 +829,7 @@ impl Controller { self.status = Status::RetryingConnection { token: token.expose_secret().clone().into(), }; - self.refresh_system_tray_menu(); + self.refresh_ui_state(); Ok(()) } Err(service::ConnectError::Other(error)) => { @@ -851,13 +850,13 @@ impl Controller { ) -> Result<()> { let Some(notification) = notification else { self.release = None; - self.refresh_system_tray_menu(); + self.refresh_ui_state(); return Ok(()); }; let release = notification.release; self.release = Some(release.clone()); - self.refresh_system_tray_menu(); + self.refresh_ui_state(); if notification.tell_user { let title = format!("Firezone {} available for download", release.version); @@ -891,7 +890,7 @@ impl Controller { disabled_resources, )) .await?; - self.refresh_system_tray_menu(); + self.refresh_ui_state(); Ok(()) } @@ -899,41 +898,70 @@ impl Controller { /// Saves the current settings (including favorites) to disk and refreshes the tray menu async fn refresh_favorite_resources(&mut self) -> Result<()> { settings::save_general(&self.general_settings).await?; - self.refresh_system_tray_menu(); + self.refresh_ui_state(); Ok(()) } - /// Builds a new system tray menu and applies it to the app - fn refresh_system_tray_menu(&mut self) { + fn build_ui_state(&self) -> (system_tray::ConnlibState, SessionViewModel) { // 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() { + if let Some(auth_session) = self.auth.session() { match &self.status { Status::Disconnected => { // If we have an `auth_session` but no connlib session, we are most likely configured to // _not_ auto-connect on startup. Thus, we treat this the same as being signed out. - system_tray::ConnlibState::SignedOut + ( + system_tray::ConnlibState::SignedOut, + SessionViewModel::SignedOut, + ) } - Status::Quitting => system_tray::ConnlibState::Quitting, - Status::RetryingConnection { .. } => system_tray::ConnlibState::RetryingConnection, - Status::TunnelReady { resources } => { + Status::Quitting => ( + system_tray::ConnlibState::Quitting, + SessionViewModel::Loading, + ), + Status::RetryingConnection { .. } => ( + system_tray::ConnlibState::RetryingConnection, + SessionViewModel::Loading, + ), + Status::TunnelReady { resources } => ( system_tray::ConnlibState::SignedIn(system_tray::SignedIn { actor_name: auth_session.actor_name.clone(), favorite_resources: self.general_settings.favorite_resources.clone(), internet_resource_enabled: self.general_settings.internet_resource_enabled, resources: resources.clone(), - }) - } - Status::WaitingForPortal { .. } => system_tray::ConnlibState::WaitingForPortal, - Status::WaitingForTunnel { .. } => system_tray::ConnlibState::WaitingForTunnel, + }), + SessionViewModel::SignedIn { + account_slug: auth_session.account_slug.clone(), + actor_name: auth_session.actor_name.clone(), + }, + ), + Status::WaitingForPortal { .. } => ( + system_tray::ConnlibState::WaitingForPortal, + SessionViewModel::Loading, + ), + Status::WaitingForTunnel { .. } => ( + system_tray::ConnlibState::WaitingForTunnel, + SessionViewModel::Loading, + ), } } else if self.auth.ongoing_request().is_some() { // Signing in, waiting on deep link callback - system_tray::ConnlibState::WaitingForBrowser + ( + system_tray::ConnlibState::WaitingForBrowser, + SessionViewModel::Loading, + ) } else { - system_tray::ConnlibState::SignedOut - }; + ( + system_tray::ConnlibState::SignedOut, + SessionViewModel::SignedOut, + ) + } + } + + /// Refreshes our UI state (i.e. tray-menu and GUI). + fn refresh_ui_state(&mut self) { + let (connlib, session_view_model) = self.build_ui_state(); self.integration.set_tray_menu(system_tray::AppState { connlib, @@ -944,6 +972,9 @@ impl Controller { .is_some_and(|hide| hide), support_url: self.mdm_settings.support_url.clone(), }); + if let Err(e) = self.integration.notify_session_changed(&session_view_model) { + tracing::warn!("Failed to send notify session change: {e:#}") + } } /// If we're in the `RetryingConnection` state, use the token to retry the Portal connection @@ -973,13 +1004,12 @@ impl Controller { | Status::WaitingForTunnel { .. } => {} } self.auth.sign_out()?; - self.integration.notify_signed_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.send_ipc(&service::ClientMsg::Disconnect).await?; - self.refresh_system_tray_menu(); + self.refresh_ui_state(); Ok(()) } diff --git a/rust/gui-client/src-tauri/src/gui.rs b/rust/gui-client/src-tauri/src/gui.rs index 382357e5f..7d05cadf3 100644 --- a/rust/gui-client/src-tauri/src/gui.rs +++ b/rust/gui-client/src-tauri/src/gui.rs @@ -4,7 +4,6 @@ //! The real macOS Client is in `swift/apple` use crate::{ - auth, controller::{Controller, ControllerRequest, CtlrTx, Failure, GuiIntegration}, deep_link, ipc::{self, ClientRead, ClientWrite, SocketId}, @@ -14,6 +13,7 @@ use crate::{ GeneralSettingsViewModel, MdmSettings, }, updates, + view::SessionViewModel, }; use anyhow::{Context, Result, bail}; use firezone_logging::err_with_src; @@ -82,20 +82,10 @@ impl Drop for TauriIntegration { } impl GuiIntegration for TauriIntegration { - fn notify_signed_in(&self, session: &auth::Session) -> Result<()> { + fn notify_session_changed(&self, session: &SessionViewModel) -> Result<()> { self.app - .emit("signed_in", session) - .context("Failed to send `signed_in` event")?; - - Ok(()) - } - - fn notify_signed_out(&self) -> Result<()> { - self.app - .emit("signed_out", ()) - .context("Failed to send `signed_out` event")?; - - Ok(()) + .emit("session_changed", session) + .context("Failed to send `session_changed` event") } fn notify_settings_changed( @@ -166,12 +156,9 @@ impl GuiIntegration for TauriIntegration { Ok(()) } - fn show_overview_page(&self, current_session: Option<&auth::Session>) -> Result<()> { + fn show_overview_page(&self, session: &SessionViewModel) -> Result<()> { // Ensure state in frontend is up-to-date. - match current_session { - Some(session) => self.notify_signed_in(session)?, - None => self.notify_signed_out()?, - }; + self.notify_session_changed(session)?; self.navigate("overview")?; self.set_window_visible(true)?; diff --git a/rust/gui-client/src-tauri/src/view.rs b/rust/gui-client/src-tauri/src/view.rs index 1635208e8..d69e68c75 100644 --- a/rust/gui-client/src-tauri/src/view.rs +++ b/rust/gui-client/src-tauri/src/view.rs @@ -20,6 +20,17 @@ pub struct GeneralSettingsForm { pub account_slug: String, } +#[tslink::tslink(target = "./gui-client/src-frontend/generated/SessionViewModel.ts")] +#[derive(Clone, serde::Serialize)] +pub enum SessionViewModel { + SignedIn { + account_slug: String, + actor_name: String, + }, + Loading, + SignedOut, +} + pub fn generate_handler() -> impl Fn(Invoke) -> bool + Send + Sync + 'static { tauri::generate_handler![ clear_logs, diff --git a/website/src/components/Changelog/GUI.tsx b/website/src/components/Changelog/GUI.tsx index 91d469d14..b1dee97de 100644 --- a/website/src/components/Changelog/GUI.tsx +++ b/website/src/components/Changelog/GUI.tsx @@ -19,6 +19,10 @@ export default function GUI({ os }: { os: OS }) { Fixes an issue where disabling the update checker via MDM would cause the Client to hang upon sign-in. + + Fixes an issue where disabling "connect on start" would incorrectly + show the Client as "Signed in" on the next launch. +