refactor(windows): extract modules from gui module (#2961)

So everything in `gui` is controller logic.
This commit is contained in:
Reactor Scram
2023-12-19 19:54:55 -06:00
committed by GitHub
parent 61bff3b1ed
commit f284e06014
3 changed files with 218 additions and 200 deletions

View File

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

View File

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

View 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());
}
}