mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
refactor(windows): extract modules from gui module (#2961)
So everything in `gui` is controller logic.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
//! A module for registering deep links that are sent over to the app's already-running instance
|
||||
//! A module for registering, catching, and parsing deep links that are sent over to the app's already-running instance
|
||||
//! Based on reading some of the Windows code from <https://github.com/FabianLars/tauri-plugin-deep-link>, which is licensed "MIT OR Apache-2.0"
|
||||
|
||||
use secrecy::SecretString;
|
||||
use std::{ffi::c_void, io, path::Path};
|
||||
use tokio::{io::AsyncReadExt, io::AsyncWriteExt, net::windows::named_pipe};
|
||||
use windows::Win32::Security as WinSec;
|
||||
@@ -21,8 +22,7 @@ pub enum Error {
|
||||
/// We got some data but it's not UTF-8
|
||||
#[error(transparent)]
|
||||
LinkNotUtf8(std::string::FromUtf8Error),
|
||||
/// This means we are probably the second instance
|
||||
#[error("named pipe server couldn't start listening")]
|
||||
#[error("named pipe server couldn't start listening, we are probably the second instance")]
|
||||
Listen,
|
||||
/// Error from server's POV
|
||||
#[error(transparent)]
|
||||
@@ -34,6 +34,59 @@ pub enum Error {
|
||||
WindowsRegistry(io::Error),
|
||||
}
|
||||
|
||||
pub(crate) struct AuthCallback {
|
||||
pub actor_name: String,
|
||||
pub token: SecretString,
|
||||
pub _identifier: SecretString,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_auth_callback(url: &url::Url) -> Option<AuthCallback> {
|
||||
match url.host() {
|
||||
Some(url::Host::Domain("handle_client_auth_callback")) => {}
|
||||
_ => return None,
|
||||
}
|
||||
if url.path() != "/" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut actor_name = None;
|
||||
let mut token = None;
|
||||
let mut identifier = None;
|
||||
|
||||
for (key, value) in url.query_pairs() {
|
||||
match key.as_ref() {
|
||||
"actor_name" => {
|
||||
if actor_name.is_some() {
|
||||
// actor_name must appear exactly once
|
||||
return None;
|
||||
}
|
||||
actor_name = Some(value.to_string());
|
||||
}
|
||||
"client_auth_token" => {
|
||||
if token.is_some() {
|
||||
// client_auth_token must appear exactly once
|
||||
return None;
|
||||
}
|
||||
token = Some(SecretString::new(value.to_string()));
|
||||
}
|
||||
"identity_provider_identifier" => {
|
||||
if identifier.is_some() {
|
||||
// identity_provider_identifier must appear exactly once
|
||||
return None;
|
||||
}
|
||||
identifier = Some(SecretString::new(value.to_string()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Some(AuthCallback {
|
||||
actor_name: actor_name?,
|
||||
token: token?,
|
||||
_identifier: identifier?,
|
||||
})
|
||||
}
|
||||
|
||||
/// A server for a named pipe, so we can receive deep links from other instances
|
||||
/// of the client launched by web browsers
|
||||
pub struct Server {
|
||||
@@ -174,3 +227,26 @@ fn set_registry_values(id: &str, exe: &str) -> Result<(), io::Error> {
|
||||
fn named_pipe_path(id: &str) -> String {
|
||||
format!(r"\\.\pipe\{}", id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use anyhow::Result;
|
||||
use secrecy::ExposeSecret;
|
||||
|
||||
#[test]
|
||||
fn parse_auth_callback() -> Result<()> {
|
||||
let input = "firezone://handle_client_auth_callback/?actor_name=Reactor+Scram&client_auth_token=a_very_secret_string&identity_provider_identifier=12345";
|
||||
let input = url::Url::parse(input)?;
|
||||
dbg!(&input);
|
||||
let actual = super::parse_auth_callback(&input).unwrap();
|
||||
|
||||
assert_eq!(actual.actor_name, "Reactor Scram");
|
||||
assert_eq!(actual.token.expose_secret(), "a_very_secret_string");
|
||||
|
||||
let input = "firezone://not_handle_client_auth_callback/?actor_name=Reactor+Scram&client_auth_token=a_very_secret_string&identity_provider_identifier=12345";
|
||||
let actual = super::parse_auth_callback(&url::Url::parse(input)?);
|
||||
assert!(actual.is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// TODO: `git grep` for unwraps before 1.0, especially this gui module
|
||||
|
||||
use crate::client::{self, deep_link, AppLocalDataDir};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use client::settings::{self, AdvancedSettings};
|
||||
use connlib_client_shared::file_logger;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
@@ -13,13 +13,13 @@ use std::{
|
||||
path::PathBuf,
|
||||
str::FromStr,
|
||||
};
|
||||
use tauri::{
|
||||
CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem,
|
||||
SystemTraySubmenu,
|
||||
};
|
||||
use system_tray_menu::{Event as TrayMenuEvent, Resource as ResourceDisplay};
|
||||
use tauri::{Manager, SystemTray, SystemTrayEvent};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use ControllerRequest as Req;
|
||||
|
||||
mod system_tray_menu;
|
||||
|
||||
pub(crate) type CtlrTx = mpsc::Sender<ControllerRequest>;
|
||||
|
||||
pub(crate) fn app_local_data_dir(app: &tauri::AppHandle) -> Result<AppLocalDataDir> {
|
||||
@@ -68,7 +68,7 @@ pub(crate) fn run(params: client::GuiParams) -> Result<()> {
|
||||
inject_faults,
|
||||
};
|
||||
|
||||
let tray = SystemTray::new().with_menu(signed_out_menu());
|
||||
let tray = SystemTray::new().with_menu(system_tray_menu::signed_out());
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(managed)
|
||||
@@ -157,41 +157,11 @@ async fn accept_deep_links(
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
// 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(TAURI_ID)?;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum TrayMenuEvent {
|
||||
About,
|
||||
Resource { id: String },
|
||||
Settings,
|
||||
SignIn,
|
||||
SignOut,
|
||||
Quit,
|
||||
}
|
||||
|
||||
impl FromStr for TrayMenuEvent {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
Ok(match s {
|
||||
"/about" => Self::About,
|
||||
"/settings" => Self::Settings,
|
||||
"/sign_in" => Self::SignIn,
|
||||
"/sign_out" => Self::SignOut,
|
||||
"/quit" => Self::Quit,
|
||||
s => {
|
||||
if let Some(id) = s.strip_prefix("/resource/") {
|
||||
Self::Resource { id: id.to_string() }
|
||||
} else {
|
||||
anyhow::bail!("unknown system tray menu event");
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_system_tray_event(app: &tauri::AppHandle, event: TrayMenuEvent) -> Result<()> {
|
||||
match event {
|
||||
TrayMenuEvent::About => {
|
||||
@@ -439,7 +409,7 @@ async fn run_controller(
|
||||
tx.send(controller.advanced_settings.clone()).ok();
|
||||
}
|
||||
Req::SchemeRequest(url) => {
|
||||
if let Ok(auth) = parse_auth_callback(&url) {
|
||||
if let Some(auth) = client::deep_link::parse_auth_callback(&url) {
|
||||
tracing::debug!("setting new token");
|
||||
let entry = keyring_entry()?;
|
||||
entry.set_password(auth.token.expose_secret())?;
|
||||
@@ -472,7 +442,7 @@ async fn run_controller(
|
||||
// TODO: Needs testing
|
||||
session.disconnect(None);
|
||||
}
|
||||
app.tray_handle().set_menu(signed_out_menu())?;
|
||||
app.tray_handle().set_menu(system_tray_menu::signed_out())?;
|
||||
}
|
||||
Req::UpdateResources(resources) => {
|
||||
tracing::debug!("controller got UpdateResources");
|
||||
@@ -485,167 +455,10 @@ async fn run_controller(
|
||||
.map(|x| x.actor_name.as_str())
|
||||
.unwrap_or("TODO");
|
||||
app.tray_handle()
|
||||
.set_menu(signed_in_menu(actor_name, &resources))?;
|
||||
.set_menu(system_tray_menu::signed_in(actor_name, &resources))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::debug!("GUI controller task exiting cleanly");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) struct AuthCallback {
|
||||
actor_name: String,
|
||||
token: SecretString,
|
||||
_identifier: SecretString,
|
||||
}
|
||||
|
||||
fn parse_auth_callback(url: &url::Url) -> Result<AuthCallback> {
|
||||
let mut actor_name = None;
|
||||
let mut token = None;
|
||||
let mut identifier = None;
|
||||
|
||||
for (key, value) in url.query_pairs() {
|
||||
match key.as_ref() {
|
||||
"actor_name" => {
|
||||
if actor_name.is_some() {
|
||||
bail!("actor_name must appear exactly once");
|
||||
}
|
||||
actor_name = Some(value.to_string());
|
||||
}
|
||||
"client_auth_token" => {
|
||||
if token.is_some() {
|
||||
bail!("client_auth_token must appear exactly once");
|
||||
}
|
||||
token = Some(SecretString::new(value.to_string()));
|
||||
}
|
||||
"identity_provider_identifier" => {
|
||||
if identifier.is_some() {
|
||||
bail!("identity_provider_identifier must appear exactly once");
|
||||
}
|
||||
identifier = Some(SecretString::new(value.to_string()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(AuthCallback {
|
||||
actor_name: actor_name.ok_or_else(|| anyhow!("expected actor_name"))?,
|
||||
token: token.ok_or_else(|| anyhow!("expected client_auth_token"))?,
|
||||
_identifier: identifier.ok_or_else(|| anyhow!("expected identity_provider_identifier"))?,
|
||||
})
|
||||
}
|
||||
|
||||
/// The information needed for the GUI to display a resource inside the Firezone VPN
|
||||
struct ResourceDisplay {
|
||||
id: connlib_shared::messages::ResourceId,
|
||||
/// User-friendly name, e.g. "GitLab"
|
||||
name: String,
|
||||
/// What will be copied to the clipboard to paste into a web browser
|
||||
pastable: String,
|
||||
}
|
||||
|
||||
impl From<connlib_client_shared::ResourceDescription> for ResourceDisplay {
|
||||
fn from(x: connlib_client_shared::ResourceDescription) -> Self {
|
||||
match x {
|
||||
connlib_client_shared::ResourceDescription::Dns(x) => Self {
|
||||
id: x.id,
|
||||
name: x.name,
|
||||
pastable: x.address,
|
||||
},
|
||||
connlib_client_shared::ResourceDescription::Cidr(x) => Self {
|
||||
id: x.id,
|
||||
name: x.name,
|
||||
// TODO: CIDRs aren't URLs right?
|
||||
pastable: x.address.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn signed_in_menu(user_name: &str, resources: &[ResourceDisplay]) -> SystemTrayMenu {
|
||||
let mut menu = SystemTrayMenu::new()
|
||||
.add_item(
|
||||
CustomMenuItem::new("".to_string(), format!("Signed in as {user_name}")).disabled(),
|
||||
)
|
||||
.add_item(CustomMenuItem::new("/sign_out".to_string(), "Sign out"))
|
||||
.add_native_item(SystemTrayMenuItem::Separator)
|
||||
.add_item(CustomMenuItem::new("".to_string(), "Resources").disabled());
|
||||
|
||||
for ResourceDisplay { id, name, pastable } in resources {
|
||||
let submenu = SystemTrayMenu::new().add_item(CustomMenuItem::new(
|
||||
format!("/resource/{id}"),
|
||||
pastable.to_string(),
|
||||
));
|
||||
menu = menu.add_submenu(SystemTraySubmenu::new(name, submenu));
|
||||
}
|
||||
|
||||
menu = menu
|
||||
.add_native_item(SystemTrayMenuItem::Separator)
|
||||
.add_item(CustomMenuItem::new("/about".to_string(), "About"))
|
||||
.add_item(CustomMenuItem::new("/settings".to_string(), "Settings"))
|
||||
.add_item(
|
||||
CustomMenuItem::new("/quit".to_string(), "Disconnect and quit Firezone")
|
||||
.accelerator("Ctrl+Q"),
|
||||
);
|
||||
|
||||
menu
|
||||
}
|
||||
|
||||
fn signed_out_menu() -> SystemTrayMenu {
|
||||
SystemTrayMenu::new()
|
||||
.add_item(CustomMenuItem::new("/sign_in".to_string(), "Sign In"))
|
||||
.add_native_item(SystemTrayMenuItem::Separator)
|
||||
.add_item(CustomMenuItem::new("/about".to_string(), "About"))
|
||||
.add_item(CustomMenuItem::new("/settings".to_string(), "Settings"))
|
||||
.add_item(CustomMenuItem::new("/quit".to_string(), "Quit Firezone").accelerator("Ctrl+Q"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TrayMenuEvent;
|
||||
use anyhow::Result;
|
||||
use secrecy::ExposeSecret;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn parse_auth_callback() -> Result<()> {
|
||||
let input = "firezone://handle_client_auth_callback/?actor_name=Reactor+Scram&client_auth_token=a_very_secret_string&identity_provider_identifier=12345";
|
||||
|
||||
let actual = super::parse_auth_callback(&url::Url::parse(input)?)?;
|
||||
|
||||
assert_eq!(actual.actor_name, "Reactor Scram");
|
||||
assert_eq!(actual.token.expose_secret(), "a_very_secret_string");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn systray_parse() {
|
||||
assert_eq!(
|
||||
TrayMenuEvent::from_str("/about").unwrap(),
|
||||
TrayMenuEvent::About
|
||||
);
|
||||
assert_eq!(
|
||||
TrayMenuEvent::from_str("/resource/1234").unwrap(),
|
||||
TrayMenuEvent::Resource {
|
||||
id: "1234".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
TrayMenuEvent::from_str("/resource/quit").unwrap(),
|
||||
TrayMenuEvent::Resource {
|
||||
id: "quit".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
TrayMenuEvent::from_str("/sign_out").unwrap(),
|
||||
TrayMenuEvent::SignOut
|
||||
);
|
||||
assert_eq!(
|
||||
TrayMenuEvent::from_str("/quit").unwrap(),
|
||||
TrayMenuEvent::Quit
|
||||
);
|
||||
|
||||
assert!(TrayMenuEvent::from_str("/unknown").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
129
rust/windows-client/src-tauri/src/client/gui/system_tray_menu.rs
Executable file
129
rust/windows-client/src-tauri/src/client/gui/system_tray_menu.rs
Executable file
@@ -0,0 +1,129 @@
|
||||
use connlib_client_shared::ResourceDescription;
|
||||
use std::str::FromStr;
|
||||
use tauri::{CustomMenuItem, SystemTrayMenu, SystemTrayMenuItem, SystemTraySubmenu};
|
||||
|
||||
/// The information needed for the GUI to display a resource inside the Firezone VPN
|
||||
pub(crate) struct Resource {
|
||||
pub id: connlib_shared::messages::ResourceId,
|
||||
/// User-friendly name, e.g. "GitLab"
|
||||
pub name: String,
|
||||
/// What will be copied to the clipboard to paste into a web browser
|
||||
pub pastable: String,
|
||||
}
|
||||
|
||||
impl From<ResourceDescription> for Resource {
|
||||
fn from(x: ResourceDescription) -> Self {
|
||||
match x {
|
||||
ResourceDescription::Dns(x) => Self {
|
||||
id: x.id,
|
||||
name: x.name,
|
||||
pastable: x.address,
|
||||
},
|
||||
ResourceDescription::Cidr(x) => Self {
|
||||
id: x.id,
|
||||
name: x.name,
|
||||
// TODO: CIDRs aren't URLs right?
|
||||
pastable: x.address.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) enum Event {
|
||||
About,
|
||||
Resource { id: String },
|
||||
Settings,
|
||||
SignIn,
|
||||
SignOut,
|
||||
Quit,
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub(crate) enum Error {
|
||||
#[error("the system tray menu item ID is not valid")]
|
||||
InvalidId,
|
||||
}
|
||||
|
||||
impl FromStr for Event {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Error> {
|
||||
Ok(match s {
|
||||
"/about" => Self::About,
|
||||
"/settings" => Self::Settings,
|
||||
"/sign_in" => Self::SignIn,
|
||||
"/sign_out" => Self::SignOut,
|
||||
"/quit" => Self::Quit,
|
||||
s => {
|
||||
let id = s.strip_prefix("/resource/").ok_or(Error::InvalidId)?;
|
||||
Self::Resource { id: id.to_string() }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn signed_in(user_name: &str, resources: &[Resource]) -> SystemTrayMenu {
|
||||
let mut menu = SystemTrayMenu::new()
|
||||
.add_item(
|
||||
CustomMenuItem::new("".to_string(), format!("Signed in as {user_name}")).disabled(),
|
||||
)
|
||||
.add_item(CustomMenuItem::new("/sign_out".to_string(), "Sign out"))
|
||||
.add_native_item(SystemTrayMenuItem::Separator)
|
||||
.add_item(CustomMenuItem::new("".to_string(), "Resources").disabled());
|
||||
|
||||
for Resource { id, name, pastable } in resources {
|
||||
let submenu = SystemTrayMenu::new().add_item(CustomMenuItem::new(
|
||||
format!("/resource/{id}"),
|
||||
pastable.to_string(),
|
||||
));
|
||||
menu = menu.add_submenu(SystemTraySubmenu::new(name, submenu));
|
||||
}
|
||||
|
||||
menu = menu
|
||||
.add_native_item(SystemTrayMenuItem::Separator)
|
||||
.add_item(CustomMenuItem::new("/about".to_string(), "About"))
|
||||
.add_item(CustomMenuItem::new("/settings".to_string(), "Settings"))
|
||||
.add_item(
|
||||
CustomMenuItem::new("/quit".to_string(), "Disconnect and quit Firezone")
|
||||
.accelerator("Ctrl+Q"),
|
||||
);
|
||||
|
||||
menu
|
||||
}
|
||||
|
||||
pub(crate) fn signed_out() -> SystemTrayMenu {
|
||||
SystemTrayMenu::new()
|
||||
.add_item(CustomMenuItem::new("/sign_in".to_string(), "Sign In"))
|
||||
.add_native_item(SystemTrayMenuItem::Separator)
|
||||
.add_item(CustomMenuItem::new("/about".to_string(), "About"))
|
||||
.add_item(CustomMenuItem::new("/settings".to_string(), "Settings"))
|
||||
.add_item(CustomMenuItem::new("/quit".to_string(), "Quit Firezone").accelerator("Ctrl+Q"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Event;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn systray_parse() {
|
||||
assert_eq!(Event::from_str("/about").unwrap(), Event::About);
|
||||
assert_eq!(
|
||||
Event::from_str("/resource/1234").unwrap(),
|
||||
Event::Resource {
|
||||
id: "1234".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
Event::from_str("/resource/quit").unwrap(),
|
||||
Event::Resource {
|
||||
id: "quit".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(Event::from_str("/sign_out").unwrap(), Event::SignOut);
|
||||
assert_eq!(Event::from_str("/quit").unwrap(), Event::Quit);
|
||||
|
||||
assert!(Event::from_str("/unknown").is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user