mirror of
				https://github.com/yuezk/GlobalProtect-openconnect.git
				synced 2025-05-20 07:26:58 -04:00 
			
		
		
		
	feat: add the settings window
This commit is contained in:
		| @@ -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 | ||||
|   | ||||
| @@ -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) { | ||||
|             debug!("Got auth data from HTTP headers: {:?}", auth_data); | ||||
|             send_auth_data(auth_event_tx, auth_data); | ||||
|             return; | ||||
|         match read_auth_data_from_response(&response) { | ||||
|             Ok(auth_data) => { | ||||
|                 debug!("Got auth data from HTTP headers: {:?}", auth_data); | ||||
|                 send_auth_data(auth_event_tx, auth_data); | ||||
|                 return; | ||||
|             } | ||||
|             Err(AuthError::TokenInvalid) => { | ||||
|                 debug!("Received invalid token from HTTP headers"); | ||||
|                 send_auth_error(auth_event_tx, AuthError::TokenInvalid); | ||||
|                 return; | ||||
|             } | ||||
|             Err(AuthError::TokenNotFound) => { | ||||
|                 debug!("Token not found in HTTP headers, trying to read from HTML"); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     let auth_event_tx = auth_event_tx.clone(); | ||||
|     main_res.data(Cancellable::NONE, move |data| { | ||||
|         if let Ok(data) = data { | ||||
|             let html = String::from_utf8_lossy(&data); | ||||
| @@ -378,20 +387,27 @@ fn parse_auth_data(main_res: &WebResource, auth_event_tx: mpsc::Sender<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| { | ||||
|         let auth_data = AuthData::new( | ||||
|             headers.get("saml-username").map(GString::into), | ||||
|             headers.get("prelogin-cookie").map(GString::into), | ||||
|             headers.get("portal-userauthcookie").map(GString::into), | ||||
|         ); | ||||
| fn read_auth_data_from_response(response: &webkit2gtk::URIResponse) -> Result<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); | ||||
|             } | ||||
|  | ||||
|         if auth_data.check() { | ||||
|             Some(auth_data) | ||||
|         } else { | ||||
|             None | ||||
|         } | ||||
|     }) | ||||
|             let auth_data = AuthData::new( | ||||
|                 headers.get("saml-username").map(GString::into), | ||||
|                 headers.get("prelogin-cookie").map(GString::into), | ||||
|                 headers.get("portal-userauthcookie").map(GString::into), | ||||
|             ); | ||||
|  | ||||
|             if auth_data.check() { | ||||
|                 Ok(auth_data) | ||||
|             } else { | ||||
|                 Err(AuthError::TokenNotFound) | ||||
|             } | ||||
|         }) | ||||
| } | ||||
|  | ||||
| /// Read the authentication data from the HTML content | ||||
| @@ -441,7 +457,7 @@ fn send_auth_error(auth_event_tx: mpsc::Sender<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,15 +63,12 @@ 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 desktop == "KDE" { | ||||
|                 if let Some(main_window) = app.get_window("main") { | ||||
|                     let _ = main_window.set_decorations(false); | ||||
|                 } | ||||
|     if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") { | ||||
|         if desktop == "KDE" { | ||||
|             if let Some(main_window) = app.get_window("main") { | ||||
|                 let _ = main_window.set_decorations(false); | ||||
|             } | ||||
|         } | ||||
|         Err(_) => (), | ||||
|     } | ||||
|  | ||||
|     Ok(()) | ||||
| @@ -61,7 +76,6 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> { | ||||
|  | ||||
| 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 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); | ||||
|         } else { | ||||
|           await set(launchPasswordAuthAtom, prelogin); | ||||
|         } | ||||
|       } | ||||
|     } catch (err) { | ||||
|       set(cancelConnectPortalAtom); | ||||
|       set(notifyErrorAtom, err); | ||||
|     } | ||||
|   } | ||||
| ); | ||||
|  | ||||
| connectPortalAtom.onMount = (dispatch) => { | ||||
|   return authService.onAuthError(() => { | ||||
|     dispatch("retry-auth"); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const loginWithCachedCredentialAtom = atom( | ||||
| export const updatePortalDataAtom = atom( | ||||
|   null, | ||||
|   async (get, set, prelogin: Prelogin) => { | ||||
|     const { cachedCredential } = get(currentPortalDataAtom); | ||||
|     if (!cachedCredential) { | ||||
|       throw new Error("No cached credential"); | ||||
|   async (get, set, update: PortalData) => { | ||||
|     const appData = await get(appDataAtom); | ||||
|     const { portals } = appData; | ||||
|     const portalIndex = portals.findIndex( | ||||
|       ({ address }) => address === update.address | ||||
|     ); | ||||
|  | ||||
|     if (portalIndex === -1) { | ||||
|       portals.push(update); | ||||
|     } else { | ||||
|       portals[portalIndex] = update; | ||||
|     } | ||||
|     await set(portalLoginAtom, cachedCredential, prelogin); | ||||
|  | ||||
|     await set(appDataAtom, (appData) => ({ | ||||
|       ...appData, | ||||
|       portal: update.address, | ||||
|       portals, | ||||
|     })); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| export const passwordPreloginAtom = atom<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, | ||||
|         }, | ||||
|         selectedGateway: previousGateway, | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|     const { region } = prelogin; | ||||
|     const { name, address } = portalService.preferredGateway(gateways, { | ||||
|       region, | ||||
|       previousGateway, | ||||
|     }); | ||||
|     await set(gatewayLoginAtom, address, { | ||||
|       user: credential.user, | ||||
|       userAuthCookie, | ||||
|       prelogonUserAuthCookie, | ||||
|     }); | ||||
|  | ||||
|     // Update the app data to persist the gateway data | ||||
|     set(updateAppDataAtom, { | ||||
|       type: "SELECTED_GATEWAY", | ||||
|       payload: name, | ||||
|     }); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| export const switchingGatewayAtom = atom(false); | ||||
| export const switchToGatewayAtom = atom( | ||||
|   (get) => get(switchingGatewayAtom), | ||||
|   async (get, set, gateway: GatewayData) => { | ||||
|     set(updateAppDataAtom, { | ||||
|       type: "SELECTED_GATEWAY", | ||||
|       payload: gateway.name, | ||||
|     }); | ||||
|  | ||||
|     if (get(statusAtom) === "connected") { | ||||
|       try { | ||||
|         set(switchingGatewayAtom, true); | ||||
|         await set(disconnectVpnAtom); | ||||
|         await set(connectPortalAtom); | ||||
|       } finally { | ||||
|         set(switchingGatewayAtom, false); | ||||
|       } | ||||
|     } | ||||
| export const clearCookiesAtom = atom( | ||||
|   async (get) => { | ||||
|     const { clearCookies } = await get(appDataAtom); | ||||
|     return clearCookies; | ||||
|   }, | ||||
|   async (_get, set, update: boolean) => { | ||||
|     await set(appDataAtom, (appData) => ({ | ||||
|       ...appData, | ||||
|       clearCookies: update, | ||||
|     })); | ||||
|   } | ||||
| ); | ||||
|   | ||||
							
								
								
									
										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"; | ||||
|  | ||||
| function normalizePortalAddress(input: string) { | ||||
|   const address = input.trim(); | ||||
|   if (/^https?:\/\//.test(address)) { | ||||
|     try { | ||||
|       return new URL(address).hostname; | ||||
|     } catch (e) {} | ||||
|   } | ||||
|   return address; | ||||
| } | ||||
|  | ||||
| export default function PortalForm() { | ||||
|   const isOnline = useAtomValue(isOnlineAtom); | ||||
|   const backgroundServiceStarted = useAtomValue(backgroundServiceStartedAtom); | ||||
|   const [portalAddress, setPortalAddress] = useAtom(portalAddressAtom); | ||||
|   const status = useAtomValue(statusAtom); | ||||
|   const [processing, connectPortal] = useAtom(connectPortalAtom); | ||||
|   // Use useAtom instead of useSetAtom, otherwise the onMount of the atom is not triggered | ||||
|   const [, connectPortal] = useAtom(connectPortalAtom); | ||||
|   const cancelConnectPortal = useSetAtom(cancelConnectPortalAtom); | ||||
|   const isProcessing = useAtomValue(isProcessingAtom); | ||||
|   const status = useAtomValue(statusAtom); | ||||
|   const disconnectVpn = useSetAtom(disconnectVpnAtom); | ||||
|   const switchingGateway = useAtomValue(switchingGatewayAtom); | ||||
|   const switchingGateway = useAtomValue(switchGatewayAtom); | ||||
|  | ||||
|   function handlePortalAddressChange(e: ChangeEvent<HTMLInputElement>) { | ||||
|     let host = e.target.value.trim(); | ||||
|     if (/^https?:\/\//.test(host)) { | ||||
|       try { | ||||
|         host = new URL(host).hostname; | ||||
|       } catch (e) {} | ||||
|     } | ||||
|     setPortalAddress(host); | ||||
|     setPortalAddress(normalizePortalAddress(e.target.value)); | ||||
|   } | ||||
|  | ||||
|   function handleSubmit(e: ChangeEvent<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"), | ||||
|       }, | ||||
|     }, | ||||
|   }, | ||||
| }); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user