mirror of
https://github.com/yuezk/GlobalProtect-openconnect.git
synced 2025-04-02 18:31:50 -04:00
Compare commits
2 Commits
f91f0bcd17
...
bf96a88e21
Author | SHA1 | Date | |
---|---|---|---|
|
bf96a88e21 | ||
|
963b7d5407 |
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -5,6 +5,8 @@
|
||||
"clickaway",
|
||||
"clientgpversion",
|
||||
"clientos",
|
||||
"devicename",
|
||||
"distro",
|
||||
"gpcommon",
|
||||
"gpgui",
|
||||
"gpservice",
|
||||
|
27
Cargo.lock
generated
27
Cargo.lock
generated
@ -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"
|
||||
|
@ -3,17 +3,17 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>GlobalProtect</title>
|
||||
</head>
|
||||
<body data-tauri-drag-region>
|
||||
<body>
|
||||
<script>
|
||||
/* workaround to webview font size auto scaling */
|
||||
var htmlFontSize = getComputedStyle(document.documentElement).fontSize;
|
||||
var ratio = parseInt(htmlFontSize, 10) / 16;
|
||||
document.documentElement.style.fontSize = (16 / ratio) + 'px';
|
||||
document.documentElement.style.fontSize = 16 / ratio + "px";
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<div id="root" data-tauri-drag-region></div>
|
||||
<script type="module" src="/src/pages/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -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",
|
||||
|
12
gpgui/pages/settings/index.html
Normal file
12
gpgui/pages/settings/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GlobalProtect Settings</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/pages/settings.tsx"></script>
|
||||
</body>
|
||||
</html>
|
51
gpgui/pnpm-lock.yaml
generated
51
gpgui/pnpm-lock.yaml
generated
@ -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
|
||||
|
@ -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
|
||||
|
@ -133,8 +133,8 @@ fn build_window(app_handle: &AppHandle, ua: &str) -> tauri::Result<Window> {
|
||||
Window::builder(app_handle, AUTH_WINDOW_LABEL, url)
|
||||
.visible(false)
|
||||
.title("GlobalProtect Login")
|
||||
.inner_size(390.0, 694.0)
|
||||
.min_inner_size(390.0, 600.0)
|
||||
.inner_size(400.0, 647.0)
|
||||
.min_inner_size(370.0, 600.0)
|
||||
.user_agent(ua)
|
||||
.always_on_top(true)
|
||||
.focused(true)
|
||||
@ -181,7 +181,7 @@ fn setup_window(window: &Window, event_tx: mpsc::Sender<AuthEvent>) -> EventHand
|
||||
window.listen_global(AUTH_REQUEST_EVENT, move |event| {
|
||||
if let Ok(payload) = TryInto::<AuthRequest>::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<AuthEv
|
||||
if let Some(auth_event) = event_rx.recv().await {
|
||||
match auth_event {
|
||||
AuthEvent::Request(auth_request) => {
|
||||
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<Mutex<mps
|
||||
/// and send it to the event channel
|
||||
fn parse_auth_data(main_res: &WebResource, auth_event_tx: mpsc::Sender<AuthEvent>) {
|
||||
if let Some(response) = main_res.response() {
|
||||
if let Some(auth_data) = read_auth_data_from_response(&response) {
|
||||
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,8 +387,15 @@ fn parse_auth_data(main_res: &WebResource, auth_event_tx: mpsc::Sender<AuthEvent
|
||||
}
|
||||
|
||||
/// Read the authentication data from the response headers
|
||||
fn read_auth_data_from_response(response: &webkit2gtk::URIResponse) -> Option<AuthData> {
|
||||
response.http_headers().and_then(|mut headers| {
|
||||
fn read_auth_data_from_response(response: &webkit2gtk::URIResponse) -> Result<AuthData, AuthError> {
|
||||
response
|
||||
.http_headers()
|
||||
.map_or(Err(AuthError::TokenNotFound), |mut headers| {
|
||||
let saml_status: Option<String> = headers.get("saml-auth-status").map(GString::into);
|
||||
if saml_status == Some("-1".to_string()) {
|
||||
return Err(AuthError::TokenInvalid);
|
||||
}
|
||||
|
||||
let auth_data = AuthData::new(
|
||||
headers.get("saml-username").map(GString::into),
|
||||
headers.get("prelogin-cookie").map(GString::into),
|
||||
@ -387,9 +403,9 @@ fn read_auth_data_from_response(response: &webkit2gtk::URIResponse) -> Option<Au
|
||||
);
|
||||
|
||||
if auth_data.check() {
|
||||
Some(auth_data)
|
||||
Ok(auth_data)
|
||||
} else {
|
||||
None
|
||||
Err(AuthError::TokenNotFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -441,7 +457,7 @@ fn send_auth_error(auth_event_tx: mpsc::Sender<AuthEvent>, err: AuthError) {
|
||||
}
|
||||
|
||||
fn send_auth_event(auth_event_tx: mpsc::Sender<AuthEvent>, 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);
|
||||
}
|
||||
|
@ -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<Client>>) -> Result<bool, ()> {
|
||||
@ -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(())
|
||||
}
|
||||
|
@ -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<dyn std::error::Error>> {
|
||||
let client_clone = client.clone();
|
||||
let app_handle = app.handle();
|
||||
|
||||
let stores = app.state::<StoreCollection<Wry>>();
|
||||
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,23 +63,19 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
app.manage(client);
|
||||
|
||||
match std::env::var("XDG_CURRENT_DESKTOP") {
|
||||
Ok(desktop) => {
|
||||
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(())
|
||||
}
|
||||
|
||||
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");
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -1,8 +0,0 @@
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MainContent() {
|
||||
return (
|
||||
<>
|
||||
<MainMenu />
|
||||
<ConnectionStatus />
|
||||
<ConnectForm />
|
||||
<GatewaySwitcher />
|
||||
<Feedback />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const ready = useAtomValue(statusReadyAtom);
|
||||
|
||||
return (
|
||||
<Box data-tauri-drag-region padding={2} paddingBottom={0}>
|
||||
{ready ? <MainContent /> : <Loading />}
|
||||
<Notification />
|
||||
</Box>
|
||||
);
|
||||
}
|
82
gpgui/src/atoms/connectPortal.ts
Normal file
82
gpgui/src/atoms/connectPortal.ts
Normal file
@ -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);
|
||||
}
|
||||
);
|
@ -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<GatewayData[]>((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);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
35
gpgui/src/atoms/loginGateway.ts
Normal file
35
gpgui/src/atoms/loginGateway.ts
Normal file
@ -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);
|
||||
}
|
||||
);
|
100
gpgui/src/atoms/loginPortal.ts
Normal file
100
gpgui/src/atoms/loginPortal.ts
Normal file
@ -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);
|
||||
}
|
||||
);
|
@ -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);
|
||||
|
@ -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,
|
||||
|
74
gpgui/src/atoms/passwordLogin.ts
Normal file
74
gpgui/src/atoms/passwordLogin.ts
Normal file
@ -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<PasswordPrelogin>({
|
||||
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);
|
||||
}
|
||||
}
|
||||
);
|
@ -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<AppData>(
|
||||
"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<string>(
|
||||
(get) => get(unwrappedAppDataAtom).portal
|
||||
);
|
||||
|
||||
// The cached portal data for the current portal address
|
||||
export const currentPortalDataAtom = atom<PortalData>((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 updatePortalDataAtom = atom(
|
||||
null,
|
||||
async (get, set, update: PortalData) => {
|
||||
const appData = await get(appDataAtom);
|
||||
const { portals } = appData;
|
||||
const portalIndex = portals.findIndex(
|
||||
({ address }) => address === update.address
|
||||
);
|
||||
|
||||
export const portalGatewaysAtom = atom<GatewayData[]>((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);
|
||||
if (portalIndex === -1) {
|
||||
portals.push(update);
|
||||
} else {
|
||||
await set(launchPasswordAuthAtom, prelogin);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
set(cancelConnectPortalAtom);
|
||||
set(notifyErrorAtom, err);
|
||||
portals[portalIndex] = update;
|
||||
}
|
||||
|
||||
await set(appDataAtom, (appData) => ({
|
||||
...appData,
|
||||
portal: update.address,
|
||||
portals,
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
connectPortalAtom.onMount = (dispatch) => {
|
||||
return authService.onAuthError(() => {
|
||||
dispatch("retry-auth");
|
||||
});
|
||||
};
|
||||
|
||||
const loginWithCachedCredentialAtom = atom(
|
||||
null,
|
||||
async (get, set, prelogin: Prelogin) => {
|
||||
const { cachedCredential } = get(currentPortalDataAtom);
|
||||
if (!cachedCredential) {
|
||||
throw new Error("No cached credential");
|
||||
}
|
||||
await set(portalLoginAtom, cachedCredential, prelogin);
|
||||
}
|
||||
);
|
||||
|
||||
export const passwordPreloginAtom = atom<PasswordPrelogin>({
|
||||
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,
|
||||
export const clearCookiesAtom = atom(
|
||||
async (get) => {
|
||||
const { clearCookies } = await get(appDataAtom);
|
||||
return clearCookies;
|
||||
},
|
||||
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);
|
||||
}
|
||||
}
|
||||
async (_get, set, update: boolean) => {
|
||||
await set(appDataAtom, (appData) => ({
|
||||
...appData,
|
||||
clearCookies: update,
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
59
gpgui/src/atoms/samlLogin.ts
Normal file
59
gpgui/src/atoms/samlLogin.ts
Normal file
@ -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,
|
||||
});
|
||||
}
|
||||
});
|
98
gpgui/src/atoms/settings.ts
Normal file
98
gpgui/src/atoms/settings.ts
Normal file
@ -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<ClientOS>((get) => {
|
||||
const { clientOS } = get(unwrappedSettingsDataAtom);
|
||||
return clientOS;
|
||||
});
|
||||
|
||||
export const osVersionAtom = atomWithDefault<string>((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<string>((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<boolean>((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();
|
||||
}
|
||||
});
|
@ -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<boolean>
|
||||
>(() => 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<Status>("disconnected");
|
||||
// The current status of the vpn connection
|
||||
export const statusAtom = atomWithDefault<Status | Promise<Status>>(() =>
|
||||
vpnService.status()
|
||||
);
|
||||
|
||||
statusAtom.onMount = (setAtom) => vpnService.onVpnStatusChanged(setAtom);
|
||||
|
||||
const statusTextMap: Record<Status, String> = {
|
||||
const statusTextMap: Record<Status, string> = {
|
||||
disconnected: "Not Connected",
|
||||
prelogin: "Portal pre-logging in...",
|
||||
"authenticating-saml": "Authenticating...",
|
||||
@ -65,9 +65,13 @@ const statusTextMap: Record<Status, String> = {
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
export const statusTextAtom = atom((get) => {
|
||||
const status = get(statusAtom);
|
||||
const switchingGateway = get(switchingGatewayAtom);
|
||||
export const statusTextAtom = atom<string>((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<boolean>((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";
|
||||
});
|
||||
|
1
gpgui/src/atoms/unwrap.ts
Normal file
1
gpgui/src/atoms/unwrap.ts
Normal file
@ -0,0 +1 @@
|
||||
export { unstable_unwrap as unwrap } from "jotai/utils";
|
30
gpgui/src/atoms/vpn.ts
Normal file
30
gpgui/src/atoms/vpn.ts
Normal file
@ -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");
|
||||
}
|
||||
});
|
55
gpgui/src/components/AppShell/index.tsx
Normal file
55
gpgui/src/components/AppShell/index.tsx
Normal file
@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
||||
const theme = useMemo(
|
||||
() =>
|
||||
createTheme({
|
||||
palette: {
|
||||
mode: prefersDarkMode ? "dark" : "light",
|
||||
},
|
||||
}),
|
||||
[prefersDarkMode]
|
||||
);
|
||||
|
||||
return (
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Suspense fallback={<Loading />}>{children}</Suspense>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderToRoot(children: React.ReactNode) {
|
||||
createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<AppShell>{children}</AppShell>
|
||||
);
|
||||
}
|
10
gpgui/src/components/AppShell/styles.css
Normal file
10
gpgui/src/components/AppShell/styles.css
Normal file
@ -0,0 +1,10 @@
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
@ -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<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
passwordLogin(username, password);
|
||||
passwordLogin();
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -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";
|
||||
|
||||
export default function PortalForm() {
|
||||
const isOnline = useAtomValue(isOnlineAtom);
|
||||
const [portalAddress, setPortalAddress] = useAtom(portalAddressAtom);
|
||||
const status = useAtomValue(statusAtom);
|
||||
const [processing, connectPortal] = useAtom(connectPortalAtom);
|
||||
const cancelConnectPortal = useSetAtom(cancelConnectPortalAtom);
|
||||
const disconnectVpn = useSetAtom(disconnectVpnAtom);
|
||||
const switchingGateway = useAtomValue(switchingGatewayAtom);
|
||||
|
||||
function handlePortalAddressChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
let host = e.target.value.trim();
|
||||
if (/^https?:\/\//.test(host)) {
|
||||
function normalizePortalAddress(input: string) {
|
||||
const address = input.trim();
|
||||
if (/^https?:\/\//.test(address)) {
|
||||
try {
|
||||
host = new URL(host).hostname;
|
||||
return new URL(address).hostname;
|
||||
} catch (e) {}
|
||||
}
|
||||
setPortalAddress(host);
|
||||
return address;
|
||||
}
|
||||
|
||||
export default function PortalForm() {
|
||||
const backgroundServiceStarted = useAtomValue(backgroundServiceStartedAtom);
|
||||
const [portalAddress, setPortalAddress] = useAtom(portalAddressAtom);
|
||||
// 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(switchGatewayAtom);
|
||||
|
||||
function handlePortalAddressChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
setPortalAddress(normalizePortalAddress(e.target.value));
|
||||
}
|
||||
|
||||
function handleSubmit(e: ChangeEvent<HTMLFormElement>) {
|
||||
@ -47,18 +57,20 @@ export default function PortalForm() {
|
||||
InputProps={{ readOnly: status !== "disconnected" || switchingGateway }}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
|
||||
{status === "disconnected" && !switchingGateway && (
|
||||
<Button
|
||||
fullWidth
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={!isOnline}
|
||||
disabled={!backgroundServiceStarted}
|
||||
sx={{ textTransform: "none" }}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
{(processing || switchingGateway) && (
|
||||
|
||||
{isProcessing && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
@ -74,6 +86,7 @@ export default function PortalForm() {
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status === "connected" && (
|
||||
<Button
|
||||
fullWidth
|
||||
|
@ -7,13 +7,13 @@ import {
|
||||
MenuList,
|
||||
} from "@mui/material";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { gatewaySwitcherVisibleAtom } from "../../atoms/gateway";
|
||||
import {
|
||||
GatewayData,
|
||||
gatewaySwitcherVisibleAtom,
|
||||
portalGatewaysAtom,
|
||||
selectedGatewayAtom,
|
||||
switchToGatewayAtom,
|
||||
} from "../../atoms/portal";
|
||||
switchGatewayAtom,
|
||||
} from "../../atoms/gateway";
|
||||
import { GatewayData } from "../../atoms/portal";
|
||||
|
||||
export default function GatewaySwitcher() {
|
||||
const [visible, setGatewaySwitcherVisible] = useAtom(
|
||||
@ -21,7 +21,7 @@ export default function GatewaySwitcher() {
|
||||
);
|
||||
const gateways = useAtomValue(portalGatewaysAtom);
|
||||
const selectedGateway = useAtomValue(selectedGatewayAtom);
|
||||
const switchToGateway = useSetAtom(switchToGatewayAtom);
|
||||
const switchGateway = useSetAtom(switchGatewayAtom);
|
||||
|
||||
const handleClose = () => {
|
||||
setGatewaySwitcherVisible(false);
|
||||
@ -30,18 +30,24 @@ export default function GatewaySwitcher() {
|
||||
const handleMenuClick = (gateway: GatewayData) => () => {
|
||||
setGatewaySwitcherVisible(false);
|
||||
if (gateway.name !== selectedGateway) {
|
||||
switchToGateway(gateway);
|
||||
switchGateway(gateway);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer anchor="bottom" open={visible} onClose={handleClose}>
|
||||
<Drawer
|
||||
anchor="bottom"
|
||||
variant="temporary"
|
||||
open={visible}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<MenuList
|
||||
sx={{
|
||||
maxHeight: 320,
|
||||
}}
|
||||
>
|
||||
{!gateways.length && <MenuItem disabled>No gateways found</MenuItem>}
|
||||
|
||||
{gateways.map(({ name, address }) => (
|
||||
<MenuItem key={name} onClick={handleMenuClick({ name, address })}>
|
||||
{selectedGateway === name && (
|
||||
|
@ -11,7 +11,7 @@ import { alpha, styled } from "@mui/material/styles";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { openGatewaySwitcherAtom } from "../../atoms/gateway";
|
||||
import { quitAtom, resetAtom } from "../../atoms/menu";
|
||||
import { openSettingsAtom, quitAtom, resetAtom } from "../../atoms/menu";
|
||||
import { isProcessingAtom, statusAtom } from "../../atoms/status";
|
||||
|
||||
const MenuContainer = styled(Box)(({ theme }) => ({
|
||||
@ -49,6 +49,7 @@ export default function MainMenu() {
|
||||
const isProcessing = useAtomValue(isProcessingAtom);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const openGatewaySwitcher = useSetAtom(openGatewaySwitcherAtom);
|
||||
const openSettings = useSetAtom(openSettingsAtom);
|
||||
const status = useAtomValue(statusAtom);
|
||||
const reset = useSetAtom(resetAtom);
|
||||
const quit = useSetAtom(quitAtom);
|
||||
@ -57,9 +58,7 @@ export default function MainMenu() {
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
const handleClose = () => setAnchorEl(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -73,24 +72,20 @@ export default function MainMenu() {
|
||||
onClose={handleClose}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<MenuItem onClick={openGatewaySwitcher} disableRipple>
|
||||
<MenuItem onClick={openGatewaySwitcher}>
|
||||
<VpnLock />
|
||||
Switch Gateway
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleClose} disableRipple>
|
||||
<MenuItem onClick={() => openSettings()}>
|
||||
<Settings />
|
||||
Settings
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={reset}
|
||||
disableRipple
|
||||
disabled={status !== "disconnected"}
|
||||
>
|
||||
<MenuItem onClick={reset} disabled={status !== "disconnected"}>
|
||||
<LockReset />
|
||||
Reset
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={quit} disableRipple>
|
||||
<MenuItem onClick={quit}>
|
||||
<ExitToApp />
|
||||
Quit
|
||||
</MenuItem>
|
||||
|
@ -2,11 +2,13 @@ import {
|
||||
Alert,
|
||||
AlertTitle,
|
||||
Box,
|
||||
Link,
|
||||
Slide,
|
||||
SlideProps,
|
||||
Snackbar,
|
||||
} from "@mui/material";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { openSettingsAtom } from "../../atoms/menu";
|
||||
import {
|
||||
closeNotificationAtom,
|
||||
notificationConfigAtom,
|
||||
@ -22,6 +24,8 @@ export default function Notification() {
|
||||
notificationConfigAtom
|
||||
);
|
||||
const [visible, closeNotification] = useAtom(closeNotificationAtom);
|
||||
const openSettings = useSetAtom(openSettingsAtom);
|
||||
|
||||
const handleClose = () => {
|
||||
if (duration) {
|
||||
closeNotification();
|
||||
@ -51,7 +55,23 @@ export default function Notification() {
|
||||
}}
|
||||
>
|
||||
{title && <AlertTitle data-tauri-drag-region>{title}</AlertTitle>}
|
||||
{message && <Box data-tauri-drag-region>{message}</Box>}
|
||||
{message && (
|
||||
<Box data-tauri-drag-region>
|
||||
{message}
|
||||
{/* Guide the user to enable custom OpenSSL settings when encountered the SSL Error */}
|
||||
{title === "SSL Error" && (
|
||||
<Box mt={1}>
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
onClick={() => openSettings("openssl")}
|
||||
>
|
||||
Click here to configure
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
|
65
gpgui/src/components/settings/OpenSSL.tsx
Normal file
65
gpgui/src/components/settings/OpenSSL.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { TabPanel } from "@mui/lab";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { customOpenSSLAtom, opensslConfigAtom } from "../../atoms/settings";
|
||||
|
||||
export default function OpenSSL() {
|
||||
const [customOpenSSL, setCustomOpenSSL] = useAtom(customOpenSSLAtom);
|
||||
const opensslConfig = useAtomValue(opensslConfigAtom);
|
||||
|
||||
function handleCustomOpenSSLChange(
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) {
|
||||
setCustomOpenSSL(event.target.checked);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabPanel value="openssl">
|
||||
<Alert severity="info">
|
||||
You need to enable this if you encountered the "Unsafe Legacy
|
||||
Renegotiation" error.
|
||||
</Alert>
|
||||
|
||||
<Box mt={2}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={customOpenSSL}
|
||||
onChange={handleCustomOpenSSLChange}
|
||||
/>
|
||||
}
|
||||
label="Use custom OpenSSL configuration"
|
||||
/>
|
||||
|
||||
{customOpenSSL && (
|
||||
<TextField
|
||||
value={opensslConfig}
|
||||
fullWidth
|
||||
multiline
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
}}
|
||||
sx={{
|
||||
mb: 1,
|
||||
"& textarea": {
|
||||
fontFamily: "monospace",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Alert severity="warning">
|
||||
You need to restart the client after changing this setting.
|
||||
</Alert>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
);
|
||||
}
|
95
gpgui/src/components/settings/Simulation.tsx
Normal file
95
gpgui/src/components/settings/Simulation.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { TabPanel } from "@mui/lab";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormLabel,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import {
|
||||
clientOSAtom,
|
||||
clientVersionAtom,
|
||||
defaultOsVersionAtom,
|
||||
osVersionAtom,
|
||||
userAgentAtom,
|
||||
} from "../../atoms/settings";
|
||||
import {
|
||||
ClientOS,
|
||||
DEFAULT_CLIENT_VERSION,
|
||||
} from "../../services/settingsService";
|
||||
|
||||
export default function Simulation() {
|
||||
const [clientOS, setClientOS] = useAtom(clientOSAtom);
|
||||
const [osVersion, setOsVersion] = useAtom(osVersionAtom);
|
||||
const [clientVersion, setClientVersion] = useAtom(clientVersionAtom);
|
||||
const defaultOsVersion = useAtomValue(defaultOsVersionAtom);
|
||||
const userAgent = useAtomValue(userAgentAtom);
|
||||
|
||||
const handleClientOSChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setClientOS(event.target.value as ClientOS);
|
||||
};
|
||||
|
||||
return (
|
||||
<TabPanel value="simulation">
|
||||
<Alert severity="info">
|
||||
Controls the platform the client should simulate.
|
||||
</Alert>
|
||||
|
||||
<Box
|
||||
mt={2}
|
||||
sx={{
|
||||
"& > .MuiFormControl-root": {
|
||||
mb: 2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<FormLabel>Client OS</FormLabel>
|
||||
<RadioGroup row value={clientOS} onChange={handleClientOSChange}>
|
||||
<FormControlLabel value="Linux" control={<Radio />} label="Linux" />
|
||||
<FormControlLabel
|
||||
value="Windows"
|
||||
control={<Radio />}
|
||||
label="Windows"
|
||||
/>
|
||||
<FormControlLabel value="Mac" control={<Radio />} label="macOS" />
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="OS Version"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
variant="standard"
|
||||
value={osVersion}
|
||||
onChange={(event) => setOsVersion(event.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder={`Default: ${defaultOsVersion}`}
|
||||
/>
|
||||
<TextField
|
||||
label="Client Version"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
variant="standard"
|
||||
onChange={(event) => setClientVersion(event.target.value)}
|
||||
value={clientVersion}
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder={`Default: ${DEFAULT_CLIENT_VERSION}`}
|
||||
/>
|
||||
<TextField
|
||||
label="User Agent"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
variant="standard"
|
||||
value={userAgent}
|
||||
fullWidth
|
||||
size="small"
|
||||
disabled
|
||||
multiline
|
||||
/>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
);
|
||||
}
|
74
gpgui/src/components/settings/index.tsx
Normal file
74
gpgui/src/components/settings/index.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { Devices, Https } from "@mui/icons-material";
|
||||
import { TabContext, TabList } from "@mui/lab";
|
||||
import { Box, Button, DialogActions, Tab } from "@mui/material";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { saveSettingsAtom } from "../../atoms/settings";
|
||||
import settingsService, { TabValue } from "../../services/settingsService";
|
||||
import OpenSSL from "./OpenSSL";
|
||||
import Simulation from "./Simulation";
|
||||
|
||||
const activeTab = new URLSearchParams(window.location.search).get(
|
||||
"tab"
|
||||
) as TabValue;
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const [value, setValue] = useState<TabValue>(activeTab);
|
||||
const saveSettings = useSetAtom(saveSettingsAtom);
|
||||
|
||||
const handleChange = (event: React.SyntheticEvent, newValue: string) => {
|
||||
setValue(newValue as TabValue);
|
||||
};
|
||||
|
||||
const closeWindow = async () => {
|
||||
await settingsService.closeSettings();
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
await saveSettings();
|
||||
await closeWindow();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<Box sx={{ flex: 1, height: 0, display: "flex" }}>
|
||||
<TabContext value={value}>
|
||||
<TabList
|
||||
onChange={handleChange}
|
||||
orientation="vertical"
|
||||
sx={{ borderRight: 1, borderColor: "divider", flexShrink: 0 }}
|
||||
>
|
||||
<Tab
|
||||
label="Simulation"
|
||||
value="simulation"
|
||||
icon={<Devices />}
|
||||
iconPosition="start"
|
||||
sx={{ textTransform: "none" }}
|
||||
/>
|
||||
<Tab
|
||||
label="OpenSSL"
|
||||
value="openssl"
|
||||
icon={<Https />}
|
||||
iconPosition="start"
|
||||
sx={{ textTransform: "none" }}
|
||||
/>
|
||||
</TabList>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Simulation />
|
||||
<OpenSSL />
|
||||
</Box>
|
||||
</TabContext>
|
||||
</Box>
|
||||
<Box sx={{ flexShrink: 0, borderTop: 1, borderColor: "divider" }}>
|
||||
<DialogActions>
|
||||
<Button sx={{ textTransform: "none" }} onClick={closeWindow}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button sx={{ textTransform: "none" }} onClick={save}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
import {
|
||||
CssBaseline,
|
||||
ThemeProvider,
|
||||
createTheme,
|
||||
useMediaQuery,
|
||||
} from "@mui/material";
|
||||
import React, { useMemo } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
function Root() {
|
||||
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
||||
const theme = useMemo(
|
||||
() =>
|
||||
createTheme({
|
||||
palette: {
|
||||
mode: prefersDarkMode ? "dark" : "light",
|
||||
},
|
||||
}),
|
||||
[prefersDarkMode]
|
||||
);
|
||||
|
||||
return (
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<Root />
|
||||
);
|
23
gpgui/src/pages/main.tsx
Normal file
23
gpgui/src/pages/main.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Box } from "@mui/material";
|
||||
import { renderToRoot } from "../components/AppShell";
|
||||
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";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Box data-tauri-drag-region padding={2} paddingBottom={0}>
|
||||
<MainMenu />
|
||||
<ConnectionStatus />
|
||||
<ConnectForm />
|
||||
<GatewaySwitcher />
|
||||
<Feedback />
|
||||
<Notification />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
renderToRoot(<App />);
|
4
gpgui/src/pages/settings.tsx
Normal file
4
gpgui/src/pages/settings.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
import { renderToRoot } from "../components/AppShell";
|
||||
import SettingsPanel from "../components/settings";
|
||||
|
||||
renderToRoot(<SettingsPanel />);
|
@ -1,4 +1,5 @@
|
||||
import { Body, ResponseType, fetch } from "@tauri-apps/api/http";
|
||||
import ErrorWithTitle from "../utils/ErrorWithTitle";
|
||||
import { parseXml } from "../utils/parseXml";
|
||||
import { Gateway } from "./types";
|
||||
|
||||
@ -59,12 +60,21 @@ class PortalService {
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to prelogin: Network error", err);
|
||||
throw new Error("Failed to prelogin: Network error");
|
||||
if (
|
||||
typeof err === "string" &&
|
||||
err.includes("unsafe legacy renegotiation")
|
||||
) {
|
||||
throw new ErrorWithTitle(
|
||||
"SSL Error",
|
||||
"Unsafe Legacy Renegotiation disabled"
|
||||
);
|
||||
}
|
||||
console.error("prelogin error", err);
|
||||
throw new Error("Network error");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to prelogin: ${response.status}`);
|
||||
throw new Error(`Status code: ${response.status}`);
|
||||
}
|
||||
return this.parsePrelogin(response.data);
|
||||
}
|
||||
@ -186,12 +196,12 @@ class PortalService {
|
||||
};
|
||||
}
|
||||
|
||||
preferredGateway(
|
||||
chooseGateway(
|
||||
gateways: Gateway[],
|
||||
{ region, previousGateway }: { region: string; previousGateway?: string }
|
||||
{ region, preferredGateway }: { region: string; preferredGateway?: string }
|
||||
) {
|
||||
for (const gateway of gateways) {
|
||||
if (gateway.name === previousGateway) {
|
||||
if (gateway.name === preferredGateway) {
|
||||
return gateway;
|
||||
}
|
||||
}
|
||||
@ -207,7 +217,7 @@ class PortalService {
|
||||
return defaultGateway;
|
||||
}
|
||||
|
||||
let preferredGateway = defaultGateway;
|
||||
let finalGateway = defaultGateway;
|
||||
let currentPriority = Infinity;
|
||||
for (const gateway of gateways) {
|
||||
const priorityRule = gateway.priorityRules.find(
|
||||
@ -215,11 +225,11 @@ class PortalService {
|
||||
);
|
||||
|
||||
if (priorityRule && priorityRule.priority < currentPriority) {
|
||||
preferredGateway = gateway;
|
||||
finalGateway = gateway;
|
||||
currentPriority = priorityRule.priority;
|
||||
}
|
||||
}
|
||||
return preferredGateway;
|
||||
return finalGateway;
|
||||
}
|
||||
}
|
||||
|
||||
|
139
gpgui/src/services/settingsService.ts
Normal file
139
gpgui/src/services/settingsService.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import { UserAttentionType, WebviewWindow } from "@tauri-apps/api/window";
|
||||
import invokeCommand from "../utils/invokeCommand";
|
||||
import { appStore } from "./storeService";
|
||||
|
||||
export type TabValue = "simulation" | "openssl";
|
||||
const SETTINGS_WINDOW_LABEL = "settings";
|
||||
|
||||
async function openSettings(options?: { tab?: TabValue }) {
|
||||
const tab = options?.tab || "simulation";
|
||||
const webview = WebviewWindow.getByLabel(SETTINGS_WINDOW_LABEL);
|
||||
|
||||
if (webview) {
|
||||
await webview.requestUserAttention(UserAttentionType.Critical);
|
||||
return;
|
||||
}
|
||||
|
||||
new WebviewWindow(SETTINGS_WINDOW_LABEL, {
|
||||
url: `pages/settings/index.html?tab=${tab}`,
|
||||
title: "GlobalProtect Settings",
|
||||
width: 650,
|
||||
height: 480,
|
||||
center: true,
|
||||
resizable: false,
|
||||
fileDropEnabled: false,
|
||||
focus: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function closeSettings() {
|
||||
const webview = WebviewWindow.getByLabel(SETTINGS_WINDOW_LABEL);
|
||||
if (webview) {
|
||||
await webview.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentOsVersion() {
|
||||
return invokeCommand<string>("os_version");
|
||||
}
|
||||
|
||||
export type ClientOS = "Linux" | "Windows" | "Mac";
|
||||
|
||||
export type SettingsData = {
|
||||
clientOS: ClientOS;
|
||||
osVersion: string;
|
||||
clientVersion: string;
|
||||
customOpenSSL: boolean;
|
||||
};
|
||||
|
||||
type SimulationSettings = {
|
||||
userAgent: string;
|
||||
clientOS: ClientOS;
|
||||
osVersion: string;
|
||||
clientVersion: string;
|
||||
};
|
||||
|
||||
export const SETTINGS_DATA = "SETTINGS_DATA";
|
||||
|
||||
const UA_PREFIX = "PAN GlobalProtect";
|
||||
const DEFAULT_CLIENT_OS: ClientOS = "Linux";
|
||||
const DEFAULT_OS_VERSION_MACOS = "Apple Mac OS X 13.4.0";
|
||||
const DEFAULT_OS_VERSION_WINDOWS = "Microsoft Windows 11 Pro , 64-bit";
|
||||
export const DEFAULT_CLIENT_VERSION = "6.0.1-19";
|
||||
|
||||
export const DEFAULT_SETTINGS_DATA: SettingsData = {
|
||||
clientOS: DEFAULT_CLIENT_OS,
|
||||
osVersion: "",
|
||||
clientVersion: "",
|
||||
customOpenSSL: false,
|
||||
};
|
||||
|
||||
async function getSimulationSettings(): Promise<SimulationSettings> {
|
||||
const { clientOS, osVersion, clientVersion } =
|
||||
(await appStore.get<SettingsData>(SETTINGS_DATA)) || DEFAULT_SETTINGS_DATA;
|
||||
const currentOsVersion = await getCurrentOsVersion();
|
||||
|
||||
return {
|
||||
userAgent: buildUserAgent(
|
||||
clientOS,
|
||||
osVersion,
|
||||
currentOsVersion,
|
||||
clientVersion
|
||||
),
|
||||
clientOS,
|
||||
osVersion,
|
||||
clientVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function buildUserAgent(
|
||||
clientOS: ClientOS,
|
||||
osVersion: string,
|
||||
currentOsVersion: string,
|
||||
clientVersion: string
|
||||
) {
|
||||
osVersion = determineOsVersion(clientOS, osVersion, currentOsVersion);
|
||||
clientVersion = clientVersion || DEFAULT_CLIENT_VERSION;
|
||||
|
||||
const suffix = ` (${clientOS === "Linux" ? "Linux " : ""}${osVersion})`;
|
||||
return `${UA_PREFIX}/${clientVersion}${suffix}`;
|
||||
}
|
||||
|
||||
function determineOsVersion(
|
||||
clientOS: ClientOS,
|
||||
osVersion: string,
|
||||
currentOsVersion: string
|
||||
) {
|
||||
if (osVersion.trim()) {
|
||||
return osVersion;
|
||||
}
|
||||
|
||||
if (clientOS === "Linux") {
|
||||
return currentOsVersion;
|
||||
}
|
||||
|
||||
if (clientOS === "Windows") {
|
||||
return DEFAULT_OS_VERSION_WINDOWS;
|
||||
}
|
||||
|
||||
return DEFAULT_OS_VERSION_MACOS;
|
||||
}
|
||||
|
||||
async function getOpenSSLConfig() {
|
||||
return invokeCommand("openssl_config");
|
||||
}
|
||||
|
||||
async function updateOpenSSLConfig() {
|
||||
return invokeCommand("update_openssl_config");
|
||||
}
|
||||
|
||||
export default {
|
||||
openSettings,
|
||||
closeSettings,
|
||||
getCurrentOsVersion,
|
||||
getSimulationSettings,
|
||||
buildUserAgent,
|
||||
determineOsVersion,
|
||||
getOpenSSLConfig,
|
||||
updateOpenSSLConfig,
|
||||
};
|
45
gpgui/src/services/storeService.ts
Normal file
45
gpgui/src/services/storeService.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { atom } from "jotai";
|
||||
import { RESET, atomWithDefault } from "jotai/utils";
|
||||
import { Store } from "tauri-plugin-store-api";
|
||||
|
||||
type SetStateActionWithReset<T> =
|
||||
| T
|
||||
| typeof RESET
|
||||
| ((prev: T) => T | typeof RESET);
|
||||
|
||||
export const appStore = new Store(".settings.dat");
|
||||
|
||||
export function atomWithTauriStorage<T>(key: string, initialValue: T) {
|
||||
const baseAtom = atomWithDefault<T | Promise<T>>(async () => {
|
||||
const storedValue = await appStore.get<T>(key);
|
||||
if (!storedValue) {
|
||||
return initialValue;
|
||||
}
|
||||
return storedValue;
|
||||
});
|
||||
|
||||
const anAtom = atom(
|
||||
(get) => get(baseAtom),
|
||||
async (get, set, update: SetStateActionWithReset<T>) => {
|
||||
const value = await get(baseAtom);
|
||||
let newValue: T | typeof RESET;
|
||||
if (typeof update === "function") {
|
||||
newValue = (update as (prev: T) => T | typeof RESET)(value);
|
||||
} else {
|
||||
newValue = update as T | typeof RESET;
|
||||
}
|
||||
|
||||
if (newValue === RESET) {
|
||||
set(baseAtom, initialValue);
|
||||
await appStore.set(key, initialValue);
|
||||
} else {
|
||||
set(baseAtom, newValue);
|
||||
await appStore.set(key, newValue);
|
||||
}
|
||||
|
||||
await appStore.save();
|
||||
}
|
||||
);
|
||||
|
||||
return anAtom;
|
||||
}
|
7
gpgui/src/utils/ErrorWithTitle.ts
Normal file
7
gpgui/src/utils/ErrorWithTitle.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export default class ErrorWithTitle extends Error {
|
||||
public title: string;
|
||||
constructor(title: string, message: string) {
|
||||
super(message);
|
||||
this.title = title;
|
||||
}
|
||||
}
|
@ -1,7 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import { resolve } from "path";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, "index.html"),
|
||||
"pages/settings": resolve(__dirname, "pages/settings/index.html"),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user