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