Files
firezone/rust/gui-client/src-tauri/src/client/gui.rs
Jamil 228389882e refactor(connlib): delay initialization of Sockets until we have a tokio runtime (#4286)
Our sockets need to be initialized within a tokio runtime context. To
achieve this, we don't actually initialize anything on `Sockets::new`.
Instead, we call `rebind` within the constructor of `Tunnel` which
already runs in a tokio context.

Fixes: #4282

---------

Signed-off-by: Jamil <jamilbk@users.noreply.github.com>
Co-authored-by: Thomas Eizinger <thomas@eizinger.io>
Co-authored-by: Reactor Scram <ReactorScram@users.noreply.github.com>
2024-03-25 22:51:35 +00:00

880 lines
34 KiB
Rust

//! The Tauri-based GUI Client for Windows and Linux
//!
//! Most of this Client is stubbed out with panics on macOS.
//! The real macOS Client is in `swift/apple`
// TODO: `git grep` for unwraps before 1.0, especially this gui module <https://github.com/firezone/firezone/issues/3521>
use crate::client::{
self, about, deep_link, logging, network_changes,
settings::{self, AdvancedSettings},
Failure,
};
use anyhow::{bail, Context, Result};
use arc_swap::ArcSwap;
use connlib_client_shared::{file_logger, ResourceDescription, Sockets};
use connlib_shared::{keypair, messages::ResourceId, LoginUrl, BUNDLE_ID};
use secrecy::{ExposeSecret, SecretString};
use std::{path::PathBuf, str::FromStr, sync::Arc, time::Duration};
use system_tray_menu::Event as TrayMenuEvent;
use tauri::{Manager, SystemTray, SystemTrayEvent};
use tokio::sync::{mpsc, oneshot, Notify};
use ControllerRequest as Req;
mod system_tray_menu;
#[cfg(target_os = "linux")]
#[path = "gui/os_linux.rs"]
mod os;
// Stub only
#[cfg(target_os = "macos")]
#[path = "gui/os_macos.rs"]
mod os;
#[cfg(target_os = "windows")]
#[path = "gui/os_windows.rs"]
mod os;
/// The Windows client doesn't use platform APIs to detect network connectivity changes,
/// so we rely on connlib to do so. We have valid use cases for headless Windows clients
/// (IoT devices, point-of-sale devices, etc), so try to reconnect for 30 days if there's
/// been a partition.
const MAX_PARTITION_TIME: Duration = Duration::from_secs(60 * 60 * 24 * 30);
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
/// <https://github.com/tauri-apps/tauri/issues/8631>
pub(crate) struct Managed {
pub ctlr_tx: CtlrTx,
pub inject_faults: bool,
}
impl Managed {
#[cfg(debug_assertions)]
/// In debug mode, if `--inject-faults` is passed, sleep for `millis` milliseconds
pub async fn fault_msleep(&self, millis: u64) {
if self.inject_faults {
tokio::time::sleep(std::time::Duration::from_millis(millis)).await;
}
}
#[cfg(not(debug_assertions))]
/// Does nothing in release mode
pub async fn fault_msleep(&self, _millis: u64) {}
}
// TODO: Replace with `anyhow` gradually per <https://github.com/firezone/firezone/pull/3546#discussion_r1477114789>
#[cfg_attr(target_os = "macos", allow(dead_code))]
#[derive(Debug, thiserror::Error)]
pub(crate) enum Error {
#[error("Deep-link module error: {0}")]
DeepLink(#[from] deep_link::Error),
#[error("Logging module error: {0}")]
Logging(#[from] logging::Error),
// `client.rs` provides a more user-friendly message when showing the error dialog box
#[error("WebViewNotInstalled")]
WebViewNotInstalled,
#[error(transparent)]
Other(#[from] anyhow::Error),
}
/// Runs the Tauri GUI and returns on exit or unrecoverable error
///
/// Still uses `thiserror` so we can catch the deep_link `CantListen` error
pub(crate) fn run(cli: client::Cli) -> Result<(), Error> {
let advanced_settings = settings::load_advanced_settings().unwrap_or_default();
// If the log filter is unparsable, show an error and use the default
// Fixes <https://github.com/firezone/firezone/issues/3452>
let advanced_settings =
match tracing_subscriber::EnvFilter::from_str(&advanced_settings.log_filter) {
Ok(_) => advanced_settings,
Err(_) => {
native_dialog::MessageDialog::new()
.set_title("Log filter error")
.set_text(
"The custom log filter is not parsable. Using the default log filter.",
)
.set_type(native_dialog::MessageType::Error)
.show_alert()
.context("Can't show log filter error dialog")?;
AdvancedSettings {
log_filter: AdvancedSettings::default().log_filter,
..advanced_settings
}
}
};
// Start logging
// TODO: Try using an Arc to keep the file logger alive even if Tauri bails out
// That may fix <https://github.com/firezone/firezone/issues/3567>
let logging_handles = client::logging::setup(&advanced_settings.log_filter)?;
tracing::info!("started log");
tracing::info!("GIT_VERSION = {}", crate::client::GIT_VERSION);
// Need to keep this alive so crashes will be handled. Dropping detaches it.
let _crash_handler = match client::crash_handling::attach_handler() {
Ok(x) => Some(x),
Err(error) => {
// TODO: None of these logs are actually written yet
// <https://github.com/firezone/firezone/issues/3211>
tracing::warn!(?error, "Did not set up crash handler");
None
}
};
// Needed for the deep link server
let rt = tokio::runtime::Runtime::new().context("Couldn't start Tokio runtime")?;
let _guard = rt.enter();
let (ctlr_tx, ctlr_rx) = mpsc::channel(5);
let managed = Managed {
ctlr_tx: ctlr_tx.clone(),
inject_faults: cli.inject_faults,
};
let tray = SystemTray::new().with_menu(system_tray_menu::signed_out());
tracing::debug!("Setting up Tauri app instance...");
let app = tauri::Builder::default()
.manage(managed)
.on_window_event(|event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event.event() {
// Keep the frontend running but just hide this webview
// Per https://tauri.app/v1/guides/features/system-tray/#preventing-the-app-from-closing
// Closing the window fully seems to deallocate it or something.
event.window().hide().unwrap();
api.prevent_close();
}
})
.invoke_handler(tauri::generate_handler![
about::get_cargo_version,
about::get_git_version,
logging::clear_logs,
logging::count_logs,
logging::export_logs,
settings::apply_advanced_settings,
settings::reset_advanced_settings,
settings::get_advanced_settings,
crate::client::welcome::sign_in,
])
.system_tray(tray)
.on_system_tray_event(|app, event| {
if let SystemTrayEvent::MenuItemClick { id, .. } = event {
tracing::debug!(?id, "SystemTrayEvent::MenuItemClick");
let event = match TrayMenuEvent::from_str(&id) {
Ok(x) => x,
Err(e) => {
tracing::error!("{e}");
return;
}
};
match handle_system_tray_event(app, event) {
Ok(_) => {}
Err(e) => tracing::error!("{e}"),
}
}
})
.setup(move |app| {
tracing::debug!("Entered Tauri's `setup`");
// Check for updates
let ctlr_tx_clone = ctlr_tx.clone();
let always_show_update_notification = cli.always_show_update_notification;
tokio::spawn(async move {
if let Err(error) = check_for_updates(ctlr_tx_clone, always_show_update_notification).await
{
tracing::error!(?error, "Error in check_for_updates");
}
});
// Make sure we're single-instance
// We register our deep links to call the `open-deep-link` subcommand,
// so if we're at this point, we know we've been launched manually
let server = deep_link::Server::new()?;
if let Some(client::Cmd::SmokeTest) = &cli.command {
let ctlr_tx = ctlr_tx.clone();
tokio::spawn(async move {
if let Err(error) = smoke_test(ctlr_tx).await {
tracing::error!(?error, "Error during smoke test");
tracing::error!("Crashing on purpose so a dev can see our stacktraces");
unsafe { sadness_generator::raise_segfault() }
}
});
}
tracing::debug!(cli.no_deep_links);
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")?;
tokio::spawn(accept_deep_links(server, ctlr_tx.clone()));
}
if let Some(failure) = cli.fail_on_purpose() {
let ctlr_tx = ctlr_tx.clone();
tokio::spawn(async move {
let delay = 5;
tracing::info!(
"Will crash / error / panic on purpose in {delay} seconds to test error handling."
);
tokio::time::sleep(Duration::from_secs(delay)).await;
tracing::info!("Crashing / erroring / panicking on purpose");
ctlr_tx.send(ControllerRequest::Fail(failure)).await?;
Ok::<_, anyhow::Error>(())
});
}
assert_eq!(
BUNDLE_ID,
app.handle().config().tauri.bundle.identifier,
"BUNDLE_ID should match bundle ID in tauri.conf.json"
);
let app_handle = app.handle();
let _ctlr_task = tokio::spawn(async move {
let app_handle_2 = app_handle.clone();
// Spawn two nested Tasks so the outer can catch panics from the inner
let task = tokio::spawn(async move {
run_controller(
app_handle_2,
ctlr_tx,
ctlr_rx,
logging_handles,
advanced_settings,
)
.await
});
// See <https://github.com/tauri-apps/tauri/issues/8631>
// This should be the ONLY place we call `app.exit` or `app_handle.exit`,
// because it exits the entire process without dropping anything.
//
// This seems to be a platform limitation that Tauri is unable to hide
// from us. It was the source of much consternation at time of writing.
match task.await {
Err(error) => {
tracing::error!(?error, "run_controller panicked");
app_handle.exit(1);
}
Ok(Err(error)) => {
tracing::error!(?error, "run_controller returned an error");
app_handle.exit(1);
}
Ok(Ok(_)) => {
tracing::info!("GUI controller task exited cleanly. Exiting process");
app_handle.exit(0);
}
}
});
Ok(())
});
tracing::debug!("Building Tauri app...");
let app = app.build(tauri::generate_context!());
let app = match app {
Ok(x) => x,
Err(error) => {
tracing::error!(?error, "Failed to build Tauri app instance");
match error {
tauri::Error::Runtime(tauri_runtime::Error::CreateWebview(_)) => {
return Err(Error::WebViewNotInstalled);
}
error => Err(anyhow::Error::from(error).context("Tauri error"))?,
}
}
};
app.run(|_app_handle, event| {
if let tauri::RunEvent::ExitRequested { api, .. } = event {
// Don't exit if we close our main window
// https://tauri.app/v1/guides/features/system-tray/#preventing-the-app-from-closing
api.prevent_exit();
}
});
Ok(())
}
/// Runs a smoke test and then asks Controller to exit gracefully
///
/// You can purposely fail this test by deleting the exported zip file during
/// the 10-second sleep.
async fn smoke_test(ctlr_tx: CtlrTx) -> Result<()> {
let delay = 10;
tracing::info!("Will quit on purpose in {delay} seconds as part of the smoke test.");
let quit_time = tokio::time::Instant::now() + Duration::from_secs(delay);
// Test log exporting
let path = PathBuf::from("smoke_test_log_export.zip");
let stem = "connlib-smoke-test".into();
match tokio::fs::remove_file(&path).await {
Ok(()) => {}
Err(error) => {
if error.kind() != std::io::ErrorKind::NotFound {
bail!("Error while removing old zip file")
}
}
}
ctlr_tx
.send(ControllerRequest::ExportLogs {
path: path.clone(),
stem,
})
.await
.context("Failed to send ExportLogs request")?;
// Give the app some time to export the zip and reach steady state
tokio::time::sleep_until(quit_time).await;
// Write the settings so we can check the path for those
settings::save(&settings::AdvancedSettings::default()).await?;
// Check results of tests
let zip_len = tokio::fs::metadata(&path)
.await
.context("Failed to get zip file metadata")?
.len();
if zip_len == 0 {
bail!("Exported log zip has 0 bytes");
}
tokio::fs::remove_file(&path)
.await
.context("Failed to remove zip file")?;
tracing::info!(?path, ?zip_len, "Exported log zip looks okay");
tracing::info!("Quitting on purpose because of `smoke-test` subcommand");
ctlr_tx
.send(ControllerRequest::SystemTrayMenu(TrayMenuEvent::Quit))
.await
.context("Failed to send Quit request")?;
Ok::<_, anyhow::Error>(())
}
async fn check_for_updates(ctlr_tx: CtlrTx, always_show_update_notification: bool) -> Result<()> {
let release = client::updates::check()
.await
.context("Error in client::updates::check")?;
let our_version = client::updates::current_version()?;
let github_version = &release.tag_name;
if always_show_update_notification || (our_version < release.tag_name) {
tracing::info!(?our_version, ?github_version, "Github has a new release");
// We don't necessarily need to route through the Controller here, but if we
// want a persistent "Click here to download the new MSI" button, this would allow that.
ctlr_tx
.send(ControllerRequest::UpdateAvailable(release))
.await
.context("Error while sending UpdateAvailable to Controller")?;
return Ok(());
}
tracing::info!(
?our_version,
?github_version,
"Our release is newer than, or the same as Github's latest"
);
Ok(())
}
/// Worker task to accept deep links from a named pipe forever
///
/// * `server` An initial named pipe server to consume before making new servers. This lets us also use the named pipe to enforce single-instance
async fn accept_deep_links(mut server: deep_link::Server, ctlr_tx: CtlrTx) -> Result<()> {
loop {
match server.accept().await {
Ok(bytes) => {
let url = SecretString::from_str(
std::str::from_utf8(bytes.expose_secret())
.context("Incoming deep link was not valid UTF-8")?,
)
.context("Impossible: can't wrap String into SecretString")?;
// Ignore errors from this, it would only happen if the app is shutting down, otherwise we would wait
ctlr_tx
.send(ControllerRequest::SchemeRequest(url))
.await
.ok();
}
Err(error) => tracing::error!(?error, "error while accepting deep link"),
}
// We re-create the named pipe server every time we get a link, because of an oddity in the Windows API.
server = deep_link::Server::new()?;
}
}
fn handle_system_tray_event(app: &tauri::AppHandle, event: TrayMenuEvent) -> Result<()> {
app.try_state::<Managed>()
.context("can't get Managed struct from Tauri")?
.ctlr_tx
.blocking_send(ControllerRequest::SystemTrayMenu(event))?;
Ok(())
}
pub(crate) enum ControllerRequest {
/// The GUI wants us to use these settings in-memory, they've already been saved to disk
ApplySettings(AdvancedSettings),
Disconnected,
/// 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),
TunnelReady,
UpdateAvailable(client::updates::Release),
UpdateNotificationClicked(client::updates::Release),
}
#[derive(Clone)]
struct CallbackHandler {
logger: file_logger::Handle,
notify_controller: Arc<Notify>,
ctlr_tx: CtlrTx,
resources: Arc<ArcSwap<Vec<ResourceDescription>>>,
}
// Callbacks must all be non-blocking
impl connlib_client_shared::Callbacks for CallbackHandler {
fn on_disconnect(&self, error: &connlib_client_shared::Error) {
tracing::debug!("on_disconnect {error:?}");
self.ctlr_tx
.try_send(ControllerRequest::Disconnected)
.expect("controller channel failed");
}
fn on_tunnel_ready(&self) {
tracing::info!("on_tunnel_ready");
self.ctlr_tx
.try_send(ControllerRequest::TunnelReady)
.expect("controller channel failed");
}
fn on_update_resources(&self, resources: Vec<ResourceDescription>) {
tracing::debug!("on_update_resources");
self.resources.store(resources.into());
self.notify_controller.notify_one();
}
fn roll_log_file(&self) -> Option<PathBuf> {
self.logger.roll_to_new_file().unwrap_or_else(|e| {
tracing::debug!("Failed to roll over to new file: {e}");
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,
ctlr_tx: CtlrTx,
/// connlib session for the currently signed-in user, if there is one
session: Option<Session>,
logging_handles: client::logging::Handles,
/// Tells us when to wake up and look for a new resource list. Tokio docs say that memory reads and writes are synchronized when notifying, so we don't need an extra mutex on the resources.
notify_controller: Arc<Notify>,
tunnel_ready: bool,
uptime: client::uptime::Tracker,
}
/// Everything related to a signed-in user session
struct Session {
callback_handler: CallbackHandler,
connlib: connlib_client_shared::Session,
}
impl Controller {
// TODO: Figure out how re-starting sessions automatically will work
/// Pre-req: the auth module must be signed in
fn start_session(&mut self, token: SecretString) -> Result<()> {
if self.session.is_some() {
bail!("can't start session, we're already in a session");
}
let callback_handler = CallbackHandler {
ctlr_tx: self.ctlr_tx.clone(),
logger: self.logging_handles.logger.clone(),
notify_controller: Arc::clone(&self.notify_controller),
resources: Default::default(),
};
let api_url = self.advanced_settings.api_url.clone();
tracing::info!(
api_url = api_url.to_string(),
"Calling connlib Session::connect"
);
let device_id =
connlib_shared::device_id::get().context("Failed to read / create device ID")?;
let (private_key, public_key) = keypair();
let login = LoginUrl::client(
api_url.as_str(),
&token,
device_id.id,
None,
public_key.to_bytes(),
)?;
let connlib = connlib_client_shared::Session::connect(
login,
Sockets::new(),
private_key,
None,
callback_handler.clone(),
Some(MAX_PARTITION_TIME),
tokio::runtime::Handle::current(),
)?;
connlib.set_dns(client::resolvers::get().unwrap_or_default());
self.session = Some(Session {
callback_handler,
connlib,
});
self.refresh_system_tray_menu()?;
Ok(())
}
fn copy_resource(&self, id: &str) -> Result<()> {
let Some(session) = &self.session else {
bail!("app is signed out");
};
let resources = session.callback_handler.resources.load();
let id = ResourceId::from_str(id)?;
let Some(res) = resources.iter().find(|r| r.id() == id) else {
bail!("resource ID is not in the list");
};
let mut clipboard = arboard::Clipboard::new()?;
// TODO: Make this a method on `ResourceDescription`
match res {
ResourceDescription::Dns(x) => clipboard.set_text(&x.address)?,
ResourceDescription::Cidr(x) => clipboard.set_text(&x.address.to_string())?,
}
Ok(())
}
async fn handle_deep_link(&mut self, url: &SecretString) -> Result<()> {
let auth_response =
client::deep_link::parse_auth_callback(url).context("Couldn't parse scheme request")?;
tracing::info!("Got deep link");
// Uses `std::fs`
let token = self.auth.handle_response(auth_response)?;
self.start_session(token)
.context("Couldn't start connlib session")?;
Ok(())
}
async fn handle_request(&mut self, req: ControllerRequest) -> Result<()> {
match req {
Req::ApplySettings(settings) => {
self.advanced_settings = settings;
// TODO: Update the logger here if we can. I can't remember if there
// was a reason why the reloading didn't work.
tracing::info!(
"Applied new settings. Log level will take effect at next app start."
);
}
Req::Disconnected => {
tracing::info!("Disconnected by connlib");
self.sign_out()?;
os::show_notification(
"Firezone disconnected",
"To access resources, sign in again.",
)?;
}
Req::ExportLogs { path, stem } => logging::export_logs_to(path, stem)
.await
.context("Failed to export logs to zip")?,
Req::Fail(_) => bail!("Impossible error: `Fail` should be handled before this"),
Req::GetAdvancedSettings(tx) => {
tx.send(self.advanced_settings.clone()).ok();
}
Req::SchemeRequest(url) => self
.handle_deep_link(&url)
.await
.context("Couldn't handle deep link")?,
Req::SignIn | Req::SystemTrayMenu(TrayMenuEvent::SignIn) => {
if let Some(req) = self.auth.start_sign_in()? {
let url = req.to_url(&self.advanced_settings.auth_base_url);
self.refresh_system_tray_menu()?;
os::open_url(&self.app, &url)?;
self.app
.get_window("welcome")
.context("Couldn't get handle to Welcome window")?
.hide()?;
}
}
Req::SystemTrayMenu(TrayMenuEvent::CancelSignIn) => {
if self.session.is_some() {
// If the user opened the menu, then sign-in completed, then they
// click "cancel sign in", don't sign out - They can click Sign Out
// if they want to sign out. "Cancel" may mean "Give up waiting,
// but if you already got in, don't make me sign in all over again."
//
// Also, by amazing coincidence, it doesn't work in Tauri anyway.
// We'd have to reuse the `sign_out` ID to make it work.
tracing::info!("This can never happen. Tauri doesn't pass us a system tray event if the menu no longer has any item with that ID.");
} else {
tracing::info!("Calling `sign_out` to cancel sign-in");
self.sign_out()?;
}
}
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::Resource { id }) => self
.copy_resource(&id)
.context("Couldn't copy resource to clipboard")?,
Req::SystemTrayMenu(TrayMenuEvent::SignOut) => {
tracing::info!("User asked to sign out");
self.sign_out()?;
}
Req::SystemTrayMenu(TrayMenuEvent::Quit) => {
bail!("Impossible error: `Quit` should be handled before this")
}
Req::TunnelReady => {
self.tunnel_ready = true;
self.refresh_system_tray_menu()?;
os::show_notification(
"Firezone connected",
"You are now signed in and able to access resources.",
)?;
}
Req::UpdateAvailable(release) => {
let title = format!("Firezone {} available for download", release.tag_name);
// 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_clickable_notification(
&title,
"Click here to download the new version.",
self.ctlr_tx.clone(),
Req::UpdateNotificationClicked(release),
)?;
}
Req::UpdateNotificationClicked(release) => {
tracing::info!("UpdateNotificationClicked in run_controller!");
tauri::api::shell::open(
&self.app.shell_scope(),
release.browser_download_url,
None,
)?;
}
}
Ok(())
}
/// Returns a new system tray menu
fn build_system_tray_menu(&self) -> tauri::SystemTrayMenu {
// TODO: Refactor this and the auth module so that "Are we logged in"
// doesn't require such complicated control flow to answer.
// TODO: Show some "Waiting for portal..." state if we got the deep link but
// haven't got `on_tunnel_ready` yet.
if let Some(auth_session) = self.auth.session() {
if let Some(connlib_session) = &self.session {
if self.tunnel_ready {
// Signed in, tunnel ready
let resources = connlib_session.callback_handler.resources.load();
system_tray_menu::signed_in(&auth_session.actor_name, &resources)
} else {
// Signed in, raising tunnel
system_tray_menu::signing_in("Signing In...")
}
} else {
tracing::error!("We have an auth session but no connlib session");
system_tray_menu::signed_out()
}
} else if self.auth.ongoing_request().is_ok() {
// Signing in, waiting on deep link callback
system_tray_menu::signing_in("Waiting for browser...")
} else {
system_tray_menu::signed_out()
}
}
/// Builds a new system tray menu and applies it to the app
fn refresh_system_tray_menu(&self) -> Result<()> {
Ok(self
.app
.tray_handle()
.set_menu(self.build_system_tray_menu())?)
}
/// Deletes the auth token, stops connlib, and refreshes the tray menu
fn sign_out(&mut self) -> Result<()> {
self.auth.sign_out()?;
self.tunnel_ready = false;
if let Some(session) = self.session.take() {
tracing::debug!("disconnecting connlib");
// This is redundant if the token is expired, in that case
// connlib already disconnected itself.
session.connlib.disconnect();
} else {
// Might just be because we got a double sign-out or
// the user canceled the sign-in or something innocent.
tracing::info!("Tried to sign out but there's no session, cancelled sign-in");
}
self.refresh_system_tray_menu()?;
Ok(())
}
fn show_window(&self, window: system_tray_menu::Window) -> Result<()> {
let id = match window {
system_tray_menu::Window::About => "about",
system_tray_menu::Window::Settings => "settings",
};
let win = self
.app
.get_window(id)
.context("Couldn't get handle to `{id}` window")?;
win.show()?;
win.unminimize()?;
Ok(())
}
}
// TODO: Move this into `impl Controller`
async fn run_controller(
app: tauri::AppHandle,
ctlr_tx: CtlrTx,
mut rx: mpsc::Receiver<ControllerRequest>,
logging_handles: client::logging::Handles,
advanced_settings: AdvancedSettings,
) -> Result<()> {
let session_dir = crate::client::known_dirs::session().context("Couldn't find session dir")?;
let ran_before_path = session_dir.join("ran_before.txt");
if !tokio::fs::try_exists(&ran_before_path).await? {
let win = app
.get_window("welcome")
.context("Couldn't get handle to Welcome window")?;
win.show()?;
tokio::fs::create_dir_all(&session_dir).await?;
tokio::fs::write(&ran_before_path, &[]).await?;
}
// TODO: This is temporary until process separation is done
// Try to create the device ID and ignore errors if we fail.
// This allows Linux to run with the "Generate device ID lazily" behavior,
// but Windows, which runs elevated, will write the device ID, and the smoke
// tests can cover it.
connlib_shared::device_id::get().ok();
let mut controller = Controller {
advanced_settings,
app,
auth: client::auth::Auth::new().context("Failed to set up auth module")?,
ctlr_tx,
session: None,
logging_handles,
notify_controller: Arc::new(Notify::new()), // TODO: Fix cancel-safety
tunnel_ready: false,
uptime: Default::default(),
};
if let Some(token) = controller
.auth
.token()
.context("Failed to load token from disk during app start")?
{
controller
.start_session(token)
.context("Failed to restart session during app start")?;
} else {
tracing::info!("No token / actor_name on disk, starting in signed-out state");
}
let mut have_internet =
network_changes::check_internet().context("Failed initial check for internet")?;
tracing::info!(?have_internet);
let mut com_worker =
network_changes::Worker::new().context("Failed to listen for network changes")?;
let mut dns_listener = network_changes::DnsListener::new()?;
loop {
tokio::select! {
() = controller.notify_controller.notified() => if let Err(error) = controller.refresh_system_tray_menu() {
tracing::error!(?error, "Failed to reload resource list");
},
() = com_worker.notified() => {
let new_have_internet = network_changes::check_internet().context("Failed to check for internet")?;
if new_have_internet != have_internet {
have_internet = new_have_internet;
if let Some(session) = controller.session.as_mut() {
tracing::debug!("Internet up/down changed, calling `Session::reconnect`");
session.connlib.reconnect();
}
}
},
r = dns_listener.notified() => {
r?;
if let Some(session) = controller.session.as_mut() {
tracing::debug!("New DNS resolvers, calling `Session::set_dns`");
session.connlib.set_dns(client::resolvers::get().unwrap_or_default());
}
},
req = rx.recv() => {
let Some(req) = req else {
break;
};
match req {
// SAFETY: Crashing is unsafe
Req::Fail(Failure::Crash) => {
tracing::error!("Crashing on purpose");
unsafe { sadness_generator::raise_segfault() }
},
Req::Fail(Failure::Error) => bail!("Test error"),
Req::Fail(Failure::Panic) => panic!("Test panic"),
Req::SystemTrayMenu(TrayMenuEvent::Quit) => break,
req => if let Err(error) = controller.handle_request(req).await {
tracing::error!(?error, "Failed to handle a ControllerRequest");
}
}
},
}
}
if let Err(error) = com_worker.close() {
tracing::error!(?error, "com_worker");
}
// Last chance to do any drops / cleanup before the process crashes.
Ok(())
}