diff --git a/.vscode/settings.json b/.vscode/settings.json
index 9052608..e5dc0e1 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -5,6 +5,8 @@
"clickaway",
"clientgpversion",
"clientos",
+ "devicename",
+ "distro",
"gpcommon",
"gpgui",
"gpservice",
diff --git a/Cargo.lock b/Cargo.lock
index 25f7ead..63ec891 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -54,16 +54,19 @@ dependencies = [
"env_logger",
"gpcommon",
"log",
+ "openssl",
"regex",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-log",
+ "tauri-plugin-store",
"tokio",
"url",
"veil",
"webkit2gtk",
+ "whoami",
]
[[package]]
@@ -2981,7 +2984,7 @@ dependencies = [
[[package]]
name = "tauri-plugin-log"
version = "0.0.0"
-source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#794f2d5cb8d53284f0abbeb8f584185b4dce3fc1"
+source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#36b7296746bf8d41f0790d8ecd9b097430750a47"
dependencies = [
"byte-unit",
"fern",
@@ -2993,6 +2996,18 @@ dependencies = [
"time",
]
+[[package]]
+name = "tauri-plugin-store"
+version = "0.0.0"
+source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#36b7296746bf8d41f0790d8ecd9b097430750a47"
+dependencies = [
+ "log",
+ "serde",
+ "serde_json",
+ "tauri",
+ "thiserror",
+]
+
[[package]]
name = "tauri-runtime"
version = "0.13.0"
@@ -3658,6 +3673,16 @@ dependencies = [
"windows-metadata",
]
+[[package]]
+name = "whoami"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22fc3756b8a9133049b26c7f61ab35416c130e8c09b660f5b3958b446f52cc50"
+dependencies = [
+ "wasm-bindgen",
+ "web-sys",
+]
+
[[package]]
name = "winapi"
version = "0.3.9"
diff --git a/gpgui/index.html b/gpgui/index.html
index fcdeb15..088afdd 100644
--- a/gpgui/index.html
+++ b/gpgui/index.html
@@ -3,17 +3,17 @@
-
+
GlobalProtect
-
+
-
-
+
+
diff --git a/gpgui/package.json b/gpgui/package.json
index 1b14efb..097a467 100644
--- a/gpgui/package.json
+++ b/gpgui/package.json
@@ -16,17 +16,19 @@
"@mui/material": "^5.11.11",
"@tauri-apps/api": "^1.3.0",
"immer": "^10.0.2",
- "jotai": "^2.1.1",
+ "jotai": "^2.2.1",
"jotai-immer": "^0.2.0",
"jotai-optics": "^0.3.0",
"optics-ts": "^2.4.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-spinners": "^0.13.8",
- "tauri-plugin-log-api": "github:tauri-apps/tauri-plugin-log"
+ "tauri-plugin-log-api": "github:tauri-apps/tauri-plugin-log",
+ "tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store#v1"
},
"devDependencies": {
"@tauri-apps/cli": "^1.3.1",
+ "@types/node": "^20.3.3",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
"@vitejs/plugin-react-swc": "^3.0.0",
diff --git a/gpgui/pages/settings/index.html b/gpgui/pages/settings/index.html
new file mode 100644
index 0000000..19d767e
--- /dev/null
+++ b/gpgui/pages/settings/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ GlobalProtect Settings
+
+
+
+
+
+
diff --git a/gpgui/pnpm-lock.yaml b/gpgui/pnpm-lock.yaml
index d195d52..b3dcbbc 100644
--- a/gpgui/pnpm-lock.yaml
+++ b/gpgui/pnpm-lock.yaml
@@ -27,14 +27,14 @@ dependencies:
specifier: ^10.0.2
version: 10.0.2
jotai:
- specifier: ^2.1.1
- version: 2.1.1(react@18.2.0)
+ specifier: ^2.2.1
+ version: 2.2.1(react@18.2.0)
jotai-immer:
specifier: ^0.2.0
- version: 0.2.0(immer@10.0.2)(jotai@2.1.1)(react@18.2.0)
+ version: 0.2.0(immer@10.0.2)(jotai@2.2.1)(react@18.2.0)
jotai-optics:
specifier: ^0.3.0
- version: 0.3.0(jotai@2.1.1)(optics-ts@2.4.0)
+ version: 0.3.0(jotai@2.2.1)(optics-ts@2.4.0)
optics-ts:
specifier: ^2.4.0
version: 2.4.0
@@ -49,12 +49,18 @@ dependencies:
version: 0.13.8(react-dom@18.2.0)(react@18.2.0)
tauri-plugin-log-api:
specifier: github:tauri-apps/tauri-plugin-log
- version: github.com/tauri-apps/tauri-plugin-log/5e14c2cad7335a4284a6caad81d8cf37dd675a27
+ version: github.com/tauri-apps/tauri-plugin-log/21921031d74f871180381317a338559f588ad8e9
+ tauri-plugin-store-api:
+ specifier: github:tauri-apps/tauri-plugin-store#v1
+ version: github.com/tauri-apps/tauri-plugin-store/1467ba770623ab1d41d825841c3d9435d9eaa0f1
devDependencies:
'@tauri-apps/cli':
specifier: ^1.3.1
version: 1.3.1
+ '@types/node':
+ specifier: ^20.3.3
+ version: 20.3.3
'@types/react':
specifier: ^18.0.27
version: 18.0.28
@@ -69,7 +75,7 @@ devDependencies:
version: 4.9.5
vite:
specifier: ^4.1.0
- version: 4.1.4
+ version: 4.1.4(@types/node@20.3.3)
packages:
@@ -969,6 +975,10 @@ packages:
'@tauri-apps/cli-win32-x64-msvc': 1.3.1
dev: true
+ /@types/node@20.3.3:
+ resolution: {integrity: sha512-wheIYdr4NYML61AjC8MKj/2jrR/kDQri/CIpVoZwldwhnIrD/j9jIU5bJ8yBKuB2VhpFV7Ab6G2XkBjv9r9Zzw==}
+ dev: true
+
/@types/parse-json@4.0.0:
resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==}
dev: false
@@ -1010,7 +1020,7 @@ packages:
vite: ^4
dependencies:
'@swc/core': 1.3.36
- vite: 4.1.4
+ vite: 4.1.4(@types/node@20.3.3)
dev: true
/ansi-styles@3.2.1:
@@ -1186,7 +1196,7 @@ packages:
dependencies:
has: 1.0.3
- /jotai-immer@0.2.0(immer@10.0.2)(jotai@2.1.1)(react@18.2.0):
+ /jotai-immer@0.2.0(immer@10.0.2)(jotai@2.2.1)(react@18.2.0):
resolution: {integrity: sha512-hahK8EPiROS9RoNWmX/Z8rY9WkAijspX4BZ1O7umpcwI4kPNkbcCpu/PhiQ8FMcpEcF6KmbpbMpSSj/GFmo8NA==}
peerDependencies:
immer: '*'
@@ -1194,22 +1204,22 @@ packages:
react: '>=17.0.0'
dependencies:
immer: 10.0.2
- jotai: 2.1.1(react@18.2.0)
+ jotai: 2.2.1(react@18.2.0)
react: 18.2.0
dev: false
- /jotai-optics@0.3.0(jotai@2.1.1)(optics-ts@2.4.0):
+ /jotai-optics@0.3.0(jotai@2.2.1)(optics-ts@2.4.0):
resolution: {integrity: sha512-5ttpCRREIBu6DJix0wlyBP6y1QDPlePnoMZSXNDi/FOkXZrhk9uIXKjwvw34/yBCHT5mYpFUD4sFDvRUU2vkvQ==}
peerDependencies:
jotai: '>=1.11.0'
optics-ts: '*'
dependencies:
- jotai: 2.1.1(react@18.2.0)
+ jotai: 2.2.1(react@18.2.0)
optics-ts: 2.4.0
dev: false
- /jotai@2.1.1(react@18.2.0):
- resolution: {integrity: sha512-LaaiuSaq+6XkwkrCtCkczyFVZOXe0dfjAFN4DVMsSZSRv/A/4xuLHnlpHMEDqvngjWYBotTIrnQ7OogMkUE6wA==}
+ /jotai@2.2.1(react@18.2.0):
+ resolution: {integrity: sha512-Gz4tpbRQy9OiFgBwF9F7TieDn0UTE3C0IFSDuxHjOIvgn2tACH30UKz6p/wIlfoZROXSTCIxEvYEa7Y25WM+8g==}
engines: {node: '>=12.20.0'}
peerDependencies:
react: '>=17.0.0'
@@ -1416,7 +1426,7 @@ packages:
hasBin: true
dev: true
- /vite@4.1.4:
+ /vite@4.1.4(@types/node@20.3.3):
resolution: {integrity: sha512-3knk/HsbSTKEin43zHu7jTwYWv81f8kgAL99G5NWBcA1LKvtvcVAC4JjBH1arBunO9kQka+1oGbrMKOjk4ZrBg==}
engines: {node: ^14.18.0 || >=16.0.0}
hasBin: true
@@ -1441,6 +1451,7 @@ packages:
terser:
optional: true
dependencies:
+ '@types/node': 20.3.3
esbuild: 0.16.17
postcss: 8.4.21
resolve: 1.22.1
@@ -1454,10 +1465,18 @@ packages:
engines: {node: '>= 6'}
dev: false
- github.com/tauri-apps/tauri-plugin-log/5e14c2cad7335a4284a6caad81d8cf37dd675a27:
- resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-log/tar.gz/5e14c2cad7335a4284a6caad81d8cf37dd675a27}
+ github.com/tauri-apps/tauri-plugin-log/21921031d74f871180381317a338559f588ad8e9:
+ resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-log/tar.gz/21921031d74f871180381317a338559f588ad8e9}
name: tauri-plugin-log-api
version: 0.0.0
dependencies:
'@tauri-apps/api': 1.3.0
dev: false
+
+ github.com/tauri-apps/tauri-plugin-store/1467ba770623ab1d41d825841c3d9435d9eaa0f1:
+ resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-store/tar.gz/1467ba770623ab1d41d825841c3d9435d9eaa0f1}
+ name: tauri-plugin-store-api
+ version: 0.0.0
+ dependencies:
+ '@tauri-apps/api': 1.3.0
+ dev: false
diff --git a/gpgui/src-tauri/Cargo.toml b/gpgui/src-tauri/Cargo.toml
index f9aaeb4..4255a1b 100644
--- a/gpgui/src-tauri/Cargo.toml
+++ b/gpgui/src-tauri/Cargo.toml
@@ -29,6 +29,9 @@ regex = "1"
url = "2.3"
tokio = { version = "1.14", features = ["full"] }
veil = "0.1.6"
+whoami = "1.4.1"
+tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
+openssl = "0.10"
[features]
# by default Tauri runs in production mode
diff --git a/gpgui/src-tauri/src/auth.rs b/gpgui/src-tauri/src/auth.rs
index 5a70979..07df56f 100644
--- a/gpgui/src-tauri/src/auth.rs
+++ b/gpgui/src-tauri/src/auth.rs
@@ -181,7 +181,7 @@ fn setup_window(window: &Window, event_tx: mpsc::Sender) -> EventHand
window.listen_global(AUTH_REQUEST_EVENT, move |event| {
if let Ok(payload) = TryInto::::try_into(event.payload()) {
let event_tx = event_tx.clone();
- send_auth_event(event_tx.clone(), AuthEvent::Request(payload));
+ send_auth_event(event_tx, AuthEvent::Request(payload));
} else {
warn!("Invalid auth request payload");
}
@@ -198,7 +198,7 @@ async fn process(
process_request(window, auth_request)?;
let handle = tokio::spawn(show_window_after_timeout(window.clone()));
- let auth_data = monitor_events(&window, event_rx).await;
+ let auth_data = monitor_events(window, event_rx).await;
if !handle.is_finished() {
handle.abort();
@@ -254,12 +254,12 @@ async fn monitor_auth_event(window: &Window, mut event_rx: mpsc::Receiver {
- attempt_times = attempt_times + 1;
+ attempt_times += 1;
info!(
"Got auth request from auth-request event, attempt #{}",
attempt_times
);
- if let Err(err) = process_request(&window, auth_request) {
+ if let Err(err) = process_request(window, auth_request) {
warn!("Error processing auth request: {}", err);
}
}
@@ -316,7 +316,7 @@ async fn monitor_window_close_event(window: &Window) {
if matches!(event, WindowEvent::CloseRequested { .. }) {
if let Ok(mut close_tx_locked) = close_tx.try_lock() {
if let Some(close_tx) = close_tx_locked.take() {
- if let Err(_) = close_tx.send(()) {
+ if close_tx.send(()).is_err() {
println!("Error sending close event");
}
}
@@ -352,14 +352,23 @@ async fn handle_token_not_found(window: Window, cancel_timeout_rx: Arc) {
if let Some(response) = main_res.response() {
- if let Some(auth_data) = read_auth_data_from_response(&response) {
- debug!("Got auth data from HTTP headers: {:?}", auth_data);
- send_auth_data(auth_event_tx, auth_data);
- return;
+ match read_auth_data_from_response(&response) {
+ Ok(auth_data) => {
+ debug!("Got auth data from HTTP headers: {:?}", auth_data);
+ send_auth_data(auth_event_tx, auth_data);
+ return;
+ }
+ Err(AuthError::TokenInvalid) => {
+ debug!("Received invalid token from HTTP headers");
+ send_auth_error(auth_event_tx, AuthError::TokenInvalid);
+ return;
+ }
+ Err(AuthError::TokenNotFound) => {
+ debug!("Token not found in HTTP headers, trying to read from HTML");
+ }
}
}
- let auth_event_tx = auth_event_tx.clone();
main_res.data(Cancellable::NONE, move |data| {
if let Ok(data) = data {
let html = String::from_utf8_lossy(&data);
@@ -378,20 +387,27 @@ fn parse_auth_data(main_res: &WebResource, auth_event_tx: mpsc::Sender Option {
- response.http_headers().and_then(|mut headers| {
- let auth_data = AuthData::new(
- headers.get("saml-username").map(GString::into),
- headers.get("prelogin-cookie").map(GString::into),
- headers.get("portal-userauthcookie").map(GString::into),
- );
+fn read_auth_data_from_response(response: &webkit2gtk::URIResponse) -> Result {
+ response
+ .http_headers()
+ .map_or(Err(AuthError::TokenNotFound), |mut headers| {
+ let saml_status: Option = headers.get("saml-auth-status").map(GString::into);
+ if saml_status == Some("-1".to_string()) {
+ return Err(AuthError::TokenInvalid);
+ }
- if auth_data.check() {
- Some(auth_data)
- } else {
- None
- }
- })
+ let auth_data = AuthData::new(
+ headers.get("saml-username").map(GString::into),
+ headers.get("prelogin-cookie").map(GString::into),
+ headers.get("portal-userauthcookie").map(GString::into),
+ );
+
+ if auth_data.check() {
+ Ok(auth_data)
+ } else {
+ Err(AuthError::TokenNotFound)
+ }
+ })
}
/// Read the authentication data from the HTML content
@@ -441,7 +457,7 @@ fn send_auth_error(auth_event_tx: mpsc::Sender, err: AuthError) {
}
fn send_auth_event(auth_event_tx: mpsc::Sender, auth_event: AuthEvent) {
- let _ = tauri::async_runtime::spawn(async move {
+ tauri::async_runtime::spawn(async move {
if let Err(err) = auth_event_tx.send(auth_event).await {
warn!("Error sending event: {}", err);
}
diff --git a/gpgui/src-tauri/src/commands.rs b/gpgui/src-tauri/src/commands.rs
index 4fe05f7..765304d 100644
--- a/gpgui/src-tauri/src/commands.rs
+++ b/gpgui/src-tauri/src/commands.rs
@@ -1,7 +1,12 @@
-use crate::auth::{self, AuthData, AuthRequest, SamlBinding, SamlLoginParams};
+use crate::{
+ auth::{self, AuthData, AuthRequest, SamlBinding, SamlLoginParams},
+ utils::get_openssl_conf,
+ utils::get_openssl_conf_path,
+};
use gpcommon::{Client, ServerApiError, VpnStatus};
use std::sync::Arc;
use tauri::{AppHandle, State};
+use tokio::fs;
#[tauri::command]
pub(crate) async fn service_online<'a>(client: State<'a, Arc>) -> Result {
@@ -47,3 +52,22 @@ pub(crate) async fn saml_login(
};
auth::saml_login(params).await
}
+
+#[tauri::command]
+pub(crate) fn os_version() -> String {
+ whoami::distro()
+}
+
+#[tauri::command]
+pub(crate) fn openssl_config() -> String {
+ get_openssl_conf()
+}
+
+#[tauri::command]
+pub(crate) async fn update_openssl_config(app_handle: AppHandle) -> tauri::Result<()> {
+ let openssl_conf = get_openssl_conf();
+ let openssl_conf_path = get_openssl_conf_path(&app_handle);
+
+ fs::write(openssl_conf_path, openssl_conf).await?;
+ Ok(())
+}
diff --git a/gpgui/src-tauri/src/main.rs b/gpgui/src-tauri/src/main.rs
index 2ecf081..7ba0387 100644
--- a/gpgui/src-tauri/src/main.rs
+++ b/gpgui/src-tauri/src/main.rs
@@ -3,13 +3,15 @@
windows_subsystem = "windows"
)]
+use crate::utils::get_openssl_conf_path;
use env_logger::Env;
use gpcommon::{Client, ClientStatus, VpnStatus};
-use log::warn;
+use log::{info, warn};
use serde::Serialize;
-use std::sync::Arc;
-use tauri::Manager;
+use std::{path::PathBuf, sync::Arc};
+use tauri::{Manager, Wry};
use tauri_plugin_log::LogTarget;
+use tauri_plugin_store::{with_store, StoreCollection};
mod auth;
mod commands;
@@ -25,8 +27,24 @@ fn setup(app: &mut tauri::App) -> Result<(), Box> {
let client_clone = client.clone();
let app_handle = app.handle();
+ let stores = app.state::>();
+ let path = PathBuf::from(".settings.dat");
+ let _ = with_store(app_handle.clone(), stores, path, |store| {
+ let settings_data = store.get("SETTINGS_DATA");
+ let custom_openssl = settings_data.map_or(false, |data| {
+ data["customOpenSSL"].as_bool().unwrap_or(false)
+ });
+
+ if custom_openssl {
+ info!("Using custom OpenSSL config");
+ let openssl_conf = get_openssl_conf_path(&app_handle).into_os_string();
+ std::env::set_var("OPENSSL_CONF", openssl_conf);
+ }
+ Ok(())
+ });
+
tauri::async_runtime::spawn(async move {
- let _ = client_clone.subscribe_status(move |client_status| match client_status {
+ client_clone.subscribe_status(move |client_status| match client_status {
ClientStatus::Vpn(vpn_status) => {
let payload = VpnStatusPayload { status: vpn_status };
if let Err(err) = app_handle.emit_all("vpn-status-received", payload) {
@@ -45,15 +63,12 @@ fn setup(app: &mut tauri::App) -> Result<(), Box> {
app.manage(client);
- match std::env::var("XDG_CURRENT_DESKTOP") {
- Ok(desktop) => {
- if desktop == "KDE" {
- if let Some(main_window) = app.get_window("main") {
- let _ = main_window.set_decorations(false);
- }
+ if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
+ if desktop == "KDE" {
+ if let Some(main_window) = app.get_window("main") {
+ let _ = main_window.set_decorations(false);
}
}
- Err(_) => (),
}
Ok(())
@@ -61,7 +76,6 @@ fn setup(app: &mut tauri::App) -> Result<(), Box> {
fn main() {
// env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
-
tauri::Builder::default()
.plugin(
tauri_plugin_log::Builder::default()
@@ -73,13 +87,17 @@ fn main() {
.with_colors(Default::default())
.build(),
)
+ .plugin(tauri_plugin_store::Builder::default().build())
.setup(setup)
.invoke_handler(tauri::generate_handler![
commands::service_online,
commands::vpn_status,
commands::vpn_connect,
commands::vpn_disconnect,
- commands::saml_login
+ commands::saml_login,
+ commands::os_version,
+ commands::openssl_config,
+ commands::update_openssl_config,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
diff --git a/gpgui/src-tauri/src/utils.rs b/gpgui/src-tauri/src/utils.rs
index 6e8ef95..b69f349 100644
--- a/gpgui/src-tauri/src/utils.rs
+++ b/gpgui/src-tauri/src/utils.rs
@@ -1,6 +1,6 @@
use log::{info, warn};
-use std::time::Instant;
-use tauri::Window;
+use std::{path::PathBuf, time::Instant};
+use tauri::{AppHandle, Window};
use tokio::sync::oneshot;
use url::{form_urlencoded, Url};
use webkit2gtk::{
@@ -9,7 +9,7 @@ use webkit2gtk::{
};
pub(crate) fn redact_url(url: &str) -> String {
- if let Ok(mut url) = Url::parse(&url) {
+ if let Ok(mut url) = Url::parse(url) {
if let Err(err) = url.set_host(Some("redacted")) {
warn!("Error redacting URL: {}", err);
}
@@ -20,7 +20,7 @@ pub(crate) fn redact_url(url: &str) -> String {
let redacted_query = redact_query(url.query().unwrap_or(""));
url.set_query(Some(&redacted_query));
}
- return url.to_string();
+ url.to_string()
} else {
warn!("Error parsing URL: {}", url);
url.to_string()
@@ -86,3 +86,40 @@ fn send_result(tx: oneshot::Sender<()>) {
warn!("Error sending clear cookies result");
}
}
+
+pub(crate) fn get_openssl_conf() -> String {
+ // OpenSSL version number format: 0xMNN00PP0L
+ // https://www.openssl.org/docs/man3.0/man3/OPENSSL_VERSION_NUMBER.html
+ let version_3_0_4: i64 = 0x30000040;
+ let openssl_version = openssl::version::number();
+
+ // See: https://stackoverflow.com/questions/75763525/curl-35-error0a000152ssl-routinesunsafe-legacy-renegotiation-disabled
+ let option = if openssl_version >= version_3_0_4 {
+ "UnsafeLegacyServerConnect"
+ } else {
+ "UnsafeLegacyRenegotiation"
+ };
+
+ format!(
+ "openssl_conf = openssl_init
+
+[openssl_init]
+ssl_conf = ssl_sect
+
+[ssl_sect]
+system_default = system_default_sect
+
+[system_default_sect]
+Options = {}",
+ option
+ )
+}
+
+pub(crate) fn get_openssl_conf_path(app_handle: &AppHandle) -> PathBuf {
+ let app_dir = app_handle
+ .path_resolver()
+ .app_data_dir()
+ .expect("failed to resolve app dir");
+
+ app_dir.join("openssl.cnf")
+}
diff --git a/gpgui/src-tauri/tauri.conf.json b/gpgui/src-tauri/tauri.conf.json
index ebb5e68..fe19e5a 100644
--- a/gpgui/src-tauri/tauri.conf.json
+++ b/gpgui/src-tauri/tauri.conf.json
@@ -7,8 +7,8 @@
"distDir": "../dist"
},
"package": {
- "productName": "gpgui",
- "version": "0.1.0"
+ "productName": "GlobalProtect",
+ "version": "2.0.0"
},
"tauri": {
"allowlist": {
@@ -42,7 +42,7 @@
"icons/icon.icns",
"icons/icon.ico"
],
- "identifier": "com.tauri.dev",
+ "identifier": "com.yuezk.gpgui",
"longDescription": "",
"macOS": {
"entitlements": null,
diff --git a/gpgui/src/App.css b/gpgui/src/App.css
deleted file mode 100644
index 8dd7a12..0000000
--- a/gpgui/src/App.css
+++ /dev/null
@@ -1,8 +0,0 @@
-html, body {
- height: 100%;
- margin: 0;
- padding: 0;
- -webkit-user-select: none;
- user-select: none;
- cursor: default;
-}
\ No newline at end of file
diff --git a/gpgui/src/App.tsx b/gpgui/src/App.tsx
deleted file mode 100644
index 5545ead..0000000
--- a/gpgui/src/App.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { Box } from "@mui/material";
-import { useAtomValue } from "jotai";
-import "./App.css";
-import { statusReadyAtom } from "./atoms/status";
-import ConnectForm from "./components/ConnectForm";
-import ConnectionStatus from "./components/ConnectionStatus";
-import Feedback from "./components/Feedback";
-import GatewaySwitcher from "./components/GatewaySwitcher";
-import MainMenu from "./components/MainMenu";
-import Notification from "./components/Notification";
-
-function Loading() {
- return (
-
- Loading...
-
- );
-}
-
-function MainContent() {
- return (
- <>
-
-
-
-
-
- >
- );
-}
-
-export default function App() {
- const ready = useAtomValue(statusReadyAtom);
-
- return (
-
- {ready ? : }
-
-
- );
-}
diff --git a/gpgui/src/atoms/connectPortal.ts b/gpgui/src/atoms/connectPortal.ts
new file mode 100644
index 0000000..81fa416
--- /dev/null
+++ b/gpgui/src/atoms/connectPortal.ts
@@ -0,0 +1,82 @@
+import { atom } from "jotai";
+import authService from "../services/authService";
+import portalService, { Prelogin } from "../services/portalService";
+import { loginPortalAtom } from "./loginPortal";
+import { notifyErrorAtom } from "./notification";
+import { launchPasswordLoginAtom } from "./passwordLogin";
+import { currentPortalDataAtom, portalAddressAtom } from "./portal";
+import { launchSamlLoginAtom, retrySamlLoginAtom } from "./samlLogin";
+import { isProcessingAtom, statusAtom } from "./status";
+
+/**
+ * Connect to the portal, workflow:
+ * 1. Portal prelogin to get the prelogin data
+ * 2. Try to login with the cached credential
+ * 3. If login failed, launch the SAML login window or the password login window based on the prelogin data
+ */
+export const connectPortalAtom = atom(
+ null,
+ async (get, set, action?: "retry-auth") => {
+ // Retry the SAML authentication
+ if (action === "retry-auth") {
+ set(retrySamlLoginAtom);
+ return;
+ }
+
+ const portal = get(portalAddressAtom);
+ if (!portal) {
+ set(notifyErrorAtom, "Portal is empty");
+ return;
+ }
+
+ try {
+ set(statusAtom, "prelogin");
+ const prelogin = await portalService.prelogin(portal);
+ const isProcessing = get(isProcessingAtom);
+ if (!isProcessing) {
+ console.info("Request cancelled");
+ return;
+ }
+
+ try {
+ // If the portal is cached, use the cached credential
+ await set(loginWithCachedCredentialAtom, prelogin);
+ } catch {
+ // Otherwise, login with SAML or the password
+ if (prelogin.isSamlAuth) {
+ await set(launchSamlLoginAtom, prelogin);
+ } else {
+ set(launchPasswordLoginAtom, prelogin);
+ }
+ }
+ } catch (err) {
+ set(cancelConnectPortalAtom);
+ set(notifyErrorAtom, err);
+ }
+ }
+);
+
+connectPortalAtom.onMount = (dispatch) => {
+ return authService.onAuthError(() => {
+ dispatch("retry-auth");
+ });
+};
+
+export const cancelConnectPortalAtom = atom(null, (_get, set) => {
+ set(statusAtom, "disconnected");
+});
+
+/**
+ * Read the cached credential from the current portal data and login with it
+ */
+const loginWithCachedCredentialAtom = atom(
+ null,
+ async (get, set, prelogin: Prelogin) => {
+ const { cachedCredential } = get(currentPortalDataAtom);
+ if (!cachedCredential) {
+ throw new Error("No cached credential");
+ }
+
+ await set(loginPortalAtom, cachedCredential, prelogin);
+ }
+);
diff --git a/gpgui/src/atoms/gateway.ts b/gpgui/src/atoms/gateway.ts
index ca6edfc..fe5ba88 100644
--- a/gpgui/src/atoms/gateway.ts
+++ b/gpgui/src/atoms/gateway.ts
@@ -1,65 +1,48 @@
import { atom } from "jotai";
-import gatewayService from "../services/gatewayService";
-import vpnService from "../services/vpnService";
-import { notifyErrorAtom } from "./notification";
-import { isProcessingAtom, statusAtom } from "./status";
+import { connectPortalAtom } from "./connectPortal";
+import {
+ GatewayData,
+ currentPortalDataAtom,
+ updatePortalDataAtom,
+} from "./portal";
+import { statusAtom } from "./status";
+import { disconnectVpnAtom } from "./vpn";
-type GatewayCredential = {
- user: string;
- passwd?: string;
- userAuthCookie: string;
- prelogonUserAuthCookie: string;
-};
-
-export const gatewayLoginAtom = atom(
- null,
- async (get, set, gateway: string, credential: GatewayCredential) => {
- set(statusAtom, "gateway-login");
- let token: string;
- try {
- token = await gatewayService.login(gateway, credential);
- } catch (err) {
- throw new Error("Failed to login to gateway");
- }
-
- const isProcessing = get(isProcessingAtom);
- if (!isProcessing) {
- console.info("Request cancelled");
- return;
- }
-
- await set(connectVpnAtom, gateway, token);
- }
-);
-
-const connectVpnAtom = atom(
- null,
- async (_get, set, vpnAddress: string, token: string) => {
- try {
- set(statusAtom, "connecting");
- await vpnService.connect(vpnAddress, token);
- set(statusAtom, "connected");
- } catch (err) {
- throw new Error("Failed to connect to VPN");
- }
- }
-);
-
-const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
-
-export const disconnectVpnAtom = atom(null, async (get, set) => {
- try {
- set(statusAtom, "disconnecting");
- await vpnService.disconnect();
- // Sleep a short time, so that the client can receive the service's disconnected event.
- await sleep(100);
- } catch (err) {
- set(statusAtom, "disconnected");
- set(notifyErrorAtom, "Failed to disconnect from VPN");
- }
+export const portalGatewaysAtom = atom((get) => {
+ const { gateways } = get(currentPortalDataAtom);
+ return gateways;
});
+export const selectedGatewayAtom = atom(
+ (get) => get(currentPortalDataAtom).selectedGateway,
+ async (get, set, update: string) => {
+ const portalData = get(currentPortalDataAtom);
+ await set(updatePortalDataAtom, { ...portalData, selectedGateway: update });
+ }
+);
+
export const gatewaySwitcherVisibleAtom = atom(false);
-export const openGatewaySwitcherAtom = atom(null, (get, set) => {
+export const openGatewaySwitcherAtom = atom(null, (_get, set) => {
set(gatewaySwitcherVisibleAtom, true);
});
+
+const switchingAtom = atom(false);
+export const switchGatewayAtom = atom(
+ (get) => get(switchingAtom),
+ async (get, set, gateway: GatewayData) => {
+ const status = await get(statusAtom);
+
+ // Update the selected gateway first
+ await set(selectedGatewayAtom, gateway.name);
+
+ if (status === "connected") {
+ try {
+ set(switchingAtom, true);
+ await set(disconnectVpnAtom);
+ await set(connectPortalAtom);
+ } finally {
+ set(switchingAtom, false);
+ }
+ }
+ }
+);
diff --git a/gpgui/src/atoms/loginGateway.ts b/gpgui/src/atoms/loginGateway.ts
new file mode 100644
index 0000000..f4a5361
--- /dev/null
+++ b/gpgui/src/atoms/loginGateway.ts
@@ -0,0 +1,35 @@
+import { atom } from "jotai";
+import gatewayService from "../services/gatewayService";
+import { isProcessingAtom, statusAtom } from "./status";
+import { connectVpnAtom } from "./vpn";
+
+type GatewayCredential = {
+ user: string;
+ passwd?: string;
+ userAuthCookie: string;
+ prelogonUserAuthCookie: string;
+};
+
+/**
+ * Login to a gateway to get the token, and then connect to VPN with the token
+ */
+export const loginGatewayAtom = atom(
+ null,
+ async (get, set, gateway: string, credential: GatewayCredential) => {
+ set(statusAtom, "gateway-login");
+ let token: string;
+ try {
+ token = await gatewayService.login(gateway, credential);
+ } catch (err) {
+ throw new Error("Failed to login to gateway");
+ }
+
+ const isProcessing = get(isProcessingAtom);
+ if (!isProcessing) {
+ console.info("Request cancelled");
+ return;
+ }
+
+ await set(connectVpnAtom, gateway, token);
+ }
+);
diff --git a/gpgui/src/atoms/loginPortal.ts b/gpgui/src/atoms/loginPortal.ts
new file mode 100644
index 0000000..6d24619
--- /dev/null
+++ b/gpgui/src/atoms/loginPortal.ts
@@ -0,0 +1,100 @@
+import { atom } from "jotai";
+import portalService, {
+ PortalConfig,
+ PortalCredential,
+ Prelogin,
+} from "../services/portalService";
+import { selectedGatewayAtom } from "./gateway";
+import { loginGatewayAtom } from "./loginGateway";
+import { portalAddressAtom, updatePortalDataAtom } from "./portal";
+import { isProcessingAtom, statusAtom } from "./status";
+
+// Indicates whether the portal config is being fetched
+// This is mainly used to show the loading indicator in the password login form
+const portalConfigLoadingAtom = atom(false);
+
+/**
+ * Workflow:
+ *
+ * 1. Fetch portal config
+ * 2. Save the portal config to the external storage
+ * 3. Login the gateway, which will retrieve the token and pass it
+ * to the background service to connect the VPN
+ */
+export const loginPortalAtom = atom(
+ (get) => get(portalConfigLoadingAtom),
+ async (
+ get,
+ set,
+ credential: PortalCredential,
+ prelogin: Prelogin,
+ configFetched?: () => void
+ ) => {
+ set(statusAtom, "portal-config");
+
+ const portalAddress = get(portalAddressAtom);
+ if (!portalAddress) {
+ throw new Error("Portal is empty");
+ }
+
+ set(portalConfigLoadingAtom, true);
+ let portalConfig: PortalConfig;
+ try {
+ portalConfig = await portalService.fetchConfig(portalAddress, credential);
+ configFetched?.();
+ } finally {
+ set(portalConfigLoadingAtom, false);
+ }
+
+ const isProcessing = get(isProcessingAtom);
+ if (!isProcessing) {
+ console.info("Request cancelled");
+ return;
+ }
+
+ const { gateways, userAuthCookie, prelogonUserAuthCookie } = portalConfig;
+ if (!gateways.length) {
+ throw new Error("No gateway found");
+ }
+
+ if (userAuthCookie === "empty" || prelogonUserAuthCookie === "empty") {
+ throw new Error("Failed to login, please try again");
+ }
+
+ // Here, we have got the portal config successfully, refresh the cached portal data
+ const previousSelectedGateway = get(selectedGatewayAtom);
+ const selectedGateway = gateways.find(
+ ({ name }) => name === previousSelectedGateway
+ );
+
+ // Update the portal data to persist it
+ await set(updatePortalDataAtom, {
+ address: portalAddress,
+ gateways: gateways.map(({ name, address }) => ({ name, address })),
+ cachedCredential: {
+ user: credential.user,
+ passwd: credential.passwd,
+ "portal-userauthcookie": userAuthCookie,
+ "portal-prelogonuserauthcookie": prelogonUserAuthCookie,
+ },
+ selectedGateway: selectedGateway?.name,
+ });
+
+ // Choose the best gateway
+ const { region } = prelogin;
+ const { name, address } = portalService.chooseGateway(gateways, {
+ region,
+ preferredGateway: previousSelectedGateway,
+ });
+
+ // Log in to the gateway
+ await set(loginGatewayAtom, address, {
+ user: credential.user,
+ userAuthCookie,
+ prelogonUserAuthCookie,
+ });
+
+ // Update the selected gateway after a successful login
+ await set(selectedGatewayAtom, name);
+ }
+);
diff --git a/gpgui/src/atoms/menu.ts b/gpgui/src/atoms/menu.ts
index fe3f59e..21f8c14 100644
--- a/gpgui/src/atoms/menu.ts
+++ b/gpgui/src/atoms/menu.ts
@@ -1,17 +1,25 @@
import { exit } from "@tauri-apps/api/process";
import { atom } from "jotai";
import { RESET } from "jotai/utils";
-import { disconnectVpnAtom } from "./gateway";
-import { appDataStorageAtom, portalAddressAtom } from "./portal";
+import settingsService, { TabValue } from "../services/settingsService";
+import { passwordAtom, usernameAtom } from "./passwordLogin";
+import { appDataAtom, portalAddressAtom } from "./portal";
import { statusAtom } from "./status";
+import { disconnectVpnAtom } from "./vpn";
+
+export const openSettingsAtom = atom(null, (_get, _set, update?: TabValue) => {
+ settingsService.openSettings({ tab: update });
+});
export const resetAtom = atom(null, (_get, set) => {
- set(appDataStorageAtom, RESET);
+ set(appDataAtom, RESET);
set(portalAddressAtom, "");
+ set(usernameAtom, "");
+ set(passwordAtom, "");
});
export const quitAtom = atom(null, async (get, set) => {
- const status = get(statusAtom);
+ const status = await get(statusAtom);
if (status === "connected") {
await set(disconnectVpnAtom);
diff --git a/gpgui/src/atoms/notification.ts b/gpgui/src/atoms/notification.ts
index 35853df..a89d9ef 100644
--- a/gpgui/src/atoms/notification.ts
+++ b/gpgui/src/atoms/notification.ts
@@ -1,5 +1,6 @@
import { AlertColor } from "@mui/material";
import { atom } from "jotai";
+import ErrorWithTitle from "../utils/ErrorWithTitle";
export type Severity = AlertColor;
@@ -37,9 +38,11 @@ export const notifyErrorAtom = atom(
msg = "Unknown error";
}
+ const title = err instanceof ErrorWithTitle ? err.title : "Error";
+
set(notificationVisibleAtom, true);
set(notificationConfigAtom, {
- title: "Error",
+ title,
message: msg,
severity: "error",
duration: duration <= 0 ? undefined : duration,
diff --git a/gpgui/src/atoms/passwordLogin.ts b/gpgui/src/atoms/passwordLogin.ts
new file mode 100644
index 0000000..c55ebb9
--- /dev/null
+++ b/gpgui/src/atoms/passwordLogin.ts
@@ -0,0 +1,74 @@
+import { atom } from "jotai";
+import { atomWithDefault } from "jotai/utils";
+import { PasswordPrelogin } from "../services/portalService";
+import { loginPortalAtom } from "./loginPortal";
+import { notifyErrorAtom } from "./notification";
+import { currentPortalDataAtom, portalAddressAtom } from "./portal";
+import { statusAtom } from "./status";
+
+const loginFormVisibleAtom = atom(false);
+
+export const passwordPreloginAtom = atom({
+ isSamlAuth: false,
+ region: "",
+ authMessage: "",
+ labelUsername: "",
+ labelPassword: "",
+});
+
+export const launchPasswordLoginAtom = atom(
+ null,
+ (_get, set, prelogin: PasswordPrelogin) => {
+ set(loginFormVisibleAtom, true);
+ set(passwordPreloginAtom, prelogin);
+ set(statusAtom, "authenticating-password");
+ }
+);
+
+// Use the cached credential to login
+export const usernameAtom = atomWithDefault((get) => {
+ return get(currentPortalDataAtom).cachedCredential?.user ?? "";
+});
+
+export const passwordAtom = atomWithDefault((get) => {
+ return get(currentPortalDataAtom).cachedCredential?.passwd ?? "";
+});
+
+export const cancelPasswordAuthAtom = atom(
+ (get) => get(loginFormVisibleAtom),
+ (_get, set) => {
+ set(loginFormVisibleAtom, false);
+ set(statusAtom, "disconnected");
+ }
+);
+
+export const passwordLoginAtom = atom(
+ (get) => get(loginPortalAtom),
+ async (get, set) => {
+ const portal = get(portalAddressAtom);
+ const username = get(usernameAtom);
+ const password = get(passwordAtom);
+
+ if (!portal) {
+ set(notifyErrorAtom, "Portal is empty");
+ return;
+ }
+
+ if (!username) {
+ set(notifyErrorAtom, "Username is empty");
+ return;
+ }
+
+ try {
+ const credential = { user: username, passwd: password };
+ const prelogin = get(passwordPreloginAtom);
+ await set(loginPortalAtom, credential, prelogin, () => {
+ // Hide the login form after portal login success
+ set(loginFormVisibleAtom, false);
+ });
+ } catch (err) {
+ set(statusAtom, "disconnected");
+ set(notifyErrorAtom, err);
+ }
+ }
+);
diff --git a/gpgui/src/atoms/portal.ts b/gpgui/src/atoms/portal.ts
index 1998d11..eb63572 100644
--- a/gpgui/src/atoms/portal.ts
+++ b/gpgui/src/atoms/portal.ts
@@ -1,16 +1,8 @@
import { atom } from "jotai";
-import { withImmer } from "jotai-immer";
-import { atomWithDefault, atomWithStorage } from "jotai/utils";
-import authService, { AuthData } from "../services/authService";
-import portalService, {
- PasswordPrelogin,
- PortalCredential,
- Prelogin,
- SamlPrelogin,
-} from "../services/portalService";
-import { disconnectVpnAtom, gatewayLoginAtom } from "./gateway";
-import { notifyErrorAtom } from "./notification";
-import { isProcessingAtom, statusAtom } from "./status";
+import { atomWithDefault } from "jotai/utils";
+import { PortalCredential } from "../services/portalService";
+import { atomWithTauriStorage } from "../services/storeService";
+import { unwrap } from "./unwrap";
export type GatewayData = {
name: string;
@@ -32,346 +24,65 @@ type AppData = {
clearCookies: boolean;
};
-type AppDataUpdate =
- | {
- type: "PORTAL";
- payload: PortalData;
- }
- | {
- type: "SELECTED_GATEWAY";
- payload: string;
- };
-
-const defaultAppData: AppData = {
+const DEFAULT_APP_DATA: AppData = {
portal: "",
portals: [],
// Whether to clear the cookies of the SAML login webview, default is true
clearCookies: true,
};
-export const appDataStorageAtom = atomWithStorage(
- "APP_DATA",
- defaultAppData
-);
-const appDataImmerAtom = withImmer(appDataStorageAtom);
-
-const updateAppDataAtom = atom(null, (_get, set, update: AppDataUpdate) => {
- const { type, payload } = update;
- switch (type) {
- case "PORTAL":
- const { address } = payload;
- set(appDataImmerAtom, (draft) => {
- draft.portal = address;
- const portalIndex = draft.portals.findIndex(
- ({ address: portalAddress }) => portalAddress === address
- );
- if (portalIndex === -1) {
- draft.portals.push(payload);
- } else {
- draft.portals[portalIndex] = payload;
- }
- });
- break;
- case "SELECTED_GATEWAY":
- set(appDataImmerAtom, (draft) => {
- const { portal, portals } = draft;
- const portalData = portals.find(({ address }) => address === portal);
- if (portalData) {
- portalData.selectedGateway = payload;
- }
- });
- break;
- }
-});
-
-export const portalAddressAtom = atomWithDefault(
- (get) => get(appDataImmerAtom).portal
+export const appDataAtom = atomWithTauriStorage("APP_DATA", DEFAULT_APP_DATA);
+const unwrappedAppDataAtom = atom(
+ (get) => get(unwrap(appDataAtom)) || DEFAULT_APP_DATA
);
+// Read the portal address from the store as the default value
+export const portalAddressAtom = atomWithDefault(
+ (get) => get(unwrappedAppDataAtom).portal
+);
+
+// The cached portal data for the current portal address
export const currentPortalDataAtom = atom((get) => {
const portalAddress = get(portalAddressAtom);
- const { portals } = get(appDataImmerAtom);
+ const appData = get(unwrappedAppDataAtom);
+ const { portals } = appData;
const portalData = portals.find(({ address }) => address === portalAddress);
return portalData || { address: portalAddress, gateways: [] };
});
-const clearCookiesAtom = atom(
- (get) => get(appDataImmerAtom).clearCookies,
- (_get, set, update: boolean) => {
- set(appDataImmerAtom, (draft) => {
- draft.clearCookies = update;
- });
- }
-);
-
-export const portalGatewaysAtom = atom((get) => {
- const { gateways } = get(currentPortalDataAtom);
- return gateways;
-});
-
-export const selectedGatewayAtom = atom(
- (get) => get(currentPortalDataAtom).selectedGateway
-);
-
-export const connectPortalAtom = atom(
- (get) => get(isProcessingAtom),
- async (get, set, action?: "retry-auth") => {
- // Retry the SAML authentication
- if (action === "retry-auth") {
- set(retrySamlAuthAtom);
- return;
- }
-
- const portal = get(portalAddressAtom);
- if (!portal) {
- set(notifyErrorAtom, "Portal is empty");
- return;
- }
-
- try {
- set(statusAtom, "prelogin");
- const prelogin = await portalService.prelogin(portal);
- const isProcessing = get(isProcessingAtom);
- if (!isProcessing) {
- console.info("Request cancelled");
- return;
- }
-
- try {
- await set(loginWithCachedCredentialAtom, prelogin);
- } catch {
- if (prelogin.isSamlAuth) {
- await set(launchSamlAuthAtom, prelogin);
- } else {
- await set(launchPasswordAuthAtom, prelogin);
- }
- }
- } catch (err) {
- set(cancelConnectPortalAtom);
- set(notifyErrorAtom, err);
- }
- }
-);
-
-connectPortalAtom.onMount = (dispatch) => {
- return authService.onAuthError(() => {
- dispatch("retry-auth");
- });
-};
-
-const loginWithCachedCredentialAtom = atom(
+export const updatePortalDataAtom = atom(
null,
- async (get, set, prelogin: Prelogin) => {
- const { cachedCredential } = get(currentPortalDataAtom);
- if (!cachedCredential) {
- throw new Error("No cached credential");
+ async (get, set, update: PortalData) => {
+ const appData = await get(appDataAtom);
+ const { portals } = appData;
+ const portalIndex = portals.findIndex(
+ ({ address }) => address === update.address
+ );
+
+ if (portalIndex === -1) {
+ portals.push(update);
+ } else {
+ portals[portalIndex] = update;
}
- await set(portalLoginAtom, cachedCredential, prelogin);
+
+ await set(appDataAtom, (appData) => ({
+ ...appData,
+ portal: update.address,
+ portals,
+ }));
}
);
-export const passwordPreloginAtom = atom({
- isSamlAuth: false,
- region: "",
- authMessage: "",
- labelUsername: "",
- labelPassword: "",
-});
-
-export const cancelConnectPortalAtom = atom(null, (_get, set) => {
- set(statusAtom, "disconnected");
-});
-
-export const usernameAtom = atomWithDefault(
- (get) => get(currentPortalDataAtom).cachedCredential?.user ?? ""
-);
-
-export const passwordAtom = atomWithDefault(
- (get) => get(currentPortalDataAtom).cachedCredential?.passwd ?? ""
-);
-
-const passwordAuthVisibleAtom = atom(false);
-
-const launchPasswordAuthAtom = atom(
- null,
- async (_get, set, prelogin: PasswordPrelogin) => {
- set(passwordAuthVisibleAtom, true);
- set(passwordPreloginAtom, prelogin);
- set(statusAtom, "authenticating-password");
- }
-);
-
-export const cancelPasswordAuthAtom = atom(
- (get) => get(passwordAuthVisibleAtom),
- (_get, set) => {
- set(passwordAuthVisibleAtom, false);
- set(cancelConnectPortalAtom);
- }
-);
-
-export const passwordLoginAtom = atom(
- (get) => get(portalConfigLoadingAtom),
- async (get, set, username: string, password: string) => {
- const portal = get(portalAddressAtom);
- if (!portal) {
- set(notifyErrorAtom, "Portal is empty");
- return;
- }
-
- if (!username) {
- set(notifyErrorAtom, "Username is empty");
- return;
- }
-
- try {
- const credential = { user: username, passwd: password };
- const prelogin = get(passwordPreloginAtom);
- await set(portalLoginAtom, credential, prelogin);
- } catch (err) {
- set(cancelConnectPortalAtom);
- set(notifyErrorAtom, err);
- }
- }
-);
-
-const launchSamlAuthAtom = atom(
- null,
- async (get, set, prelogin: SamlPrelogin) => {
- const { samlAuthMethod, samlRequest } = prelogin;
- let authData: AuthData;
-
- try {
- set(statusAtom, "authenticating-saml");
- const clearCookies = get(clearCookiesAtom);
- authData = await authService.samlLogin(
- samlAuthMethod,
- samlRequest,
- clearCookies
- );
- } catch (err) {
- throw new Error("SAML login failed");
- }
-
- if (!authData) {
- // User closed the SAML login window, cancel the login
- set(cancelConnectPortalAtom);
- return;
- }
-
- // SAML login success, update clearCookies to false to reuse the SAML session
- set(clearCookiesAtom, false);
-
- const credential = {
- user: authData.username,
- "prelogin-cookie": authData.prelogin_cookie,
- "portal-userauthcookie": authData.portal_userauthcookie,
- };
-
- await set(portalLoginAtom, credential, prelogin);
- }
-);
-
-const retrySamlAuthAtom = atom(null, async (get) => {
- const portal = get(portalAddressAtom);
- const prelogin = await portalService.prelogin(portal);
- if (prelogin.isSamlAuth) {
- await authService.emitAuthRequest({
- samlBinding: prelogin.samlAuthMethod,
- samlRequest: prelogin.samlRequest,
- });
- }
-});
-
-const portalConfigLoadingAtom = atom(false);
-const portalLoginAtom = atom(
- (get) => get(portalConfigLoadingAtom),
- async (get, set, credential: PortalCredential, prelogin: Prelogin) => {
- set(statusAtom, "portal-config");
- set(portalConfigLoadingAtom, true);
-
- const portalAddress = get(portalAddressAtom);
- let portalConfig;
- try {
- portalConfig = await portalService.fetchConfig(portalAddress, credential);
- // Ensure the password auth window is closed
- set(passwordAuthVisibleAtom, false);
- } finally {
- set(portalConfigLoadingAtom, false);
- }
-
- const isProcessing = get(isProcessingAtom);
- if (!isProcessing) {
- console.info("Request cancelled");
- return;
- }
-
- const { gateways, userAuthCookie, prelogonUserAuthCookie } = portalConfig;
- if (!gateways.length) {
- throw new Error("No gateway found");
- }
-
- if (userAuthCookie === "empty" || prelogonUserAuthCookie === "empty") {
- throw new Error("Failed to login, please try again");
- }
-
- // Previous selected gateway
- const previousGateway = get(selectedGatewayAtom);
- // Update the app data to persist the portal data
- set(updateAppDataAtom, {
- type: "PORTAL",
- payload: {
- address: portalAddress,
- gateways: gateways.map(({ name, address }) => ({
- name,
- address,
- })),
- cachedCredential: {
- user: credential.user,
- passwd: credential.passwd,
- "portal-userauthcookie": userAuthCookie,
- "portal-prelogonuserauthcookie": prelogonUserAuthCookie,
- },
- selectedGateway: previousGateway,
- },
- });
-
- const { region } = prelogin;
- const { name, address } = portalService.preferredGateway(gateways, {
- region,
- previousGateway,
- });
- await set(gatewayLoginAtom, address, {
- user: credential.user,
- userAuthCookie,
- prelogonUserAuthCookie,
- });
-
- // Update the app data to persist the gateway data
- set(updateAppDataAtom, {
- type: "SELECTED_GATEWAY",
- payload: name,
- });
- }
-);
-
-export const switchingGatewayAtom = atom(false);
-export const switchToGatewayAtom = atom(
- (get) => get(switchingGatewayAtom),
- async (get, set, gateway: GatewayData) => {
- set(updateAppDataAtom, {
- type: "SELECTED_GATEWAY",
- payload: gateway.name,
- });
-
- if (get(statusAtom) === "connected") {
- try {
- set(switchingGatewayAtom, true);
- await set(disconnectVpnAtom);
- await set(connectPortalAtom);
- } finally {
- set(switchingGatewayAtom, false);
- }
- }
+export const clearCookiesAtom = atom(
+ async (get) => {
+ const { clearCookies } = await get(appDataAtom);
+ return clearCookies;
+ },
+ async (_get, set, update: boolean) => {
+ await set(appDataAtom, (appData) => ({
+ ...appData,
+ clearCookies: update,
+ }));
}
);
diff --git a/gpgui/src/atoms/samlLogin.ts b/gpgui/src/atoms/samlLogin.ts
new file mode 100644
index 0000000..cbc7271
--- /dev/null
+++ b/gpgui/src/atoms/samlLogin.ts
@@ -0,0 +1,59 @@
+import { atom } from "jotai";
+import authService, { AuthData } from "../services/authService";
+import portalService, { SamlPrelogin } from "../services/portalService";
+import { loginPortalAtom } from "./loginPortal";
+import { clearCookiesAtom, portalAddressAtom } from "./portal";
+import { statusAtom } from "./status";
+import { unwrap } from "./unwrap";
+
+export const launchSamlLoginAtom = atom(
+ null,
+ async (get, set, prelogin: SamlPrelogin) => {
+ const { samlAuthMethod, samlRequest } = prelogin;
+ let authData: AuthData;
+
+ try {
+ set(statusAtom, "authenticating-saml");
+ const clearCookies = await get(clearCookiesAtom);
+ authData = await authService.samlLogin(
+ samlAuthMethod,
+ samlRequest,
+ clearCookies
+ );
+
+ // update clearCookies to false to reuse the SAML session
+ await set(clearCookiesAtom, false);
+ } catch (err) {
+ throw new Error("SAML login failed");
+ }
+
+ if (!authData) {
+ // User closed the SAML login window, cancel the login
+ set(statusAtom, "disconnected");
+ return;
+ }
+
+ const credential = {
+ user: authData.username,
+ "prelogin-cookie": authData.prelogin_cookie,
+ "portal-userauthcookie": authData.portal_userauthcookie,
+ };
+
+ await set(loginPortalAtom, credential, prelogin);
+ }
+);
+
+export const retrySamlLoginAtom = atom(null, async (get) => {
+ const portal = get(portalAddressAtom);
+ if (!portal) {
+ throw new Error("Portal not found");
+ }
+
+ const prelogin = await portalService.prelogin(portal);
+ if (prelogin.isSamlAuth) {
+ await authService.emitAuthRequest({
+ samlBinding: prelogin.samlAuthMethod,
+ samlRequest: prelogin.samlRequest,
+ });
+ }
+});
diff --git a/gpgui/src/atoms/settings.ts b/gpgui/src/atoms/settings.ts
new file mode 100644
index 0000000..f11d8da
--- /dev/null
+++ b/gpgui/src/atoms/settings.ts
@@ -0,0 +1,98 @@
+import { atom } from "jotai";
+import { atomWithDefault } from "jotai/utils";
+import settingsService, {
+ ClientOS,
+ DEFAULT_SETTINGS_DATA,
+ SETTINGS_DATA,
+} from "../services/settingsService";
+import { atomWithTauriStorage } from "../services/storeService";
+import { unwrap } from "./unwrap";
+
+const settingsDataAtom = atomWithTauriStorage(
+ SETTINGS_DATA,
+ DEFAULT_SETTINGS_DATA
+);
+
+const unwrappedSettingsDataAtom = atom(
+ (get) => get(unwrap(settingsDataAtom)) || DEFAULT_SETTINGS_DATA
+);
+
+export const clientOSAtom = atomWithDefault((get) => {
+ const { clientOS } = get(unwrappedSettingsDataAtom);
+ return clientOS;
+});
+
+export const osVersionAtom = atomWithDefault((get) => {
+ const { osVersion } = get(unwrappedSettingsDataAtom);
+ return osVersion;
+});
+
+// The os version of the current OS, retrieved from the Rust backend
+const currentOsVersionAtom = atomWithDefault(() =>
+ settingsService.getCurrentOsVersion()
+);
+
+// The default OS version for the selected client OS
+export const defaultOsVersionAtom = atomWithDefault((get) => {
+ const clientOS = get(clientOSAtom);
+ const osVersion = get(osVersionAtom);
+ const currentOsVersion = get(unwrap(currentOsVersionAtom));
+
+ // The current OS version is not ready, trigger the suspense,
+ // to avoid the intermediate UI state
+ if (!currentOsVersion) {
+ return Promise.resolve("");
+ }
+
+ return settingsService.determineOsVersion(
+ clientOS,
+ osVersion,
+ currentOsVersion
+ );
+});
+
+export const clientVersionAtom = atomWithDefault((get) => {
+ const { clientVersion } = get(unwrappedSettingsDataAtom);
+ return clientVersion;
+});
+
+export const userAgentAtom = atom((get) => {
+ const clientOS = get(clientOSAtom);
+ const osVersion = get(osVersionAtom);
+ const currentOsVersion = get(unwrap(currentOsVersionAtom)) || "";
+ const clientVersion = get(clientVersionAtom);
+
+ return settingsService.buildUserAgent(
+ clientOS,
+ osVersion,
+ currentOsVersion,
+ clientVersion
+ );
+});
+
+export const customOpenSSLAtom = atomWithDefault((get) => {
+ const { customOpenSSL } = get(unwrappedSettingsDataAtom);
+ return customOpenSSL;
+});
+
+export const opensslConfigAtom = atomWithDefault(async () => {
+ return settingsService.getOpenSSLConfig();
+});
+
+export const saveSettingsAtom = atom(null, async (get, set) => {
+ const clientOS = get(clientOSAtom);
+ const osVersion = get(osVersionAtom);
+ const clientVersion = get(clientVersionAtom);
+ const customOpenSSL = get(customOpenSSLAtom);
+
+ await set(settingsDataAtom, {
+ clientOS,
+ osVersion,
+ clientVersion,
+ customOpenSSL,
+ });
+
+ if (customOpenSSL) {
+ await settingsService.updateOpenSSLConfig();
+ }
+});
diff --git a/gpgui/src/atoms/status.ts b/gpgui/src/atoms/status.ts
index 19425ff..cc6331b 100644
--- a/gpgui/src/atoms/status.ts
+++ b/gpgui/src/atoms/status.ts
@@ -1,8 +1,9 @@
import { atom } from "jotai";
import { atomWithDefault } from "jotai/utils";
import vpnService from "../services/vpnService";
+import { selectedGatewayAtom, switchGatewayAtom } from "./gateway";
import { notifyErrorAtom, notifySuccessAtom } from "./notification";
-import { selectedGatewayAtom, switchingGatewayAtom } from "./portal";
+import { unwrap } from "./unwrap";
export type Status =
| "disconnected"
@@ -16,17 +17,22 @@ export type Status =
| "disconnecting"
| "error";
-const internalIsOnlineAtom = atomWithDefault(() => vpnService.isOnline());
-export const isOnlineAtom = atom(
- (get) => get(internalIsOnlineAtom),
+// Whether the gpservice has started
+const _backgroundServiceStartedAtom = atomWithDefault<
+ boolean | Promise
+>(() => vpnService.isOnline());
+
+export const backgroundServiceStartedAtom = atom(
+ (get) => get(_backgroundServiceStartedAtom),
async (get, set, update: boolean) => {
- const isOnline = await get(internalIsOnlineAtom);
- // Already online, do nothing
- if (update && update === isOnline) {
+ const prev = await get(_backgroundServiceStartedAtom);
+ // Already started, do nothing
+ if (update && update === prev) {
return;
}
- set(internalIsOnlineAtom, update);
+ set(_backgroundServiceStartedAtom, update);
+ // From stopped to started
if (update) {
set(notifySuccessAtom, "The background service is online");
} else {
@@ -34,25 +40,19 @@ export const isOnlineAtom = atom(
}
}
);
-isOnlineAtom.onMount = (setAtom) => vpnService.onServiceStatusChanged(setAtom);
-const internalStatusReadyAtom = atom(false);
-export const statusReadyAtom = atom(
- (get) => get(internalStatusReadyAtom),
- (get, set, status: Status) => {
- set(internalStatusReadyAtom, true);
- set(statusAtom, status);
- }
-);
-
-statusReadyAtom.onMount = (setAtom) => {
- vpnService.status().then(setAtom);
+backgroundServiceStartedAtom.onMount = (setAtom) => {
+ vpnService.onServiceStatusChanged(setAtom);
};
-export const statusAtom = atom("disconnected");
+// The current status of the vpn connection
+export const statusAtom = atomWithDefault>(() =>
+ vpnService.status()
+);
+
statusAtom.onMount = (setAtom) => vpnService.onVpnStatusChanged(setAtom);
-const statusTextMap: Record = {
+const statusTextMap: Record = {
disconnected: "Not Connected",
prelogin: "Portal pre-logging in...",
"authenticating-saml": "Authenticating...",
@@ -65,9 +65,13 @@ const statusTextMap: Record = {
error: "Error",
};
-export const statusTextAtom = atom((get) => {
- const status = get(statusAtom);
- const switchingGateway = get(switchingGatewayAtom);
+export const statusTextAtom = atom((get) => {
+ const status = get(unwrap(statusAtom));
+ const switchingGateway = get(switchGatewayAtom);
+
+ if (!status) {
+ return "Loading...";
+ }
if (status === "connected") {
const selectedGateway = get(selectedGatewayAtom);
@@ -84,11 +88,16 @@ export const statusTextAtom = atom((get) => {
return statusTextMap[status];
});
-export const isProcessingAtom = atom((get) => {
- const status = get(statusAtom);
- const switchingGateway = get(switchingGatewayAtom);
+export const isProcessingAtom = atom((get) => {
+ const status = get(unwrap(statusAtom));
+ const switchingGateway = get(switchGatewayAtom);
- return (
- (status !== "disconnected" && status !== "connected") || switchingGateway
- );
+ if (!status) {
+ return false;
+ }
+
+ if (switchingGateway) {
+ return true;
+ }
+ return status !== "disconnected" && status !== "connected";
});
diff --git a/gpgui/src/atoms/unwrap.ts b/gpgui/src/atoms/unwrap.ts
new file mode 100644
index 0000000..80fe85a
--- /dev/null
+++ b/gpgui/src/atoms/unwrap.ts
@@ -0,0 +1 @@
+export { unstable_unwrap as unwrap } from "jotai/utils";
\ No newline at end of file
diff --git a/gpgui/src/atoms/vpn.ts b/gpgui/src/atoms/vpn.ts
new file mode 100644
index 0000000..6e9ad05
--- /dev/null
+++ b/gpgui/src/atoms/vpn.ts
@@ -0,0 +1,30 @@
+import { atom } from "jotai";
+import vpnService from "../services/vpnService";
+import { notifyErrorAtom } from "./notification";
+import { statusAtom } from "./status";
+
+export const connectVpnAtom = atom(
+ null,
+ async (_get, set, vpnAddress: string, token: string) => {
+ try {
+ set(statusAtom, "connecting");
+ await vpnService.connect(vpnAddress, token);
+ set(statusAtom, "connected");
+ } catch (err) {
+ throw new Error("Failed to connect to VPN");
+ }
+ }
+);
+
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+export const disconnectVpnAtom = atom(null, async (get, set) => {
+ try {
+ set(statusAtom, "disconnecting");
+ await vpnService.disconnect();
+ // Sleep a short time, so that the client can receive the service's disconnected event.
+ await sleep(100);
+ } catch (err) {
+ set(statusAtom, "disconnected");
+ set(notifyErrorAtom, "Failed to disconnect from VPN");
+ }
+});
diff --git a/gpgui/src/components/AppShell/index.tsx b/gpgui/src/components/AppShell/index.tsx
new file mode 100644
index 0000000..84e181d
--- /dev/null
+++ b/gpgui/src/components/AppShell/index.tsx
@@ -0,0 +1,55 @@
+import {
+ Box,
+ CssBaseline,
+ ThemeProvider,
+ createTheme,
+ useMediaQuery,
+} from "@mui/material";
+import React, { Suspense, useMemo } from "react";
+import { createRoot } from "react-dom/client";
+import "./styles.css";
+
+function Loading() {
+ console.warn("Loading rendered");
+ return (
+
+ Loading...
+
+ );
+}
+
+function AppShell({ children }: { children: React.ReactNode }) {
+ const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
+ const theme = useMemo(
+ () =>
+ createTheme({
+ palette: {
+ mode: prefersDarkMode ? "dark" : "light",
+ },
+ }),
+ [prefersDarkMode]
+ );
+
+ return (
+
+
+
+ }>{children}
+
+
+ );
+}
+
+export function renderToRoot(children: React.ReactNode) {
+ createRoot(document.getElementById("root") as HTMLElement).render(
+ {children}
+ );
+}
diff --git a/gpgui/src/components/AppShell/styles.css b/gpgui/src/components/AppShell/styles.css
new file mode 100644
index 0000000..3d5de9e
--- /dev/null
+++ b/gpgui/src/components/AppShell/styles.css
@@ -0,0 +1,10 @@
+html,
+body,
+#root {
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ -webkit-user-select: none;
+ user-select: none;
+ cursor: default;
+}
diff --git a/gpgui/src/components/ConnectForm/PasswordAuth.tsx b/gpgui/src/components/ConnectForm/PasswordAuth.tsx
index 497640c..905e186 100644
--- a/gpgui/src/components/ConnectForm/PasswordAuth.tsx
+++ b/gpgui/src/components/ConnectForm/PasswordAuth.tsx
@@ -8,7 +8,7 @@ import {
passwordLoginAtom,
passwordPreloginAtom,
usernameAtom,
-} from "../../atoms/portal";
+} from "../../atoms/passwordLogin";
export default function PasswordAuth() {
const [visible, cancelPasswordAuth] = useAtom(cancelPasswordAuthAtom);
@@ -29,7 +29,7 @@ export default function PasswordAuth() {
function handleSubmit(e: FormEvent) {
e.preventDefault();
- passwordLogin(username, password);
+ passwordLogin();
}
return (
diff --git a/gpgui/src/components/ConnectForm/PortalForm.tsx b/gpgui/src/components/ConnectForm/PortalForm.tsx
index 807fd52..83ebec0 100644
--- a/gpgui/src/components/ConnectForm/PortalForm.tsx
+++ b/gpgui/src/components/ConnectForm/PortalForm.tsx
@@ -1,32 +1,42 @@
import { Button, TextField } from "@mui/material";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { ChangeEvent } from "react";
-import { disconnectVpnAtom } from "../../atoms/gateway";
import {
cancelConnectPortalAtom,
connectPortalAtom,
- portalAddressAtom,
- switchingGatewayAtom,
-} from "../../atoms/portal";
-import { isOnlineAtom, statusAtom } from "../../atoms/status";
+} from "../../atoms/connectPortal";
+import { switchGatewayAtom } from "../../atoms/gateway";
+import { portalAddressAtom } from "../../atoms/portal";
+import {
+ backgroundServiceStartedAtom,
+ isProcessingAtom,
+ statusAtom,
+} from "../../atoms/status";
+import { disconnectVpnAtom } from "../../atoms/vpn";
+
+function normalizePortalAddress(input: string) {
+ const address = input.trim();
+ if (/^https?:\/\//.test(address)) {
+ try {
+ return new URL(address).hostname;
+ } catch (e) {}
+ }
+ return address;
+}
export default function PortalForm() {
- const isOnline = useAtomValue(isOnlineAtom);
+ const backgroundServiceStarted = useAtomValue(backgroundServiceStartedAtom);
const [portalAddress, setPortalAddress] = useAtom(portalAddressAtom);
- const status = useAtomValue(statusAtom);
- const [processing, connectPortal] = useAtom(connectPortalAtom);
+ // Use useAtom instead of useSetAtom, otherwise the onMount of the atom is not triggered
+ const [, connectPortal] = useAtom(connectPortalAtom);
const cancelConnectPortal = useSetAtom(cancelConnectPortalAtom);
+ const isProcessing = useAtomValue(isProcessingAtom);
+ const status = useAtomValue(statusAtom);
const disconnectVpn = useSetAtom(disconnectVpnAtom);
- const switchingGateway = useAtomValue(switchingGatewayAtom);
+ const switchingGateway = useAtomValue(switchGatewayAtom);
function handlePortalAddressChange(e: ChangeEvent) {
- let host = e.target.value.trim();
- if (/^https?:\/\//.test(host)) {
- try {
- host = new URL(host).hostname;
- } catch (e) {}
- }
- setPortalAddress(host);
+ setPortalAddress(normalizePortalAddress(e.target.value));
}
function handleSubmit(e: ChangeEvent) {
@@ -47,18 +57,20 @@ export default function PortalForm() {
InputProps={{ readOnly: status !== "disconnected" || switchingGateway }}
sx={{ mb: 1 }}
/>
+
{status === "disconnected" && !switchingGateway && (
)}
- {(processing || switchingGateway) && (
+
+ {isProcessing && (