mirror of
https://github.com/yuezk/GlobalProtect-openconnect.git
synced 2025-05-20 07:26:58 -04:00
refactor: improve workflow
This commit is contained in:
@@ -16,7 +16,7 @@ tauri-build = { version = "1.3", features = [] }
|
||||
|
||||
[dependencies]
|
||||
gpcommon = { path = "../../gpcommon" }
|
||||
tauri = { version = "1.3", features = ["http-all", "window-all", "window-data-url"] }
|
||||
tauri = { version = "1.3", features = ["http-all", "process-exit", "shell-open", "window-all", "window-data-url"] }
|
||||
tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1", features = [
|
||||
"colored",
|
||||
] }
|
||||
|
@@ -4,9 +4,9 @@ use regex::Regex;
|
||||
use serde::de::Error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tauri::EventHandler;
|
||||
use tauri::{AppHandle, Manager, Window, WindowEvent::CloseRequested, WindowUrl};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tauri::{AppHandle, Manager, Window, WindowUrl};
|
||||
use tauri::{EventHandler, WindowEvent};
|
||||
use tokio::sync::{mpsc, oneshot, Mutex};
|
||||
use tokio::time::timeout;
|
||||
use veil::Redact;
|
||||
use webkit2gtk::gio::Cancellable;
|
||||
@@ -100,7 +100,6 @@ enum AuthEvent {
|
||||
Request(AuthRequest),
|
||||
Success(AuthData),
|
||||
Error(AuthError),
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub(crate) struct SamlLoginParams {
|
||||
@@ -113,10 +112,10 @@ pub(crate) struct SamlLoginParams {
|
||||
pub(crate) async fn saml_login(params: SamlLoginParams) -> tauri::Result<Option<AuthData>> {
|
||||
info!("Starting SAML login");
|
||||
|
||||
let (event_tx, event_rx) = mpsc::channel::<AuthEvent>(8);
|
||||
let (auth_event_tx, auth_event_rx) = mpsc::channel::<AuthEvent>(1);
|
||||
let window = build_window(¶ms.app_handle, ¶ms.user_agent)?;
|
||||
setup_webview(&window, event_tx.clone())?;
|
||||
let handler = setup_window(&window, event_tx);
|
||||
setup_webview(&window, auth_event_tx.clone())?;
|
||||
let handler = setup_window(&window, auth_event_tx);
|
||||
|
||||
if params.clear_cookies {
|
||||
if let Err(err) = clear_webview_cookies(&window).await {
|
||||
@@ -124,7 +123,7 @@ pub(crate) async fn saml_login(params: SamlLoginParams) -> tauri::Result<Option<
|
||||
}
|
||||
}
|
||||
|
||||
let result = process(&window, params.auth_request, event_rx).await;
|
||||
let result = process(&window, params.auth_request, auth_event_rx).await;
|
||||
window.unlisten(handler);
|
||||
result
|
||||
}
|
||||
@@ -134,6 +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)
|
||||
.user_agent(ua)
|
||||
.always_on_top(true)
|
||||
.focused(true)
|
||||
@@ -142,10 +143,10 @@ fn build_window(app_handle: &AppHandle, ua: &str) -> tauri::Result<Window> {
|
||||
}
|
||||
|
||||
// Setup webview events
|
||||
fn setup_webview(window: &Window, event_tx: mpsc::Sender<AuthEvent>) -> tauri::Result<()> {
|
||||
fn setup_webview(window: &Window, auth_event_tx: mpsc::Sender<AuthEvent>) -> tauri::Result<()> {
|
||||
window.with_webview(move |wv| {
|
||||
let wv = wv.inner();
|
||||
let event_tx_clone = event_tx.clone();
|
||||
let auth_event_tx_clone = auth_event_tx.clone();
|
||||
|
||||
wv.connect_load_changed(move |wv, event| {
|
||||
if LoadEvent::Finished != event {
|
||||
@@ -156,13 +157,13 @@ fn setup_webview(window: &Window, event_tx: mpsc::Sender<AuthEvent>) -> tauri::R
|
||||
// Empty URI indicates that an error occurred
|
||||
if uri.is_empty() {
|
||||
warn!("Empty URI loaded, retrying");
|
||||
send_auth_error(event_tx_clone.clone(), AuthError::TokenInvalid);
|
||||
send_auth_error(auth_event_tx_clone.clone(), AuthError::TokenInvalid);
|
||||
return;
|
||||
}
|
||||
info!("Loaded URI: {}", redact_url(&uri));
|
||||
|
||||
if let Some(main_res) = wv.main_resource() {
|
||||
parse_auth_data(&main_res, event_tx_clone.clone());
|
||||
parse_auth_data(&main_res, auth_event_tx_clone.clone());
|
||||
} else {
|
||||
warn!("No main_resource");
|
||||
}
|
||||
@@ -170,20 +171,13 @@ fn setup_webview(window: &Window, event_tx: mpsc::Sender<AuthEvent>) -> tauri::R
|
||||
|
||||
wv.connect_load_failed(move |_wv, event, _uri, err| {
|
||||
warn!("Load failed: {:?}, {:?}", event, err);
|
||||
send_auth_error(event_tx.clone(), AuthError::TokenInvalid);
|
||||
send_auth_error(auth_event_tx.clone(), AuthError::TokenInvalid);
|
||||
false
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn setup_window(window: &Window, event_tx: mpsc::Sender<AuthEvent>) -> EventHandler {
|
||||
let event_tx_clone = event_tx.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let CloseRequested { .. } = event {
|
||||
send_auth_event(event_tx_clone.clone(), AuthEvent::Cancel);
|
||||
}
|
||||
});
|
||||
|
||||
window.listen_global(AUTH_REQUEST_EVENT, move |event| {
|
||||
if let Ok(payload) = TryInto::<AuthRequest>::try_into(event.payload()) {
|
||||
let event_tx = event_tx.clone();
|
||||
@@ -204,7 +198,7 @@ async fn process(
|
||||
process_request(window, auth_request)?;
|
||||
|
||||
let handle = tokio::spawn(show_window_after_timeout(window.clone()));
|
||||
let auth_data = process_auth_event(&window, event_rx).await;
|
||||
let auth_data = monitor_events(&window, event_rx).await;
|
||||
|
||||
if !handle.is_finished() {
|
||||
handle.abort();
|
||||
@@ -239,20 +233,32 @@ async fn show_window_after_timeout(window: Window) {
|
||||
show_window(&window);
|
||||
}
|
||||
|
||||
async fn process_auth_event(
|
||||
window: &Window,
|
||||
mut event_rx: mpsc::Receiver<AuthEvent>,
|
||||
) -> Option<AuthData> {
|
||||
info!("Processing auth event...");
|
||||
async fn monitor_events(window: &Window, event_rx: mpsc::Receiver<AuthEvent>) -> Option<AuthData> {
|
||||
tokio::select! {
|
||||
auth_data = monitor_auth_event(window, event_rx) => Some(auth_data),
|
||||
_ = monitor_window_close_event(window) => {
|
||||
warn!("Auth window closed without auth data");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn monitor_auth_event(window: &Window, mut event_rx: mpsc::Receiver<AuthEvent>) -> AuthData {
|
||||
info!("Monitoring auth events");
|
||||
|
||||
let (cancel_timeout_tx, cancel_timeout_rx) = mpsc::channel::<()>(1);
|
||||
let cancel_timeout_rx = Arc::new(Mutex::new(cancel_timeout_rx));
|
||||
let mut attempt_times = 1;
|
||||
|
||||
loop {
|
||||
if let Some(auth_event) = event_rx.recv().await {
|
||||
match auth_event {
|
||||
AuthEvent::Request(auth_request) => {
|
||||
info!("Got auth request from auth-request event, processing");
|
||||
attempt_times = attempt_times + 1;
|
||||
info!(
|
||||
"Got auth request from auth-request event, attempt #{}",
|
||||
attempt_times
|
||||
);
|
||||
if let Err(err) = process_request(&window, auth_request) {
|
||||
warn!("Error processing auth request: {}", err);
|
||||
}
|
||||
@@ -260,20 +266,26 @@ async fn process_auth_event(
|
||||
AuthEvent::Success(auth_data) => {
|
||||
info!("Got auth data successfully, closing window");
|
||||
close_window(window);
|
||||
return Some(auth_data);
|
||||
}
|
||||
AuthEvent::Cancel => {
|
||||
info!("User cancelled the authentication process, closing window");
|
||||
return None;
|
||||
return auth_data;
|
||||
}
|
||||
AuthEvent::Error(AuthError::TokenInvalid) => {
|
||||
// Found the invalid token, means that user is authenticated, keep retrying and no need to show the window
|
||||
warn!("Found invalid auth data, retrying");
|
||||
if let Err(err) = cancel_timeout_tx.send(()).await {
|
||||
warn!("Error sending cancel timeout: {}", err);
|
||||
warn!(
|
||||
"Attempt #{} failed, found invalid token, retrying",
|
||||
attempt_times
|
||||
);
|
||||
|
||||
// If the cancel timeout is locked, it means that the window is about to show, so we need to cancel it
|
||||
if cancel_timeout_rx.try_lock().is_err() {
|
||||
if let Err(err) = cancel_timeout_tx.try_send(()) {
|
||||
warn!("Error sending cancel timeout: {}", err);
|
||||
}
|
||||
} else {
|
||||
info!("Window is not about to show, skipping cancel timeout");
|
||||
}
|
||||
|
||||
// Send the error event to the outside, so that we can retry it when receiving the auth-request event
|
||||
if let Err(err) = window.emit_all(AUTH_ERROR_EVENT, ()) {
|
||||
if let Err(err) = window.emit_all(AUTH_ERROR_EVENT, attempt_times) {
|
||||
warn!("Error emitting auth-error event: {:?}", err);
|
||||
}
|
||||
}
|
||||
@@ -296,6 +308,26 @@ async fn process_auth_event(
|
||||
}
|
||||
}
|
||||
|
||||
async fn monitor_window_close_event(window: &Window) {
|
||||
let (close_tx, close_rx) = oneshot::channel();
|
||||
let close_tx = Arc::new(Mutex::new(Some(close_tx)));
|
||||
|
||||
window.on_window_event(move |event| {
|
||||
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(()) {
|
||||
println!("Error sending close event");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Err(err) = close_rx.await {
|
||||
warn!("Error receiving close event: {}", err);
|
||||
}
|
||||
}
|
||||
/// Tokens not found means that the page might need the user interaction to login,
|
||||
/// we should show the window after a short timeout, it will be cancelled if the
|
||||
/// token is found in the response, no matter it's valid or not.
|
||||
@@ -309,36 +341,36 @@ async fn handle_token_not_found(window: Window, cancel_timeout_rx: Arc<Mutex<mps
|
||||
);
|
||||
show_window(&window);
|
||||
} else {
|
||||
info!("Showing window timeout cancelled");
|
||||
info!("The scheduled show window task is cancelled");
|
||||
}
|
||||
} else {
|
||||
debug!("Window will be shown by another task, skipping");
|
||||
info!("The show window task has been already been scheduled, skipping");
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the authentication data from the response headers or HTML content
|
||||
/// and send it to the event channel
|
||||
fn parse_auth_data(main_res: &WebResource, event_tx: mpsc::Sender<AuthEvent>) {
|
||||
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) {
|
||||
debug!("Got auth data from HTTP headers: {:?}", auth_data);
|
||||
send_auth_data(event_tx, auth_data);
|
||||
send_auth_data(auth_event_tx, auth_data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let event_tx = event_tx.clone();
|
||||
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);
|
||||
match read_auth_data_from_html(&html) {
|
||||
Ok(auth_data) => {
|
||||
debug!("Got auth data from HTML: {:?}", auth_data);
|
||||
send_auth_data(event_tx, auth_data);
|
||||
send_auth_data(auth_event_tx, auth_data);
|
||||
}
|
||||
Err(err) => {
|
||||
debug!("Error reading auth data from HTML: {:?}", err);
|
||||
send_auth_error(event_tx, err);
|
||||
send_auth_error(auth_event_tx, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,17 +432,17 @@ fn parse_xml_tag(html: &str, tag: &str) -> Option<String> {
|
||||
.map(|m| m.as_str().to_string())
|
||||
}
|
||||
|
||||
fn send_auth_data(event_tx: mpsc::Sender<AuthEvent>, auth_data: AuthData) {
|
||||
send_auth_event(event_tx, AuthEvent::Success(auth_data));
|
||||
fn send_auth_data(auth_event_tx: mpsc::Sender<AuthEvent>, auth_data: AuthData) {
|
||||
send_auth_event(auth_event_tx, AuthEvent::Success(auth_data));
|
||||
}
|
||||
|
||||
fn send_auth_error(event_tx: mpsc::Sender<AuthEvent>, err: AuthError) {
|
||||
send_auth_event(event_tx, AuthEvent::Error(err));
|
||||
fn send_auth_error(auth_event_tx: mpsc::Sender<AuthEvent>, err: AuthError) {
|
||||
send_auth_event(auth_event_tx, AuthEvent::Error(err));
|
||||
}
|
||||
|
||||
fn send_auth_event(event_tx: mpsc::Sender<AuthEvent>, auth_event: AuthEvent) {
|
||||
fn send_auth_event(auth_event_tx: mpsc::Sender<AuthEvent>, auth_event: AuthEvent) {
|
||||
let _ = tauri::async_runtime::spawn(async move {
|
||||
if let Err(err) = event_tx.send(auth_event).await {
|
||||
if let Err(err) = auth_event_tx.send(auth_event).await {
|
||||
warn!("Error sending event: {}", err);
|
||||
}
|
||||
});
|
||||
|
@@ -3,6 +3,11 @@ use gpcommon::{Client, ServerApiError, VpnStatus};
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn service_online<'a>(client: State<'a, Arc<Client>>) -> Result<bool, ()> {
|
||||
Ok(client.is_online().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn vpn_status<'a>(
|
||||
client: State<'a, Arc<Client>>,
|
||||
@@ -30,10 +35,10 @@ pub(crate) async fn vpn_disconnect<'a>(
|
||||
pub(crate) async fn saml_login(
|
||||
binding: SamlBinding,
|
||||
request: String,
|
||||
clear_cookies: bool,
|
||||
app_handle: AppHandle,
|
||||
) -> tauri::Result<Option<AuthData>> {
|
||||
let user_agent = String::from("PAN GlobalProtect");
|
||||
let clear_cookies = false;
|
||||
let params = SamlLoginParams {
|
||||
auth_request: AuthRequest::new(binding, request),
|
||||
user_agent,
|
||||
|
@@ -4,7 +4,7 @@
|
||||
)]
|
||||
|
||||
use env_logger::Env;
|
||||
use gpcommon::{Client, VpnStatus};
|
||||
use gpcommon::{Client, ClientStatus, VpnStatus};
|
||||
use log::warn;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
@@ -16,7 +16,7 @@ mod commands;
|
||||
mod utils;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct StatusPayload {
|
||||
struct VpnStatusPayload {
|
||||
status: VpnStatus,
|
||||
}
|
||||
|
||||
@@ -26,10 +26,17 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let app_handle = app.handle();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _ = client_clone.subscribe_status(move |status| {
|
||||
let payload = StatusPayload { status };
|
||||
if let Err(err) = app_handle.emit_all("vpn-status-received", payload) {
|
||||
warn!("Error emitting event: {}", err);
|
||||
let _ = 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) {
|
||||
warn!("Error emitting event: {}", err);
|
||||
}
|
||||
}
|
||||
ClientStatus::Service(is_online) => {
|
||||
if let Err(err) = app_handle.emit_all("service-status-changed", is_online) {
|
||||
warn!("Error emitting event: {}", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -56,6 +63,7 @@ fn main() {
|
||||
)
|
||||
.setup(setup)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::service_online,
|
||||
commands::vpn_status,
|
||||
commands::vpn_connect,
|
||||
commands::vpn_disconnect,
|
||||
|
@@ -12,6 +12,9 @@
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"http": {
|
||||
"all": true,
|
||||
"request": true,
|
||||
@@ -19,6 +22,9 @@
|
||||
},
|
||||
"window": {
|
||||
"all": true
|
||||
},
|
||||
"process": {
|
||||
"exit": true
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
@@ -1,10 +1,8 @@
|
||||
html {
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
@@ -1,15 +1,48 @@
|
||||
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";
|
||||
|
||||
export default function App() {
|
||||
function Loading() {
|
||||
return (
|
||||
<Box padding={2} paddingTop={3}>
|
||||
<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 padding={2} paddingBottom={0}>
|
||||
{ready ? <MainContent /> : <Loading />}
|
||||
<Notification />
|
||||
</Box>
|
||||
);
|
||||
|
@@ -22,7 +22,8 @@ export const gatewayLoginAtom = atom(
|
||||
throw new Error("Failed to login to gateway");
|
||||
}
|
||||
|
||||
if (!get(isProcessingAtom)) {
|
||||
const isProcessing = get(isProcessingAtom);
|
||||
if (!isProcessing) {
|
||||
console.info("Request cancelled");
|
||||
return;
|
||||
}
|
||||
@@ -44,13 +45,21 @@ const connectVpnAtom = atom(
|
||||
}
|
||||
);
|
||||
|
||||
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();
|
||||
set(statusAtom, "disconnected");
|
||||
// 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 gatewaySwitcherVisibleAtom = atom(false);
|
||||
export const openGatewaySwitcherAtom = atom(null, (get, set) => {
|
||||
set(gatewaySwitcherVisibleAtom, true);
|
||||
});
|
||||
|
20
gpgui/src/atoms/menu.ts
Normal file
20
gpgui/src/atoms/menu.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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 { statusAtom } from "./status";
|
||||
|
||||
export const resetAtom = atom(null, (_get, set) => {
|
||||
set(appDataStorageAtom, RESET);
|
||||
set(portalAddressAtom, "");
|
||||
});
|
||||
|
||||
export const quitAtom = atom(null, async (get, set) => {
|
||||
const status = get(statusAtom);
|
||||
|
||||
if (status === "connected") {
|
||||
await set(disconnectVpnAtom);
|
||||
}
|
||||
await exit();
|
||||
});
|
@@ -3,11 +3,19 @@ import { atom } from "jotai";
|
||||
|
||||
export type Severity = AlertColor;
|
||||
|
||||
type NotificationConfig = {
|
||||
title: string;
|
||||
message: string;
|
||||
severity: Severity;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
const notificationVisibleAtom = atom(false);
|
||||
export const notificationConfigAtom = atom({
|
||||
export const notificationConfigAtom = atom<NotificationConfig>({
|
||||
title: "",
|
||||
message: "",
|
||||
severity: "info" as Severity,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
export const closeNotificationAtom = atom(
|
||||
@@ -17,20 +25,37 @@ export const closeNotificationAtom = atom(
|
||||
}
|
||||
);
|
||||
|
||||
export const notifyErrorAtom = atom(null, (_get, set, err: unknown) => {
|
||||
let msg: string;
|
||||
if (err instanceof Error) {
|
||||
msg = err.message;
|
||||
} else if (typeof err === "string") {
|
||||
msg = err;
|
||||
} else {
|
||||
msg = "Unknown error";
|
||||
}
|
||||
export const notifyErrorAtom = atom(
|
||||
null,
|
||||
(_get, set, err: unknown, duration: number = 5000) => {
|
||||
let msg: string;
|
||||
if (err instanceof Error) {
|
||||
msg = err.message;
|
||||
} else if (typeof err === "string") {
|
||||
msg = err;
|
||||
} else {
|
||||
msg = "Unknown error";
|
||||
}
|
||||
|
||||
set(notificationVisibleAtom, true);
|
||||
set(notificationConfigAtom, {
|
||||
title: "Error",
|
||||
message: msg,
|
||||
severity: "error",
|
||||
});
|
||||
});
|
||||
set(notificationVisibleAtom, true);
|
||||
set(notificationConfigAtom, {
|
||||
title: "Error",
|
||||
message: msg,
|
||||
severity: "error",
|
||||
duration: duration <= 0 ? undefined : duration,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const notifySuccessAtom = atom(
|
||||
null,
|
||||
(_get, set, msg: string, duration: number = 5000) => {
|
||||
set(notificationVisibleAtom, true);
|
||||
set(notificationConfigAtom, {
|
||||
title: "Success",
|
||||
message: msg,
|
||||
severity: "success",
|
||||
duration: duration <= 0 ? undefined : duration,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
@@ -1,42 +1,119 @@
|
||||
import { atom } from "jotai";
|
||||
import { focusAtom } from "jotai-optics";
|
||||
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 { gatewayLoginAtom } from "./gateway";
|
||||
import { disconnectVpnAtom, gatewayLoginAtom } from "./gateway";
|
||||
import { notifyErrorAtom } from "./notification";
|
||||
import { isProcessingAtom, statusAtom } from "./status";
|
||||
|
||||
type GatewayData = {
|
||||
export type GatewayData = {
|
||||
name: string;
|
||||
address: string;
|
||||
};
|
||||
|
||||
type Credential = {
|
||||
user: string;
|
||||
passwd: string;
|
||||
userAuthCookie: string;
|
||||
prelogonUserAuthCookie: string;
|
||||
type CachedPortalCredential = Omit<PortalCredential, "prelogin-cookie">;
|
||||
|
||||
type PortalData = {
|
||||
address: string;
|
||||
gateways: GatewayData[];
|
||||
cachedCredential?: CachedPortalCredential;
|
||||
selectedGateway?: string;
|
||||
};
|
||||
|
||||
type AppData = {
|
||||
portal: string;
|
||||
gateways: GatewayData[];
|
||||
selectedGateway: string;
|
||||
credentials: Record<string, Credential>;
|
||||
portals: PortalData[];
|
||||
clearCookies: boolean;
|
||||
};
|
||||
|
||||
const appAtom = atom<AppData>({
|
||||
type AppDataUpdate =
|
||||
| {
|
||||
type: "PORTAL";
|
||||
payload: PortalData;
|
||||
}
|
||||
| {
|
||||
type: "SELECTED_GATEWAY";
|
||||
payload: string;
|
||||
};
|
||||
|
||||
const defaultAppData: AppData = {
|
||||
portal: "",
|
||||
gateways: [],
|
||||
selectedGateway: "",
|
||||
credentials: {},
|
||||
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 portalAtom = focusAtom(appAtom, (optic) => optic.prop("portal"));
|
||||
export const portalAddressAtom = atomWithDefault(
|
||||
(get) => get(appDataImmerAtom).portal
|
||||
);
|
||||
|
||||
export const currentPortalDataAtom = atom<PortalData>((get) => {
|
||||
const portalAddress = get(portalAddressAtom);
|
||||
const { portals } = get(appDataImmerAtom);
|
||||
const portalData = portals.find(({ address }) => address === portalAddress);
|
||||
|
||||
return portalData || { address: portalAddress, gateways: [] };
|
||||
});
|
||||
|
||||
const clearCookiesAtom = atom(
|
||||
(get) => get(appDataImmerAtom).clearCookies,
|
||||
(_get, set, update: boolean) => {
|
||||
set(appDataImmerAtom, (draft) => {
|
||||
draft.clearCookies = update;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const portalGatewaysAtom = atom<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") => {
|
||||
@@ -46,7 +123,7 @@ export const connectPortalAtom = atom(
|
||||
return;
|
||||
}
|
||||
|
||||
const portal = get(portalAtom);
|
||||
const portal = get(portalAddressAtom);
|
||||
if (!portal) {
|
||||
set(notifyErrorAtom, "Portal is empty");
|
||||
return;
|
||||
@@ -55,15 +132,20 @@ export const connectPortalAtom = atom(
|
||||
try {
|
||||
set(statusAtom, "prelogin");
|
||||
const prelogin = await portalService.prelogin(portal);
|
||||
if (!get(isProcessingAtom)) {
|
||||
const isProcessing = get(isProcessingAtom);
|
||||
if (!isProcessing) {
|
||||
console.info("Request cancelled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (prelogin.isSamlAuth) {
|
||||
await set(launchSamlAuthAtom, prelogin);
|
||||
} else {
|
||||
await set(launchPasswordAuthAtom, prelogin);
|
||||
try {
|
||||
await set(loginWithCachedCredentialAtom, prelogin);
|
||||
} catch {
|
||||
if (prelogin.isSamlAuth) {
|
||||
await set(launchSamlAuthAtom, prelogin);
|
||||
} else {
|
||||
await set(launchPasswordAuthAtom, prelogin);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
set(cancelConnectPortalAtom);
|
||||
@@ -78,6 +160,17 @@ connectPortalAtom.onMount = (dispatch) => {
|
||||
});
|
||||
};
|
||||
|
||||
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: "",
|
||||
@@ -90,8 +183,14 @@ export const cancelConnectPortalAtom = atom(null, (_get, set) => {
|
||||
set(statusAtom, "disconnected");
|
||||
});
|
||||
|
||||
export const usernameAtom = atom("");
|
||||
export const passwordAtom = atom("");
|
||||
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(
|
||||
@@ -114,7 +213,7 @@ export const cancelPasswordAuthAtom = atom(
|
||||
export const passwordLoginAtom = atom(
|
||||
(get) => get(portalConfigLoadingAtom),
|
||||
async (get, set, username: string, password: string) => {
|
||||
const portal = get(portalAtom);
|
||||
const portal = get(portalAddressAtom);
|
||||
if (!portal) {
|
||||
set(notifyErrorAtom, "Portal is empty");
|
||||
return;
|
||||
@@ -138,13 +237,18 @@ export const passwordLoginAtom = atom(
|
||||
|
||||
const launchSamlAuthAtom = atom(
|
||||
null,
|
||||
async (_get, set, prelogin: SamlPrelogin) => {
|
||||
async (get, set, prelogin: SamlPrelogin) => {
|
||||
const { samlAuthMethod, samlRequest } = prelogin;
|
||||
let authData: AuthData;
|
||||
|
||||
try {
|
||||
set(statusAtom, "authenticating-saml");
|
||||
authData = await authService.samlLogin(samlAuthMethod, samlRequest);
|
||||
const clearCookies = get(clearCookiesAtom);
|
||||
authData = await authService.samlLogin(
|
||||
samlAuthMethod,
|
||||
samlRequest,
|
||||
clearCookies
|
||||
);
|
||||
} catch (err) {
|
||||
throw new Error("SAML login failed");
|
||||
}
|
||||
@@ -155,17 +259,21 @@ const launchSamlAuthAtom = atom(
|
||||
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(portalAtom);
|
||||
const portal = get(portalAddressAtom);
|
||||
const prelogin = await portalService.prelogin(portal);
|
||||
if (prelogin.isSamlAuth) {
|
||||
await authService.emitAuthRequest({
|
||||
@@ -175,17 +283,6 @@ const retrySamlAuthAtom = atom(null, async (get) => {
|
||||
}
|
||||
});
|
||||
|
||||
type PortalCredential =
|
||||
| {
|
||||
user: string;
|
||||
passwd: string;
|
||||
}
|
||||
| {
|
||||
user: string;
|
||||
"prelogin-cookie": string | null;
|
||||
"portal-userauthcookie": string | null;
|
||||
};
|
||||
|
||||
const portalConfigLoadingAtom = atom(false);
|
||||
const portalLoginAtom = atom(
|
||||
(get) => get(portalConfigLoadingAtom),
|
||||
@@ -193,33 +290,88 @@ const portalLoginAtom = atom(
|
||||
set(statusAtom, "portal-config");
|
||||
set(portalConfigLoadingAtom, true);
|
||||
|
||||
const portal = get(portalAtom);
|
||||
const portalAddress = get(portalAddressAtom);
|
||||
let portalConfig;
|
||||
try {
|
||||
portalConfig = await portalService.fetchConfig(portal, credential);
|
||||
portalConfig = await portalService.fetchConfig(portalAddress, credential);
|
||||
// Ensure the password auth window is closed
|
||||
set(passwordAuthVisibleAtom, false);
|
||||
} finally {
|
||||
set(portalConfigLoadingAtom, false);
|
||||
}
|
||||
|
||||
if (!get(isProcessingAtom)) {
|
||||
const isProcessing = get(isProcessingAtom);
|
||||
if (!isProcessing) {
|
||||
console.info("Request cancelled");
|
||||
return;
|
||||
}
|
||||
|
||||
const { gateways, userAuthCookie, prelogonUserAuthCookie } = portalConfig;
|
||||
console.info("portalConfig", portalConfig);
|
||||
if (!gateways.length) {
|
||||
throw new Error("No gateway found");
|
||||
}
|
||||
|
||||
if (userAuthCookie === "empty" || prelogonUserAuthCookie === "empty") {
|
||||
throw new Error("Failed to login, please try again");
|
||||
}
|
||||
|
||||
// Previous selected gateway
|
||||
const previousGateway = get(selectedGatewayAtom);
|
||||
// Update the app data to persist the portal data
|
||||
set(updateAppDataAtom, {
|
||||
type: "PORTAL",
|
||||
payload: {
|
||||
address: portalAddress,
|
||||
gateways: gateways.map(({ name, address }) => ({
|
||||
name,
|
||||
address,
|
||||
})),
|
||||
cachedCredential: {
|
||||
user: credential.user,
|
||||
passwd: credential.passwd,
|
||||
"portal-userauthcookie": userAuthCookie,
|
||||
"portal-prelogonuserauthcookie": prelogonUserAuthCookie,
|
||||
},
|
||||
selectedGateway: previousGateway,
|
||||
},
|
||||
});
|
||||
|
||||
const { region } = prelogin;
|
||||
const { address } = portalService.preferredGateway(gateways, region);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@@ -1,5 +1,8 @@
|
||||
import { atom } from "jotai";
|
||||
import { atomWithDefault } from "jotai/utils";
|
||||
import vpnService from "../services/vpnService";
|
||||
import { notifyErrorAtom, notifySuccessAtom } from "./notification";
|
||||
import { selectedGatewayAtom, switchingGatewayAtom } from "./portal";
|
||||
|
||||
export type Status =
|
||||
| "disconnected"
|
||||
@@ -13,13 +16,42 @@ export type Status =
|
||||
| "disconnecting"
|
||||
| "error";
|
||||
|
||||
export const statusAtom = atom<Status>("disconnected");
|
||||
statusAtom.onMount = (setAtom) => {
|
||||
return vpnService.onStatusChanged((status) => {
|
||||
status === "connected" && setAtom("connected");
|
||||
});
|
||||
const internalIsOnlineAtom = atomWithDefault(() => vpnService.isOnline());
|
||||
export const isOnlineAtom = atom(
|
||||
(get) => get(internalIsOnlineAtom),
|
||||
async (get, set, update: boolean) => {
|
||||
const isOnline = await get(internalIsOnlineAtom);
|
||||
// Already online, do nothing
|
||||
if (update && update === isOnline) {
|
||||
return;
|
||||
}
|
||||
|
||||
set(internalIsOnlineAtom, update);
|
||||
if (update) {
|
||||
set(notifySuccessAtom, "The background service is online");
|
||||
} else {
|
||||
set(notifyErrorAtom, "The background service is offline", 0);
|
||||
}
|
||||
}
|
||||
);
|
||||
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);
|
||||
};
|
||||
|
||||
export const statusAtom = atom<Status>("disconnected");
|
||||
statusAtom.onMount = (setAtom) => vpnService.onVpnStatusChanged(setAtom);
|
||||
|
||||
const statusTextMap: Record<Status, String> = {
|
||||
disconnected: "Not Connected",
|
||||
prelogin: "Portal pre-logging in...",
|
||||
@@ -35,10 +67,28 @@ const statusTextMap: Record<Status, String> = {
|
||||
|
||||
export const statusTextAtom = atom((get) => {
|
||||
const status = get(statusAtom);
|
||||
const switchingGateway = get(switchingGatewayAtom);
|
||||
|
||||
if (status === "connected") {
|
||||
const selectedGateway = get(selectedGatewayAtom);
|
||||
return selectedGateway
|
||||
? `Gateway: ${selectedGateway}`
|
||||
: statusTextMap[status];
|
||||
}
|
||||
|
||||
if (switchingGateway) {
|
||||
const selectedGateway = get(selectedGatewayAtom);
|
||||
return `Switching to ${selectedGateway}`;
|
||||
}
|
||||
|
||||
return statusTextMap[status];
|
||||
});
|
||||
|
||||
export const isProcessingAtom = atom((get) => {
|
||||
const status = get(statusAtom);
|
||||
return status !== "disconnected" && status !== "connected";
|
||||
const switchingGateway = get(switchingGatewayAtom);
|
||||
|
||||
return (
|
||||
(status !== "disconnected" && status !== "connected") || switchingGateway
|
||||
);
|
||||
});
|
||||
|
@@ -5,16 +5,29 @@ import { disconnectVpnAtom } from "../../atoms/gateway";
|
||||
import {
|
||||
cancelConnectPortalAtom,
|
||||
connectPortalAtom,
|
||||
portalAtom,
|
||||
portalAddressAtom,
|
||||
switchingGatewayAtom,
|
||||
} from "../../atoms/portal";
|
||||
import { statusAtom } from "../../atoms/status";
|
||||
import { isOnlineAtom, statusAtom } from "../../atoms/status";
|
||||
|
||||
export default function PortalForm() {
|
||||
const [portal, setPortal] = useAtom(portalAtom);
|
||||
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)) {
|
||||
try {
|
||||
host = new URL(host).hostname;
|
||||
} catch (e) {}
|
||||
}
|
||||
setPortalAddress(host);
|
||||
}
|
||||
|
||||
function handleSubmit(e: ChangeEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
@@ -29,26 +42,32 @@ export default function PortalForm() {
|
||||
placeholder="Hostname or IP address"
|
||||
fullWidth
|
||||
size="small"
|
||||
value={portal}
|
||||
onChange={(e) => setPortal(e.target.value.trim())}
|
||||
InputProps={{ readOnly: status !== "disconnected" }}
|
||||
value={portalAddress}
|
||||
onChange={handlePortalAddressChange}
|
||||
InputProps={{ readOnly: status !== "disconnected" || switchingGateway }}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
{status === "disconnected" && (
|
||||
{status === "disconnected" && !switchingGateway && (
|
||||
<Button
|
||||
fullWidth
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={!isOnline}
|
||||
sx={{ textTransform: "none" }}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
{processing && (
|
||||
{(processing || switchingGateway) && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
disabled={status === "authenticating-saml"}
|
||||
disabled={
|
||||
status === "authenticating-saml" ||
|
||||
status === "connecting" ||
|
||||
status === "disconnecting" ||
|
||||
switchingGateway
|
||||
}
|
||||
onClick={cancelConnectPortal}
|
||||
sx={{ textTransform: "none" }}
|
||||
>
|
||||
|
@@ -2,7 +2,7 @@ import { GppBad, VerifiedUser as VerifiedIcon } from "@mui/icons-material";
|
||||
import { Box, CircularProgress, styled, useTheme } from "@mui/material";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { BeatLoader } from "react-spinners";
|
||||
import { statusAtom, isProcessingAtom } from "../../atoms/status";
|
||||
import { isProcessingAtom, statusAtom } from "../../atoms/status";
|
||||
|
||||
function useStatusColor() {
|
||||
const status = useAtomValue(statusAtom);
|
||||
@@ -25,14 +25,14 @@ function useStatusColor() {
|
||||
|
||||
function BackgroundIcon() {
|
||||
const color = useStatusColor();
|
||||
const processing = useAtomValue(isProcessingAtom);
|
||||
const isProcessing = useAtomValue(isProcessingAtom);
|
||||
|
||||
return (
|
||||
<CircularProgress
|
||||
size={150}
|
||||
thickness={1}
|
||||
value={processing ? undefined : 100}
|
||||
variant={processing ? "indeterminate" : "determinate"}
|
||||
value={isProcessing ? undefined : 100}
|
||||
variant={isProcessing ? "indeterminate" : "determinate"}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
@@ -40,7 +40,7 @@ function BackgroundIcon() {
|
||||
color,
|
||||
"& circle": {
|
||||
fill: color,
|
||||
fillOpacity: processing ? 0.1 : 0.25,
|
||||
fillOpacity: isProcessing ? 0.1 : 0.25,
|
||||
transition: "all 0.3s ease",
|
||||
},
|
||||
}}
|
||||
@@ -78,16 +78,26 @@ const IconContainer = styled(Box)(({ theme }) =>
|
||||
})
|
||||
);
|
||||
|
||||
export default function StatusIcon() {
|
||||
function InnerStatusIcon() {
|
||||
const status = useAtomValue(statusAtom);
|
||||
const processing = useAtomValue(isProcessingAtom);
|
||||
const isProcessing = useAtomValue(isProcessingAtom);
|
||||
|
||||
if (isProcessing) {
|
||||
return <ProcessingIcon />;
|
||||
}
|
||||
|
||||
if (status === "connected") {
|
||||
return <ConnectedIcon />;
|
||||
}
|
||||
|
||||
return <DisconnectedIcon />;
|
||||
}
|
||||
|
||||
export default function StatusIcon() {
|
||||
return (
|
||||
<IconContainer>
|
||||
<BackgroundIcon />
|
||||
{status === "disconnected" && <DisconnectedIcon />}
|
||||
{processing && <ProcessingIcon />}
|
||||
{status === "connected" && <ConnectedIcon />}
|
||||
<InnerStatusIcon />
|
||||
</IconContainer>
|
||||
);
|
||||
}
|
||||
|
@@ -6,7 +6,17 @@ export default function StatusText() {
|
||||
const statusText = useAtomValue(statusTextAtom);
|
||||
|
||||
return (
|
||||
<Typography textAlign="center" mt={1.5} variant="subtitle1" paragraph>
|
||||
<Typography
|
||||
textAlign="center"
|
||||
mt={1.5}
|
||||
variant="subtitle1"
|
||||
paragraph
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{statusText}
|
||||
</Typography>
|
||||
);
|
||||
|
@@ -1,3 +1,43 @@
|
||||
import { BugReport, Favorite } from "@mui/icons-material";
|
||||
import { Chip, ChipProps, Stack } from "@mui/material";
|
||||
import { red } from "@mui/material/colors";
|
||||
|
||||
const LinkChip = (props: ChipProps<"a">) => (
|
||||
<Chip
|
||||
component="a"
|
||||
target="_blank"
|
||||
clickable
|
||||
variant="outlined"
|
||||
size="small"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export default function Feedback() {
|
||||
return <div>Feedback</div>
|
||||
}
|
||||
return (
|
||||
<Stack direction="row" justifyContent="space-evenly" mt={1}>
|
||||
<LinkChip
|
||||
avatar={<BugReport />}
|
||||
label="Feedback"
|
||||
href="https://github.com/yuezk/GlobalProtect-openconnect/issues"
|
||||
/>
|
||||
<LinkChip
|
||||
avatar={<Favorite />}
|
||||
label="Donate"
|
||||
href="https://www.buymeacoffee.com/yuezk"
|
||||
sx={{
|
||||
"& .MuiSvgIcon-root": {
|
||||
color: red[300],
|
||||
transition: "all 0.3s ease",
|
||||
},
|
||||
"&:hover": {
|
||||
".MuiSvgIcon-root": {
|
||||
color: red[500],
|
||||
transform: "scale(1.1)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
58
gpgui/src/components/GatewaySwitcher/index.tsx
Normal file
58
gpgui/src/components/GatewaySwitcher/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Check } from "@mui/icons-material";
|
||||
import {
|
||||
Drawer,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
} from "@mui/material";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { gatewaySwitcherVisibleAtom } from "../../atoms/gateway";
|
||||
import {
|
||||
GatewayData,
|
||||
portalGatewaysAtom,
|
||||
selectedGatewayAtom,
|
||||
switchToGatewayAtom,
|
||||
} from "../../atoms/portal";
|
||||
|
||||
export default function GatewaySwitcher() {
|
||||
const [visible, setGatewaySwitcherVisible] = useAtom(
|
||||
gatewaySwitcherVisibleAtom
|
||||
);
|
||||
const gateways = useAtomValue(portalGatewaysAtom);
|
||||
const selectedGateway = useAtomValue(selectedGatewayAtom);
|
||||
const switchToGateway = useSetAtom(switchToGatewayAtom);
|
||||
|
||||
const handleClose = () => {
|
||||
setGatewaySwitcherVisible(false);
|
||||
};
|
||||
|
||||
const handleMenuClick = (gateway: GatewayData) => () => {
|
||||
setGatewaySwitcherVisible(false);
|
||||
if (gateway.name !== selectedGateway) {
|
||||
switchToGateway(gateway);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer anchor="bottom" 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 && (
|
||||
<ListItemIcon>
|
||||
<Check />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
<ListItemText inset={selectedGateway !== name}>{name}</ListItemText>
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
111
gpgui/src/components/MainMenu/index.tsx
Normal file
111
gpgui/src/components/MainMenu/index.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
ExitToApp,
|
||||
GitHub,
|
||||
LockReset,
|
||||
Menu as MenuIcon,
|
||||
Settings,
|
||||
VpnLock,
|
||||
} from "@mui/icons-material";
|
||||
import { Box, Divider, IconButton, Menu, MenuItem } from "@mui/material";
|
||||
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 { isProcessingAtom, statusAtom } from "../../atoms/status";
|
||||
|
||||
const MenuContainer = styled(Box)(({ theme }) => ({
|
||||
position: "absolute",
|
||||
left: theme.spacing(1),
|
||||
top: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const StyledMenu = styled(Menu)(({ theme }) => ({
|
||||
"& .MuiPaper-root": {
|
||||
borderRadius: 6,
|
||||
minWidth: 180,
|
||||
"& .MuiMenu-list": {
|
||||
padding: "4px 0",
|
||||
},
|
||||
"& .MuiMenuItem-root": {
|
||||
minHeight: "auto",
|
||||
"& .MuiSvgIcon-root": {
|
||||
fontSize: 18,
|
||||
color: theme.palette.text.secondary,
|
||||
marginRight: theme.spacing(1.5),
|
||||
},
|
||||
"&:active": {
|
||||
backgroundColor: alpha(
|
||||
theme.palette.primary.main,
|
||||
theme.palette.action.selectedOpacity
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export default function MainMenu() {
|
||||
const isProcessing = useAtomValue(isProcessingAtom);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const openGatewaySwitcher = useSetAtom(openGatewaySwitcherAtom);
|
||||
const status = useAtomValue(statusAtom);
|
||||
const reset = useSetAtom(resetAtom);
|
||||
const quit = useSetAtom(quitAtom);
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuContainer>
|
||||
<IconButton onClick={handleClick} disabled={isProcessing}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<StyledMenu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<MenuItem onClick={openGatewaySwitcher} disableRipple>
|
||||
<VpnLock />
|
||||
Switch Gateway
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleClose} disableRipple>
|
||||
<Settings />
|
||||
Settings
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={reset}
|
||||
disableRipple
|
||||
disabled={status !== "disconnected"}
|
||||
>
|
||||
<LockReset />
|
||||
Reset
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={quit} disableRipple>
|
||||
<ExitToApp />
|
||||
Quit
|
||||
</MenuItem>
|
||||
</StyledMenu>
|
||||
</MenuContainer>
|
||||
<IconButton
|
||||
href="https://github.com/yuezk/GlobalProtect-openconnect"
|
||||
target="_blank"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
right: (theme) => theme.spacing(1),
|
||||
top: (theme) => theme.spacing(1),
|
||||
}}
|
||||
>
|
||||
<GitHub />
|
||||
</IconButton>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -11,16 +11,23 @@ function TransitionDown(props: TransitionProps) {
|
||||
}
|
||||
|
||||
export default function Notification() {
|
||||
const { title, message, severity } = useAtomValue(notificationConfigAtom);
|
||||
const { title, message, severity, duration } = useAtomValue(
|
||||
notificationConfigAtom
|
||||
);
|
||||
const [visible, closeNotification] = useAtom(closeNotificationAtom);
|
||||
const handleClose = () => {
|
||||
if (duration) {
|
||||
closeNotification();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={visible}
|
||||
anchorOrigin={{ vertical: "top", horizontal: "center" }}
|
||||
autoHideDuration={5000}
|
||||
autoHideDuration={duration}
|
||||
TransitionComponent={TransitionDown}
|
||||
onClose={closeNotification}
|
||||
onClose={handleClose}
|
||||
sx={{
|
||||
top: 0,
|
||||
left: 0,
|
||||
|
@@ -3,8 +3,8 @@ import invokeCommand from "../utils/invokeCommand";
|
||||
|
||||
export type AuthData = {
|
||||
username: string;
|
||||
prelogin_cookie: string | null;
|
||||
portal_userauthcookie: string | null;
|
||||
prelogin_cookie?: string;
|
||||
portal_userauthcookie?: string;
|
||||
};
|
||||
|
||||
class AuthService {
|
||||
@@ -15,7 +15,8 @@ class AuthService {
|
||||
}
|
||||
|
||||
private async init() {
|
||||
await listen("auth-error", () => {
|
||||
await listen("auth-error", (evt) => {
|
||||
console.error("auth-error", evt);
|
||||
this.authErrorCallback?.();
|
||||
});
|
||||
}
|
||||
@@ -28,8 +29,12 @@ class AuthService {
|
||||
}
|
||||
|
||||
// binding: "POST" | "REDIRECT"
|
||||
async samlLogin(binding: string, request: string) {
|
||||
return invokeCommand<AuthData>("saml_login", { binding, request });
|
||||
async samlLogin(binding: string, request: string, clearCookies: boolean) {
|
||||
return invokeCommand<AuthData>("saml_login", {
|
||||
binding,
|
||||
request,
|
||||
clearCookies,
|
||||
});
|
||||
}
|
||||
|
||||
async emitAuthRequest({
|
||||
|
@@ -25,12 +25,12 @@ export type PortalConfig = {
|
||||
gateways: Gateway[];
|
||||
};
|
||||
|
||||
export type PortalConfigParams = {
|
||||
export type PortalCredential = {
|
||||
user: string;
|
||||
passwd?: string | null;
|
||||
"prelogin-cookie"?: string | null;
|
||||
"portal-userauthcookie"?: string | null;
|
||||
"portal-prelogonuserauthcookie"?: string | null;
|
||||
passwd?: string; // for password auth
|
||||
"prelogin-cookie"?: string; // for saml auth
|
||||
"portal-userauthcookie"?: string; // cached cookie from previous portal config
|
||||
"portal-prelogonuserauthcookie"?: string; // cached cookie from previous portal config
|
||||
};
|
||||
|
||||
class PortalService {
|
||||
@@ -105,7 +105,7 @@ class PortalService {
|
||||
throw new Error("Unknown prelogin response");
|
||||
}
|
||||
|
||||
async fetchConfig(portal: string, params: PortalConfigParams) {
|
||||
async fetchConfig(portal: string, params: PortalCredential) {
|
||||
const {
|
||||
user,
|
||||
passwd,
|
||||
@@ -125,8 +125,10 @@ class PortalService {
|
||||
direct: "yes",
|
||||
clientVer: "4100",
|
||||
"os-version": "Linux",
|
||||
clientgpversion: "6.0.1-19",
|
||||
"ipv6-support": "yes",
|
||||
server: portal,
|
||||
host: portal,
|
||||
user,
|
||||
passwd: passwd || "",
|
||||
"prelogin-cookie": preloginCookie || "",
|
||||
@@ -152,7 +154,7 @@ class PortalService {
|
||||
}
|
||||
|
||||
private parsePortalConfigResponse(response: string): PortalConfig {
|
||||
console.log(response);
|
||||
// console.log(response);
|
||||
|
||||
const result = parseXml(response);
|
||||
const gateways = result.all("gateways list > entry").map((entry) => {
|
||||
@@ -182,8 +184,16 @@ class PortalService {
|
||||
};
|
||||
}
|
||||
|
||||
preferredGateway(gateways: Gateway[], region: string) {
|
||||
console.log(gateways);
|
||||
preferredGateway(
|
||||
gateways: Gateway[],
|
||||
{ region, previousGateway }: { region: string; previousGateway?: string }
|
||||
) {
|
||||
for (const gateway of gateways) {
|
||||
if (gateway.name === previousGateway) {
|
||||
return gateway;
|
||||
}
|
||||
}
|
||||
|
||||
let defaultGateway = gateways[0];
|
||||
for (const gateway of gateways) {
|
||||
if (gateway.priority < defaultGateway.priority) {
|
||||
|
@@ -1,39 +1,66 @@
|
||||
import { Event, listen } from "@tauri-apps/api/event";
|
||||
import invokeCommand from "../utils/invokeCommand";
|
||||
|
||||
type Status = "disconnected" | "connecting" | "connected" | "disconnecting";
|
||||
type StatusCallback = (status: Status) => void;
|
||||
type StatusPayload = {
|
||||
status: Status;
|
||||
type VpnStatus = "disconnected" | "connecting" | "connected" | "disconnecting";
|
||||
type VpnStatusCallback = (status: VpnStatus) => void;
|
||||
type VpnStatusPayload = {
|
||||
status: VpnStatus;
|
||||
};
|
||||
|
||||
type ServiceStatusCallback = (status: boolean) => void;
|
||||
|
||||
class VpnService {
|
||||
private _status: Status = "disconnected";
|
||||
private statusCallbacks: StatusCallback[] = [];
|
||||
private _isOnline?: boolean;
|
||||
private _status?: VpnStatus;
|
||||
private statusCallbacks: VpnStatusCallback[] = [];
|
||||
private serviceStatusCallbacks: ServiceStatusCallback[] = [];
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
private async init() {
|
||||
await listen("vpn-status-received", (event: Event<StatusPayload>) => {
|
||||
console.log("vpn-status-received", event.payload);
|
||||
this.setStatus(event.payload.status);
|
||||
await listen("service-status-changed", (event: Event<boolean>) => {
|
||||
this.setIsOnline(event.payload);
|
||||
});
|
||||
|
||||
const status = await this.status();
|
||||
this.setStatus(status);
|
||||
await listen("vpn-status-received", (event: Event<VpnStatusPayload>) => {
|
||||
this.setStatus(event.payload.status);
|
||||
});
|
||||
}
|
||||
|
||||
private setStatus(status: Status) {
|
||||
if (this._status != status) {
|
||||
this._status = status;
|
||||
this.fireStatusCallbacks();
|
||||
async isOnline() {
|
||||
try {
|
||||
const isOnline = await invokeCommand<boolean>("service_online");
|
||||
this.setIsOnline(isOnline);
|
||||
return isOnline;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async status(): Promise<Status> {
|
||||
return invokeCommand<Status>("vpn_status");
|
||||
private setIsOnline(isOnline: boolean) {
|
||||
if (this._isOnline !== isOnline) {
|
||||
this._isOnline = isOnline;
|
||||
this.serviceStatusCallbacks.forEach((cb) => cb(isOnline));
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(status: VpnStatus) {
|
||||
if (this._status !== status) {
|
||||
this._status = status;
|
||||
this.statusCallbacks.forEach((cb) => cb(status));
|
||||
}
|
||||
}
|
||||
|
||||
async status(): Promise<VpnStatus> {
|
||||
try {
|
||||
const status = await invokeCommand<VpnStatus>("vpn_status");
|
||||
this._status = status;
|
||||
return status;
|
||||
} catch (err) {
|
||||
return "disconnected";
|
||||
}
|
||||
}
|
||||
|
||||
async connect(server: string, cookie: string) {
|
||||
@@ -44,19 +71,31 @@ class VpnService {
|
||||
return invokeCommand("vpn_disconnect");
|
||||
}
|
||||
|
||||
onStatusChanged(callback: StatusCallback) {
|
||||
onVpnStatusChanged(callback: VpnStatusCallback) {
|
||||
this.statusCallbacks.push(callback);
|
||||
callback(this._status);
|
||||
return () => this.removeStatusCallback(callback);
|
||||
if (typeof this._status === "string") {
|
||||
callback(this._status);
|
||||
}
|
||||
return () => this.removeVpnStatusCallback(callback);
|
||||
}
|
||||
|
||||
private fireStatusCallbacks() {
|
||||
this.statusCallbacks.forEach((cb) => cb(this._status));
|
||||
onServiceStatusChanged(callback: ServiceStatusCallback) {
|
||||
this.serviceStatusCallbacks.push(callback);
|
||||
if (typeof this._isOnline === "boolean") {
|
||||
callback(this._isOnline);
|
||||
}
|
||||
return () => this.removeServiceStatusCallback(callback);
|
||||
}
|
||||
|
||||
private removeStatusCallback(callback: StatusCallback) {
|
||||
private removeVpnStatusCallback(callback: VpnStatusCallback) {
|
||||
this.statusCallbacks = this.statusCallbacks.filter((cb) => cb !== callback);
|
||||
}
|
||||
|
||||
private removeServiceStatusCallback(callback: ServiceStatusCallback) {
|
||||
this.serviceStatusCallbacks = this.serviceStatusCallbacks.filter(
|
||||
(cb) => cb !== callback
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new VpnService();
|
||||
|
Reference in New Issue
Block a user