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:
		
							
								
								
									
										2
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @@ -5,6 +5,8 @@ | |||||||
|     "clickaway", |     "clickaway", | ||||||
|     "clientgpversion", |     "clientgpversion", | ||||||
|     "clientos", |     "clientos", | ||||||
|  |     "devicename", | ||||||
|  |     "distro", | ||||||
|     "gpcommon", |     "gpcommon", | ||||||
|     "gpgui", |     "gpgui", | ||||||
|     "gpservice", |     "gpservice", | ||||||
|   | |||||||
							
								
								
									
										27
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										27
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							| @@ -54,16 +54,19 @@ dependencies = [ | |||||||
|  "env_logger", |  "env_logger", | ||||||
|  "gpcommon", |  "gpcommon", | ||||||
|  "log", |  "log", | ||||||
|  |  "openssl", | ||||||
|  "regex", |  "regex", | ||||||
|  "serde", |  "serde", | ||||||
|  "serde_json", |  "serde_json", | ||||||
|  "tauri", |  "tauri", | ||||||
|  "tauri-build", |  "tauri-build", | ||||||
|  "tauri-plugin-log", |  "tauri-plugin-log", | ||||||
|  |  "tauri-plugin-store", | ||||||
|  "tokio", |  "tokio", | ||||||
|  "url", |  "url", | ||||||
|  "veil", |  "veil", | ||||||
|  "webkit2gtk", |  "webkit2gtk", | ||||||
|  |  "whoami", | ||||||
| ] | ] | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| @@ -2981,7 +2984,7 @@ dependencies = [ | |||||||
| [[package]] | [[package]] | ||||||
| name = "tauri-plugin-log" | name = "tauri-plugin-log" | ||||||
| version = "0.0.0" | 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 = [ | dependencies = [ | ||||||
|  "byte-unit", |  "byte-unit", | ||||||
|  "fern", |  "fern", | ||||||
| @@ -2993,6 +2996,18 @@ dependencies = [ | |||||||
|  "time", |  "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]] | [[package]] | ||||||
| name = "tauri-runtime" | name = "tauri-runtime" | ||||||
| version = "0.13.0" | version = "0.13.0" | ||||||
| @@ -3658,6 +3673,16 @@ dependencies = [ | |||||||
|  "windows-metadata", |  "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]] | [[package]] | ||||||
| name = "winapi" | name = "winapi" | ||||||
| version = "0.3.9" | version = "0.3.9" | ||||||
|   | |||||||
| @@ -3,17 +3,17 @@ | |||||||
|   <head> |   <head> | ||||||
|     <meta charset="UTF-8" /> |     <meta charset="UTF-8" /> | ||||||
|     <link rel="icon" type="image/svg+xml" href="/vite.svg" /> |     <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> |     <title>GlobalProtect</title> | ||||||
|   </head> |   </head> | ||||||
|   <body data-tauri-drag-region> |   <body> | ||||||
|     <script> |     <script> | ||||||
|       /* workaround to webview font size auto scaling */ |       /* workaround to webview font size auto scaling */ | ||||||
|       var htmlFontSize = getComputedStyle(document.documentElement).fontSize; |       var htmlFontSize = getComputedStyle(document.documentElement).fontSize; | ||||||
|       var ratio = parseInt(htmlFontSize, 10) / 16; |       var ratio = parseInt(htmlFontSize, 10) / 16; | ||||||
|       document.documentElement.style.fontSize = (16 / ratio) + 'px'; |       document.documentElement.style.fontSize = 16 / ratio + "px"; | ||||||
|     </script> |     </script> | ||||||
|     <div id="root"></div> |     <div id="root" data-tauri-drag-region></div> | ||||||
|     <script type="module" src="/src/main.tsx"></script> |     <script type="module" src="/src/pages/main.tsx"></script> | ||||||
|   </body> |   </body> | ||||||
| </html> | </html> | ||||||
|   | |||||||
| @@ -16,17 +16,19 @@ | |||||||
|     "@mui/material": "^5.11.11", |     "@mui/material": "^5.11.11", | ||||||
|     "@tauri-apps/api": "^1.3.0", |     "@tauri-apps/api": "^1.3.0", | ||||||
|     "immer": "^10.0.2", |     "immer": "^10.0.2", | ||||||
|     "jotai": "^2.1.1", |     "jotai": "^2.2.1", | ||||||
|     "jotai-immer": "^0.2.0", |     "jotai-immer": "^0.2.0", | ||||||
|     "jotai-optics": "^0.3.0", |     "jotai-optics": "^0.3.0", | ||||||
|     "optics-ts": "^2.4.0", |     "optics-ts": "^2.4.0", | ||||||
|     "react": "^18.2.0", |     "react": "^18.2.0", | ||||||
|     "react-dom": "^18.2.0", |     "react-dom": "^18.2.0", | ||||||
|     "react-spinners": "^0.13.8", |     "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": { |   "devDependencies": { | ||||||
|     "@tauri-apps/cli": "^1.3.1", |     "@tauri-apps/cli": "^1.3.1", | ||||||
|  |     "@types/node": "^20.3.3", | ||||||
|     "@types/react": "^18.0.27", |     "@types/react": "^18.0.27", | ||||||
|     "@types/react-dom": "^18.0.10", |     "@types/react-dom": "^18.0.10", | ||||||
|     "@vitejs/plugin-react-swc": "^3.0.0", |     "@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 |     specifier: ^10.0.2 | ||||||
|     version: 10.0.2 |     version: 10.0.2 | ||||||
|   jotai: |   jotai: | ||||||
|     specifier: ^2.1.1 |     specifier: ^2.2.1 | ||||||
|     version: 2.1.1(react@18.2.0) |     version: 2.2.1(react@18.2.0) | ||||||
|   jotai-immer: |   jotai-immer: | ||||||
|     specifier: ^0.2.0 |     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: |   jotai-optics: | ||||||
|     specifier: ^0.3.0 |     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: |   optics-ts: | ||||||
|     specifier: ^2.4.0 |     specifier: ^2.4.0 | ||||||
|     version: 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) |     version: 0.13.8(react-dom@18.2.0)(react@18.2.0) | ||||||
|   tauri-plugin-log-api: |   tauri-plugin-log-api: | ||||||
|     specifier: github:tauri-apps/tauri-plugin-log |     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: | devDependencies: | ||||||
|   '@tauri-apps/cli': |   '@tauri-apps/cli': | ||||||
|     specifier: ^1.3.1 |     specifier: ^1.3.1 | ||||||
|     version: 1.3.1 |     version: 1.3.1 | ||||||
|  |   '@types/node': | ||||||
|  |     specifier: ^20.3.3 | ||||||
|  |     version: 20.3.3 | ||||||
|   '@types/react': |   '@types/react': | ||||||
|     specifier: ^18.0.27 |     specifier: ^18.0.27 | ||||||
|     version: 18.0.28 |     version: 18.0.28 | ||||||
| @@ -69,7 +75,7 @@ devDependencies: | |||||||
|     version: 4.9.5 |     version: 4.9.5 | ||||||
|   vite: |   vite: | ||||||
|     specifier: ^4.1.0 |     specifier: ^4.1.0 | ||||||
|     version: 4.1.4 |     version: 4.1.4(@types/node@20.3.3) | ||||||
|  |  | ||||||
| packages: | packages: | ||||||
|  |  | ||||||
| @@ -969,6 +975,10 @@ packages: | |||||||
|       '@tauri-apps/cli-win32-x64-msvc': 1.3.1 |       '@tauri-apps/cli-win32-x64-msvc': 1.3.1 | ||||||
|     dev: true |     dev: true | ||||||
|  |  | ||||||
|  |   /@types/node@20.3.3: | ||||||
|  |     resolution: {integrity: sha512-wheIYdr4NYML61AjC8MKj/2jrR/kDQri/CIpVoZwldwhnIrD/j9jIU5bJ8yBKuB2VhpFV7Ab6G2XkBjv9r9Zzw==} | ||||||
|  |     dev: true | ||||||
|  |  | ||||||
|   /@types/parse-json@4.0.0: |   /@types/parse-json@4.0.0: | ||||||
|     resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} |     resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} | ||||||
|     dev: false |     dev: false | ||||||
| @@ -1010,7 +1020,7 @@ packages: | |||||||
|       vite: ^4 |       vite: ^4 | ||||||
|     dependencies: |     dependencies: | ||||||
|       '@swc/core': 1.3.36 |       '@swc/core': 1.3.36 | ||||||
|       vite: 4.1.4 |       vite: 4.1.4(@types/node@20.3.3) | ||||||
|     dev: true |     dev: true | ||||||
|  |  | ||||||
|   /ansi-styles@3.2.1: |   /ansi-styles@3.2.1: | ||||||
| @@ -1186,7 +1196,7 @@ packages: | |||||||
|     dependencies: |     dependencies: | ||||||
|       has: 1.0.3 |       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==} |     resolution: {integrity: sha512-hahK8EPiROS9RoNWmX/Z8rY9WkAijspX4BZ1O7umpcwI4kPNkbcCpu/PhiQ8FMcpEcF6KmbpbMpSSj/GFmo8NA==} | ||||||
|     peerDependencies: |     peerDependencies: | ||||||
|       immer: '*' |       immer: '*' | ||||||
| @@ -1194,22 +1204,22 @@ packages: | |||||||
|       react: '>=17.0.0' |       react: '>=17.0.0' | ||||||
|     dependencies: |     dependencies: | ||||||
|       immer: 10.0.2 |       immer: 10.0.2 | ||||||
|       jotai: 2.1.1(react@18.2.0) |       jotai: 2.2.1(react@18.2.0) | ||||||
|       react: 18.2.0 |       react: 18.2.0 | ||||||
|     dev: false |     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==} |     resolution: {integrity: sha512-5ttpCRREIBu6DJix0wlyBP6y1QDPlePnoMZSXNDi/FOkXZrhk9uIXKjwvw34/yBCHT5mYpFUD4sFDvRUU2vkvQ==} | ||||||
|     peerDependencies: |     peerDependencies: | ||||||
|       jotai: '>=1.11.0' |       jotai: '>=1.11.0' | ||||||
|       optics-ts: '*' |       optics-ts: '*' | ||||||
|     dependencies: |     dependencies: | ||||||
|       jotai: 2.1.1(react@18.2.0) |       jotai: 2.2.1(react@18.2.0) | ||||||
|       optics-ts: 2.4.0 |       optics-ts: 2.4.0 | ||||||
|     dev: false |     dev: false | ||||||
|  |  | ||||||
|   /jotai@2.1.1(react@18.2.0): |   /jotai@2.2.1(react@18.2.0): | ||||||
|     resolution: {integrity: sha512-LaaiuSaq+6XkwkrCtCkczyFVZOXe0dfjAFN4DVMsSZSRv/A/4xuLHnlpHMEDqvngjWYBotTIrnQ7OogMkUE6wA==} |     resolution: {integrity: sha512-Gz4tpbRQy9OiFgBwF9F7TieDn0UTE3C0IFSDuxHjOIvgn2tACH30UKz6p/wIlfoZROXSTCIxEvYEa7Y25WM+8g==} | ||||||
|     engines: {node: '>=12.20.0'} |     engines: {node: '>=12.20.0'} | ||||||
|     peerDependencies: |     peerDependencies: | ||||||
|       react: '>=17.0.0' |       react: '>=17.0.0' | ||||||
| @@ -1416,7 +1426,7 @@ packages: | |||||||
|     hasBin: true |     hasBin: true | ||||||
|     dev: true |     dev: true | ||||||
|  |  | ||||||
|   /vite@4.1.4: |   /vite@4.1.4(@types/node@20.3.3): | ||||||
|     resolution: {integrity: sha512-3knk/HsbSTKEin43zHu7jTwYWv81f8kgAL99G5NWBcA1LKvtvcVAC4JjBH1arBunO9kQka+1oGbrMKOjk4ZrBg==} |     resolution: {integrity: sha512-3knk/HsbSTKEin43zHu7jTwYWv81f8kgAL99G5NWBcA1LKvtvcVAC4JjBH1arBunO9kQka+1oGbrMKOjk4ZrBg==} | ||||||
|     engines: {node: ^14.18.0 || >=16.0.0} |     engines: {node: ^14.18.0 || >=16.0.0} | ||||||
|     hasBin: true |     hasBin: true | ||||||
| @@ -1441,6 +1451,7 @@ packages: | |||||||
|       terser: |       terser: | ||||||
|         optional: true |         optional: true | ||||||
|     dependencies: |     dependencies: | ||||||
|  |       '@types/node': 20.3.3 | ||||||
|       esbuild: 0.16.17 |       esbuild: 0.16.17 | ||||||
|       postcss: 8.4.21 |       postcss: 8.4.21 | ||||||
|       resolve: 1.22.1 |       resolve: 1.22.1 | ||||||
| @@ -1454,10 +1465,18 @@ packages: | |||||||
|     engines: {node: '>= 6'} |     engines: {node: '>= 6'} | ||||||
|     dev: false |     dev: false | ||||||
|  |  | ||||||
|   github.com/tauri-apps/tauri-plugin-log/5e14c2cad7335a4284a6caad81d8cf37dd675a27: |   github.com/tauri-apps/tauri-plugin-log/21921031d74f871180381317a338559f588ad8e9: | ||||||
|     resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-log/tar.gz/5e14c2cad7335a4284a6caad81d8cf37dd675a27} |     resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-log/tar.gz/21921031d74f871180381317a338559f588ad8e9} | ||||||
|     name: tauri-plugin-log-api |     name: tauri-plugin-log-api | ||||||
|     version: 0.0.0 |     version: 0.0.0 | ||||||
|     dependencies: |     dependencies: | ||||||
|       '@tauri-apps/api': 1.3.0 |       '@tauri-apps/api': 1.3.0 | ||||||
|     dev: false |     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" | url = "2.3" | ||||||
| tokio = { version = "1.14", features = ["full"] } | tokio = { version = "1.14", features = ["full"] } | ||||||
| veil = "0.1.6" | 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] | [features] | ||||||
| # by default Tauri runs in production mode | # 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| { |     window.listen_global(AUTH_REQUEST_EVENT, move |event| { | ||||||
|         if let Ok(payload) = TryInto::<AuthRequest>::try_into(event.payload()) { |         if let Ok(payload) = TryInto::<AuthRequest>::try_into(event.payload()) { | ||||||
|             let event_tx = event_tx.clone(); |             let event_tx = event_tx.clone(); | ||||||
|             send_auth_event(event_tx.clone(), AuthEvent::Request(payload)); |             send_auth_event(event_tx, AuthEvent::Request(payload)); | ||||||
|         } else { |         } else { | ||||||
|             warn!("Invalid auth request payload"); |             warn!("Invalid auth request payload"); | ||||||
|         } |         } | ||||||
| @@ -198,7 +198,7 @@ async fn process( | |||||||
|     process_request(window, auth_request)?; |     process_request(window, auth_request)?; | ||||||
|  |  | ||||||
|     let handle = tokio::spawn(show_window_after_timeout(window.clone())); |     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() { |     if !handle.is_finished() { | ||||||
|         handle.abort(); |         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 { |         if let Some(auth_event) = event_rx.recv().await { | ||||||
|             match auth_event { |             match auth_event { | ||||||
|                 AuthEvent::Request(auth_request) => { |                 AuthEvent::Request(auth_request) => { | ||||||
|                     attempt_times = attempt_times + 1; |                     attempt_times += 1; | ||||||
|                     info!( |                     info!( | ||||||
|                         "Got auth request from auth-request event, attempt #{}", |                         "Got auth request from auth-request event, attempt #{}", | ||||||
|                         attempt_times |                         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); |                         warn!("Error processing auth request: {}", err); | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
| @@ -316,7 +316,7 @@ async fn monitor_window_close_event(window: &Window) { | |||||||
|         if matches!(event, WindowEvent::CloseRequested { .. }) { |         if matches!(event, WindowEvent::CloseRequested { .. }) { | ||||||
|             if let Ok(mut close_tx_locked) = close_tx.try_lock() { |             if let Ok(mut close_tx_locked) = close_tx.try_lock() { | ||||||
|                 if let Some(close_tx) = close_tx_locked.take() { |                 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"); |                         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 | /// and send it to the event channel | ||||||
| fn parse_auth_data(main_res: &WebResource, auth_event_tx: mpsc::Sender<AuthEvent>) { | fn parse_auth_data(main_res: &WebResource, auth_event_tx: mpsc::Sender<AuthEvent>) { | ||||||
|     if let Some(response) = main_res.response() { |     if let Some(response) = main_res.response() { | ||||||
|         if let Some(auth_data) = read_auth_data_from_response(&response) { |         match read_auth_data_from_response(&response) { | ||||||
|  |             Ok(auth_data) => { | ||||||
|                 debug!("Got auth data from HTTP headers: {:?}", auth_data); |                 debug!("Got auth data from HTTP headers: {:?}", auth_data); | ||||||
|                 send_auth_data(auth_event_tx, auth_data); |                 send_auth_data(auth_event_tx, auth_data); | ||||||
|                 return; |                 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| { |     main_res.data(Cancellable::NONE, move |data| { | ||||||
|         if let Ok(data) = data { |         if let Ok(data) = data { | ||||||
|             let html = String::from_utf8_lossy(&data); |             let html = String::from_utf8_lossy(&data); | ||||||
| @@ -378,8 +387,15 @@ fn parse_auth_data(main_res: &WebResource, auth_event_tx: mpsc::Sender<AuthEvent | |||||||
| } | } | ||||||
|  |  | ||||||
| /// Read the authentication data from the response headers | /// Read the authentication data from the response headers | ||||||
| fn read_auth_data_from_response(response: &webkit2gtk::URIResponse) -> Option<AuthData> { | fn read_auth_data_from_response(response: &webkit2gtk::URIResponse) -> Result<AuthData, AuthError> { | ||||||
|     response.http_headers().and_then(|mut headers| { |     response | ||||||
|  |         .http_headers() | ||||||
|  |         .map_or(Err(AuthError::TokenNotFound), |mut headers| { | ||||||
|  |             let saml_status: Option<String> = headers.get("saml-auth-status").map(GString::into); | ||||||
|  |             if saml_status == Some("-1".to_string()) { | ||||||
|  |                 return Err(AuthError::TokenInvalid); | ||||||
|  |             } | ||||||
|  |  | ||||||
|             let auth_data = AuthData::new( |             let auth_data = AuthData::new( | ||||||
|                 headers.get("saml-username").map(GString::into), |                 headers.get("saml-username").map(GString::into), | ||||||
|                 headers.get("prelogin-cookie").map(GString::into), |                 headers.get("prelogin-cookie").map(GString::into), | ||||||
| @@ -387,9 +403,9 @@ fn read_auth_data_from_response(response: &webkit2gtk::URIResponse) -> Option<Au | |||||||
|             ); |             ); | ||||||
|  |  | ||||||
|             if auth_data.check() { |             if auth_data.check() { | ||||||
|             Some(auth_data) |                 Ok(auth_data) | ||||||
|             } else { |             } else { | ||||||
|             None |                 Err(AuthError::TokenNotFound) | ||||||
|             } |             } | ||||||
|         }) |         }) | ||||||
| } | } | ||||||
| @@ -441,7 +457,7 @@ fn send_auth_error(auth_event_tx: mpsc::Sender<AuthEvent>, err: AuthError) { | |||||||
| } | } | ||||||
|  |  | ||||||
| fn send_auth_event(auth_event_tx: mpsc::Sender<AuthEvent>, auth_event: AuthEvent) { | 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 { |         if let Err(err) = auth_event_tx.send(auth_event).await { | ||||||
|             warn!("Error sending event: {}", err); |             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 gpcommon::{Client, ServerApiError, VpnStatus}; | ||||||
| use std::sync::Arc; | use std::sync::Arc; | ||||||
| use tauri::{AppHandle, State}; | use tauri::{AppHandle, State}; | ||||||
|  | use tokio::fs; | ||||||
|  |  | ||||||
| #[tauri::command] | #[tauri::command] | ||||||
| pub(crate) async fn service_online<'a>(client: State<'a, Arc<Client>>) -> Result<bool, ()> { | 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 |     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" |     windows_subsystem = "windows" | ||||||
| )] | )] | ||||||
|  |  | ||||||
|  | use crate::utils::get_openssl_conf_path; | ||||||
| use env_logger::Env; | use env_logger::Env; | ||||||
| use gpcommon::{Client, ClientStatus, VpnStatus}; | use gpcommon::{Client, ClientStatus, VpnStatus}; | ||||||
| use log::warn; | use log::{info, warn}; | ||||||
| use serde::Serialize; | use serde::Serialize; | ||||||
| use std::sync::Arc; | use std::{path::PathBuf, sync::Arc}; | ||||||
| use tauri::Manager; | use tauri::{Manager, Wry}; | ||||||
| use tauri_plugin_log::LogTarget; | use tauri_plugin_log::LogTarget; | ||||||
|  | use tauri_plugin_store::{with_store, StoreCollection}; | ||||||
|  |  | ||||||
| mod auth; | mod auth; | ||||||
| mod commands; | mod commands; | ||||||
| @@ -25,8 +27,24 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> { | |||||||
|     let client_clone = client.clone(); |     let client_clone = client.clone(); | ||||||
|     let app_handle = app.handle(); |     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 { |     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) => { |             ClientStatus::Vpn(vpn_status) => { | ||||||
|                 let payload = VpnStatusPayload { status: vpn_status }; |                 let payload = VpnStatusPayload { status: vpn_status }; | ||||||
|                 if let Err(err) = app_handle.emit_all("vpn-status-received", payload) { |                 if let Err(err) = app_handle.emit_all("vpn-status-received", payload) { | ||||||
| @@ -45,23 +63,19 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> { | |||||||
|  |  | ||||||
|     app.manage(client); |     app.manage(client); | ||||||
|  |  | ||||||
|     match std::env::var("XDG_CURRENT_DESKTOP") { |     if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") { | ||||||
|         Ok(desktop) => { |  | ||||||
|         if desktop == "KDE" { |         if desktop == "KDE" { | ||||||
|             if let Some(main_window) = app.get_window("main") { |             if let Some(main_window) = app.get_window("main") { | ||||||
|                 let _ = main_window.set_decorations(false); |                 let _ = main_window.set_decorations(false); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|         Err(_) => (), |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     Ok(()) |     Ok(()) | ||||||
| } | } | ||||||
|  |  | ||||||
| fn main() { | fn main() { | ||||||
|     // env_logger::Builder::from_env(Env::default().default_filter_or("info")).init(); |     // env_logger::Builder::from_env(Env::default().default_filter_or("info")).init(); | ||||||
|  |  | ||||||
|     tauri::Builder::default() |     tauri::Builder::default() | ||||||
|         .plugin( |         .plugin( | ||||||
|             tauri_plugin_log::Builder::default() |             tauri_plugin_log::Builder::default() | ||||||
| @@ -73,13 +87,17 @@ fn main() { | |||||||
|                 .with_colors(Default::default()) |                 .with_colors(Default::default()) | ||||||
|                 .build(), |                 .build(), | ||||||
|         ) |         ) | ||||||
|  |         .plugin(tauri_plugin_store::Builder::default().build()) | ||||||
|         .setup(setup) |         .setup(setup) | ||||||
|         .invoke_handler(tauri::generate_handler![ |         .invoke_handler(tauri::generate_handler![ | ||||||
|             commands::service_online, |             commands::service_online, | ||||||
|             commands::vpn_status, |             commands::vpn_status, | ||||||
|             commands::vpn_connect, |             commands::vpn_connect, | ||||||
|             commands::vpn_disconnect, |             commands::vpn_disconnect, | ||||||
|             commands::saml_login |             commands::saml_login, | ||||||
|  |             commands::os_version, | ||||||
|  |             commands::openssl_config, | ||||||
|  |             commands::update_openssl_config, | ||||||
|         ]) |         ]) | ||||||
|         .run(tauri::generate_context!()) |         .run(tauri::generate_context!()) | ||||||
|         .expect("error while running tauri application"); |         .expect("error while running tauri application"); | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| use log::{info, warn}; | use log::{info, warn}; | ||||||
| use std::time::Instant; | use std::{path::PathBuf, time::Instant}; | ||||||
| use tauri::Window; | use tauri::{AppHandle, Window}; | ||||||
| use tokio::sync::oneshot; | use tokio::sync::oneshot; | ||||||
| use url::{form_urlencoded, Url}; | use url::{form_urlencoded, Url}; | ||||||
| use webkit2gtk::{ | use webkit2gtk::{ | ||||||
| @@ -9,7 +9,7 @@ use webkit2gtk::{ | |||||||
| }; | }; | ||||||
|  |  | ||||||
| pub(crate) fn redact_url(url: &str) -> String { | 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")) { |         if let Err(err) = url.set_host(Some("redacted")) { | ||||||
|             warn!("Error redacting URL: {}", err); |             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("")); |             let redacted_query = redact_query(url.query().unwrap_or("")); | ||||||
|             url.set_query(Some(&redacted_query)); |             url.set_query(Some(&redacted_query)); | ||||||
|         } |         } | ||||||
|         return url.to_string(); |         url.to_string() | ||||||
|     } else { |     } else { | ||||||
|         warn!("Error parsing URL: {}", url); |         warn!("Error parsing URL: {}", url); | ||||||
|         url.to_string() |         url.to_string() | ||||||
| @@ -86,3 +86,40 @@ fn send_result(tx: oneshot::Sender<()>) { | |||||||
|         warn!("Error sending clear cookies result"); |         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" |     "distDir": "../dist" | ||||||
|   }, |   }, | ||||||
|   "package": { |   "package": { | ||||||
|     "productName": "gpgui", |     "productName": "GlobalProtect", | ||||||
|     "version": "0.1.0" |     "version": "2.0.0" | ||||||
|   }, |   }, | ||||||
|   "tauri": { |   "tauri": { | ||||||
|     "allowlist": { |     "allowlist": { | ||||||
| @@ -42,7 +42,7 @@ | |||||||
|         "icons/icon.icns", |         "icons/icon.icns", | ||||||
|         "icons/icon.ico" |         "icons/icon.ico" | ||||||
|       ], |       ], | ||||||
|       "identifier": "com.tauri.dev", |       "identifier": "com.yuezk.gpgui", | ||||||
|       "longDescription": "", |       "longDescription": "", | ||||||
|       "macOS": { |       "macOS": { | ||||||
|         "entitlements": null, |         "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 { atom } from "jotai"; | ||||||
| import gatewayService from "../services/gatewayService"; | import { connectPortalAtom } from "./connectPortal"; | ||||||
| import vpnService from "../services/vpnService"; | import { | ||||||
| import { notifyErrorAtom } from "./notification"; |   GatewayData, | ||||||
| import { isProcessingAtom, statusAtom } from "./status"; |   currentPortalDataAtom, | ||||||
|  |   updatePortalDataAtom, | ||||||
|  | } from "./portal"; | ||||||
|  | import { statusAtom } from "./status"; | ||||||
|  | import { disconnectVpnAtom } from "./vpn"; | ||||||
|  |  | ||||||
| type GatewayCredential = { | export const portalGatewaysAtom = atom<GatewayData[]>((get) => { | ||||||
|   user: string; |   const { gateways } = get(currentPortalDataAtom); | ||||||
|   passwd?: string; |   return gateways; | ||||||
|   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 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 gatewaySwitcherVisibleAtom = atom(false); | ||||||
| export const openGatewaySwitcherAtom = atom(null, (get, set) => { | export const openGatewaySwitcherAtom = atom(null, (_get, set) => { | ||||||
|   set(gatewaySwitcherVisibleAtom, true); |   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 { exit } from "@tauri-apps/api/process"; | ||||||
| import { atom } from "jotai"; | import { atom } from "jotai"; | ||||||
| import { RESET } from "jotai/utils"; | import { RESET } from "jotai/utils"; | ||||||
| import { disconnectVpnAtom } from "./gateway"; | import settingsService, { TabValue } from "../services/settingsService"; | ||||||
| import { appDataStorageAtom, portalAddressAtom } from "./portal"; | import { passwordAtom, usernameAtom } from "./passwordLogin"; | ||||||
|  | import { appDataAtom, portalAddressAtom } from "./portal"; | ||||||
| import { statusAtom } from "./status"; | 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) => { | export const resetAtom = atom(null, (_get, set) => { | ||||||
|   set(appDataStorageAtom, RESET); |   set(appDataAtom, RESET); | ||||||
|   set(portalAddressAtom, ""); |   set(portalAddressAtom, ""); | ||||||
|  |   set(usernameAtom, ""); | ||||||
|  |   set(passwordAtom, ""); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| export const quitAtom = atom(null, async (get, set) => { | export const quitAtom = atom(null, async (get, set) => { | ||||||
|   const status = get(statusAtom); |   const status = await get(statusAtom); | ||||||
|  |  | ||||||
|   if (status === "connected") { |   if (status === "connected") { | ||||||
|     await set(disconnectVpnAtom); |     await set(disconnectVpnAtom); | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| import { AlertColor } from "@mui/material"; | import { AlertColor } from "@mui/material"; | ||||||
| import { atom } from "jotai"; | import { atom } from "jotai"; | ||||||
|  | import ErrorWithTitle from "../utils/ErrorWithTitle"; | ||||||
|  |  | ||||||
| export type Severity = AlertColor; | export type Severity = AlertColor; | ||||||
|  |  | ||||||
| @@ -37,9 +38,11 @@ export const notifyErrorAtom = atom( | |||||||
|       msg = "Unknown error"; |       msg = "Unknown error"; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     const title = err instanceof ErrorWithTitle ? err.title : "Error"; | ||||||
|  |  | ||||||
|     set(notificationVisibleAtom, true); |     set(notificationVisibleAtom, true); | ||||||
|     set(notificationConfigAtom, { |     set(notificationConfigAtom, { | ||||||
|       title: "Error", |       title, | ||||||
|       message: msg, |       message: msg, | ||||||
|       severity: "error", |       severity: "error", | ||||||
|       duration: duration <= 0 ? undefined : duration, |       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 { atom } from "jotai"; | ||||||
| import { withImmer } from "jotai-immer"; | import { atomWithDefault } from "jotai/utils"; | ||||||
| import { atomWithDefault, atomWithStorage } from "jotai/utils"; | import { PortalCredential } from "../services/portalService"; | ||||||
| import authService, { AuthData } from "../services/authService"; | import { atomWithTauriStorage } from "../services/storeService"; | ||||||
| import portalService, { | import { unwrap } from "./unwrap"; | ||||||
|   PasswordPrelogin, |  | ||||||
|   PortalCredential, |  | ||||||
|   Prelogin, |  | ||||||
|   SamlPrelogin, |  | ||||||
| } from "../services/portalService"; |  | ||||||
| import { disconnectVpnAtom, gatewayLoginAtom } from "./gateway"; |  | ||||||
| import { notifyErrorAtom } from "./notification"; |  | ||||||
| import { isProcessingAtom, statusAtom } from "./status"; |  | ||||||
|  |  | ||||||
| export type GatewayData = { | export type GatewayData = { | ||||||
|   name: string; |   name: string; | ||||||
| @@ -32,346 +24,65 @@ type AppData = { | |||||||
|   clearCookies: boolean; |   clearCookies: boolean; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| type AppDataUpdate = | const DEFAULT_APP_DATA: AppData = { | ||||||
|   | { |  | ||||||
|       type: "PORTAL"; |  | ||||||
|       payload: PortalData; |  | ||||||
|     } |  | ||||||
|   | { |  | ||||||
|       type: "SELECTED_GATEWAY"; |  | ||||||
|       payload: string; |  | ||||||
|     }; |  | ||||||
|  |  | ||||||
| const defaultAppData: AppData = { |  | ||||||
|   portal: "", |   portal: "", | ||||||
|   portals: [], |   portals: [], | ||||||
|   // Whether to clear the cookies of the SAML login webview, default is true |   // Whether to clear the cookies of the SAML login webview, default is true | ||||||
|   clearCookies: true, |   clearCookies: true, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const appDataStorageAtom = atomWithStorage<AppData>( | export const appDataAtom = atomWithTauriStorage("APP_DATA", DEFAULT_APP_DATA); | ||||||
|   "APP_DATA", | const unwrappedAppDataAtom = atom( | ||||||
|   defaultAppData |   (get) => get(unwrap(appDataAtom)) || DEFAULT_APP_DATA | ||||||
| ); |  | ||||||
| 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 |  | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | // 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) => { | export const currentPortalDataAtom = atom<PortalData>((get) => { | ||||||
|   const portalAddress = get(portalAddressAtom); |   const portalAddress = get(portalAddressAtom); | ||||||
|   const { portals } = get(appDataImmerAtom); |   const appData = get(unwrappedAppDataAtom); | ||||||
|  |   const { portals } = appData; | ||||||
|   const portalData = portals.find(({ address }) => address === portalAddress); |   const portalData = portals.find(({ address }) => address === portalAddress); | ||||||
|  |  | ||||||
|   return portalData || { address: portalAddress, gateways: [] }; |   return portalData || { address: portalAddress, gateways: [] }; | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const clearCookiesAtom = atom( | export const updatePortalDataAtom = 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( |  | ||||||
|   null, |   null, | ||||||
|   async (get, set, prelogin: Prelogin) => { |   async (get, set, update: PortalData) => { | ||||||
|     const { cachedCredential } = get(currentPortalDataAtom); |     const appData = await get(appDataAtom); | ||||||
|     if (!cachedCredential) { |     const { portals } = appData; | ||||||
|       throw new Error("No cached credential"); |     const portalIndex = portals.findIndex( | ||||||
|     } |       ({ address }) => address === update.address | ||||||
|     await set(portalLoginAtom, cachedCredential, prelogin); |  | ||||||
|   } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| export const passwordPreloginAtom = atom<PasswordPrelogin>({ |  | ||||||
|   isSamlAuth: false, |  | ||||||
|   region: "", |  | ||||||
|   authMessage: "", |  | ||||||
|   labelUsername: "", |  | ||||||
|   labelPassword: "", |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const cancelConnectPortalAtom = atom(null, (_get, set) => { |  | ||||||
|   set(statusAtom, "disconnected"); |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| export const usernameAtom = atomWithDefault( |  | ||||||
|   (get) => get(currentPortalDataAtom).cachedCredential?.user ?? "" |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| export const passwordAtom = atomWithDefault( |  | ||||||
|   (get) => get(currentPortalDataAtom).cachedCredential?.passwd ?? "" |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| const passwordAuthVisibleAtom = atom(false); |  | ||||||
|  |  | ||||||
| const launchPasswordAuthAtom = atom( |  | ||||||
|   null, |  | ||||||
|   async (_get, set, prelogin: PasswordPrelogin) => { |  | ||||||
|     set(passwordAuthVisibleAtom, true); |  | ||||||
|     set(passwordPreloginAtom, prelogin); |  | ||||||
|     set(statusAtom, "authenticating-password"); |  | ||||||
|   } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| export const cancelPasswordAuthAtom = atom( |  | ||||||
|   (get) => get(passwordAuthVisibleAtom), |  | ||||||
|   (_get, set) => { |  | ||||||
|     set(passwordAuthVisibleAtom, false); |  | ||||||
|     set(cancelConnectPortalAtom); |  | ||||||
|   } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| export const passwordLoginAtom = atom( |  | ||||||
|   (get) => get(portalConfigLoadingAtom), |  | ||||||
|   async (get, set, username: string, password: string) => { |  | ||||||
|     const portal = get(portalAddressAtom); |  | ||||||
|     if (!portal) { |  | ||||||
|       set(notifyErrorAtom, "Portal is empty"); |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (!username) { |  | ||||||
|       set(notifyErrorAtom, "Username is empty"); |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     try { |  | ||||||
|       const credential = { user: username, passwd: password }; |  | ||||||
|       const prelogin = get(passwordPreloginAtom); |  | ||||||
|       await set(portalLoginAtom, credential, prelogin); |  | ||||||
|     } catch (err) { |  | ||||||
|       set(cancelConnectPortalAtom); |  | ||||||
|       set(notifyErrorAtom, err); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| const launchSamlAuthAtom = atom( |  | ||||||
|   null, |  | ||||||
|   async (get, set, prelogin: SamlPrelogin) => { |  | ||||||
|     const { samlAuthMethod, samlRequest } = prelogin; |  | ||||||
|     let authData: AuthData; |  | ||||||
|  |  | ||||||
|     try { |  | ||||||
|       set(statusAtom, "authenticating-saml"); |  | ||||||
|       const clearCookies = get(clearCookiesAtom); |  | ||||||
|       authData = await authService.samlLogin( |  | ||||||
|         samlAuthMethod, |  | ||||||
|         samlRequest, |  | ||||||
|         clearCookies |  | ||||||
|     ); |     ); | ||||||
|     } catch (err) { |  | ||||||
|       throw new Error("SAML login failed"); |     if (portalIndex === -1) { | ||||||
|  |       portals.push(update); | ||||||
|  |     } else { | ||||||
|  |       portals[portalIndex] = update; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (!authData) { |     await set(appDataAtom, (appData) => ({ | ||||||
|       // User closed the SAML login window, cancel the login |       ...appData, | ||||||
|       set(cancelConnectPortalAtom); |       portal: update.address, | ||||||
|       return; |       portals, | ||||||
|     } |     })); | ||||||
|  |  | ||||||
|     // 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) => { | export const clearCookiesAtom = atom( | ||||||
|   const portal = get(portalAddressAtom); |   async (get) => { | ||||||
|   const prelogin = await portalService.prelogin(portal); |     const { clearCookies } = await get(appDataAtom); | ||||||
|   if (prelogin.isSamlAuth) { |     return clearCookies; | ||||||
|     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, |   async (_get, set, update: boolean) => { | ||||||
|       }, |     await set(appDataAtom, (appData) => ({ | ||||||
|     }); |       ...appData, | ||||||
|  |       clearCookies: update, | ||||||
|     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); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
|   | |||||||
							
								
								
									
										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 { atom } from "jotai"; | ||||||
| import { atomWithDefault } from "jotai/utils"; | import { atomWithDefault } from "jotai/utils"; | ||||||
| import vpnService from "../services/vpnService"; | import vpnService from "../services/vpnService"; | ||||||
|  | import { selectedGatewayAtom, switchGatewayAtom } from "./gateway"; | ||||||
| import { notifyErrorAtom, notifySuccessAtom } from "./notification"; | import { notifyErrorAtom, notifySuccessAtom } from "./notification"; | ||||||
| import { selectedGatewayAtom, switchingGatewayAtom } from "./portal"; | import { unwrap } from "./unwrap"; | ||||||
|  |  | ||||||
| export type Status = | export type Status = | ||||||
|   | "disconnected" |   | "disconnected" | ||||||
| @@ -16,17 +17,22 @@ export type Status = | |||||||
|   | "disconnecting" |   | "disconnecting" | ||||||
|   | "error"; |   | "error"; | ||||||
|  |  | ||||||
| const internalIsOnlineAtom = atomWithDefault(() => vpnService.isOnline()); | // Whether the gpservice has started | ||||||
| export const isOnlineAtom = atom( | const _backgroundServiceStartedAtom = atomWithDefault< | ||||||
|   (get) => get(internalIsOnlineAtom), |   boolean | Promise<boolean> | ||||||
|  | >(() => vpnService.isOnline()); | ||||||
|  |  | ||||||
|  | export const backgroundServiceStartedAtom = atom( | ||||||
|  |   (get) => get(_backgroundServiceStartedAtom), | ||||||
|   async (get, set, update: boolean) => { |   async (get, set, update: boolean) => { | ||||||
|     const isOnline = await get(internalIsOnlineAtom); |     const prev = await get(_backgroundServiceStartedAtom); | ||||||
|     // Already online, do nothing |     // Already started, do nothing | ||||||
|     if (update && update === isOnline) { |     if (update && update === prev) { | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     set(internalIsOnlineAtom, update); |     set(_backgroundServiceStartedAtom, update); | ||||||
|  |     // From stopped to started | ||||||
|     if (update) { |     if (update) { | ||||||
|       set(notifySuccessAtom, "The background service is online"); |       set(notifySuccessAtom, "The background service is online"); | ||||||
|     } else { |     } else { | ||||||
| @@ -34,25 +40,19 @@ export const isOnlineAtom = atom( | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| ); | ); | ||||||
| isOnlineAtom.onMount = (setAtom) => vpnService.onServiceStatusChanged(setAtom); |  | ||||||
|  |  | ||||||
| const internalStatusReadyAtom = atom(false); | backgroundServiceStartedAtom.onMount = (setAtom) => { | ||||||
| export const statusReadyAtom = atom( |   vpnService.onServiceStatusChanged(setAtom); | ||||||
|   (get) => get(internalStatusReadyAtom), |  | ||||||
|   (get, set, status: Status) => { |  | ||||||
|     set(internalStatusReadyAtom, true); |  | ||||||
|     set(statusAtom, status); |  | ||||||
|   } |  | ||||||
| ); |  | ||||||
|  |  | ||||||
| statusReadyAtom.onMount = (setAtom) => { |  | ||||||
|   vpnService.status().then(setAtom); |  | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const statusAtom = atom<Status>("disconnected"); | // The current status of the vpn connection | ||||||
|  | export const statusAtom = atomWithDefault<Status | Promise<Status>>(() => | ||||||
|  |   vpnService.status() | ||||||
|  | ); | ||||||
|  |  | ||||||
| statusAtom.onMount = (setAtom) => vpnService.onVpnStatusChanged(setAtom); | statusAtom.onMount = (setAtom) => vpnService.onVpnStatusChanged(setAtom); | ||||||
|  |  | ||||||
| const statusTextMap: Record<Status, String> = { | const statusTextMap: Record<Status, string> = { | ||||||
|   disconnected: "Not Connected", |   disconnected: "Not Connected", | ||||||
|   prelogin: "Portal pre-logging in...", |   prelogin: "Portal pre-logging in...", | ||||||
|   "authenticating-saml": "Authenticating...", |   "authenticating-saml": "Authenticating...", | ||||||
| @@ -65,9 +65,13 @@ const statusTextMap: Record<Status, String> = { | |||||||
|   error: "Error", |   error: "Error", | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const statusTextAtom = atom((get) => { | export const statusTextAtom = atom<string>((get) => { | ||||||
|   const status = get(statusAtom); |   const status = get(unwrap(statusAtom)); | ||||||
|   const switchingGateway = get(switchingGatewayAtom); |   const switchingGateway = get(switchGatewayAtom); | ||||||
|  |  | ||||||
|  |   if (!status) { | ||||||
|  |     return "Loading..."; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   if (status === "connected") { |   if (status === "connected") { | ||||||
|     const selectedGateway = get(selectedGatewayAtom); |     const selectedGateway = get(selectedGatewayAtom); | ||||||
| @@ -84,11 +88,16 @@ export const statusTextAtom = atom((get) => { | |||||||
|   return statusTextMap[status]; |   return statusTextMap[status]; | ||||||
| }); | }); | ||||||
|  |  | ||||||
| export const isProcessingAtom = atom((get) => { | export const isProcessingAtom = atom<boolean>((get) => { | ||||||
|   const status = get(statusAtom); |   const status = get(unwrap(statusAtom)); | ||||||
|   const switchingGateway = get(switchingGatewayAtom); |   const switchingGateway = get(switchGatewayAtom); | ||||||
|  |  | ||||||
|   return ( |   if (!status) { | ||||||
|     (status !== "disconnected" && status !== "connected") || switchingGateway |     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, |   passwordLoginAtom, | ||||||
|   passwordPreloginAtom, |   passwordPreloginAtom, | ||||||
|   usernameAtom, |   usernameAtom, | ||||||
| } from "../../atoms/portal"; | } from "../../atoms/passwordLogin"; | ||||||
|  |  | ||||||
| export default function PasswordAuth() { | export default function PasswordAuth() { | ||||||
|   const [visible, cancelPasswordAuth] = useAtom(cancelPasswordAuthAtom); |   const [visible, cancelPasswordAuth] = useAtom(cancelPasswordAuthAtom); | ||||||
| @@ -29,7 +29,7 @@ export default function PasswordAuth() { | |||||||
|  |  | ||||||
|   function handleSubmit(e: FormEvent<HTMLFormElement>) { |   function handleSubmit(e: FormEvent<HTMLFormElement>) { | ||||||
|     e.preventDefault(); |     e.preventDefault(); | ||||||
|     passwordLogin(username, password); |     passwordLogin(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|   | |||||||
| @@ -1,32 +1,42 @@ | |||||||
| import { Button, TextField } from "@mui/material"; | import { Button, TextField } from "@mui/material"; | ||||||
| import { useAtom, useAtomValue, useSetAtom } from "jotai"; | import { useAtom, useAtomValue, useSetAtom } from "jotai"; | ||||||
| import { ChangeEvent } from "react"; | import { ChangeEvent } from "react"; | ||||||
| import { disconnectVpnAtom } from "../../atoms/gateway"; |  | ||||||
| import { | import { | ||||||
|   cancelConnectPortalAtom, |   cancelConnectPortalAtom, | ||||||
|   connectPortalAtom, |   connectPortalAtom, | ||||||
|   portalAddressAtom, | } from "../../atoms/connectPortal"; | ||||||
|   switchingGatewayAtom, | import { switchGatewayAtom } from "../../atoms/gateway"; | ||||||
| } from "../../atoms/portal"; | import { portalAddressAtom } from "../../atoms/portal"; | ||||||
| import { isOnlineAtom, statusAtom } from "../../atoms/status"; | import { | ||||||
|  |   backgroundServiceStartedAtom, | ||||||
|  |   isProcessingAtom, | ||||||
|  |   statusAtom, | ||||||
|  | } from "../../atoms/status"; | ||||||
|  | import { disconnectVpnAtom } from "../../atoms/vpn"; | ||||||
|  |  | ||||||
| export default function PortalForm() { | function normalizePortalAddress(input: string) { | ||||||
|   const isOnline = useAtomValue(isOnlineAtom); |   const address = input.trim(); | ||||||
|   const [portalAddress, setPortalAddress] = useAtom(portalAddressAtom); |   if (/^https?:\/\//.test(address)) { | ||||||
|   const status = useAtomValue(statusAtom); |  | ||||||
|   const [processing, connectPortal] = useAtom(connectPortalAtom); |  | ||||||
|   const cancelConnectPortal = useSetAtom(cancelConnectPortalAtom); |  | ||||||
|   const disconnectVpn = useSetAtom(disconnectVpnAtom); |  | ||||||
|   const switchingGateway = useAtomValue(switchingGatewayAtom); |  | ||||||
|  |  | ||||||
|   function handlePortalAddressChange(e: ChangeEvent<HTMLInputElement>) { |  | ||||||
|     let host = e.target.value.trim(); |  | ||||||
|     if (/^https?:\/\//.test(host)) { |  | ||||||
|     try { |     try { | ||||||
|         host = new URL(host).hostname; |       return new URL(address).hostname; | ||||||
|     } catch (e) {} |     } catch (e) {} | ||||||
|   } |   } | ||||||
|     setPortalAddress(host); |   return address; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default function PortalForm() { | ||||||
|  |   const backgroundServiceStarted = useAtomValue(backgroundServiceStartedAtom); | ||||||
|  |   const [portalAddress, setPortalAddress] = useAtom(portalAddressAtom); | ||||||
|  |   // Use useAtom instead of useSetAtom, otherwise the onMount of the atom is not triggered | ||||||
|  |   const [, connectPortal] = useAtom(connectPortalAtom); | ||||||
|  |   const cancelConnectPortal = useSetAtom(cancelConnectPortalAtom); | ||||||
|  |   const isProcessing = useAtomValue(isProcessingAtom); | ||||||
|  |   const status = useAtomValue(statusAtom); | ||||||
|  |   const disconnectVpn = useSetAtom(disconnectVpnAtom); | ||||||
|  |   const switchingGateway = useAtomValue(switchGatewayAtom); | ||||||
|  |  | ||||||
|  |   function handlePortalAddressChange(e: ChangeEvent<HTMLInputElement>) { | ||||||
|  |     setPortalAddress(normalizePortalAddress(e.target.value)); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   function handleSubmit(e: ChangeEvent<HTMLFormElement>) { |   function handleSubmit(e: ChangeEvent<HTMLFormElement>) { | ||||||
| @@ -47,18 +57,20 @@ export default function PortalForm() { | |||||||
|         InputProps={{ readOnly: status !== "disconnected" || switchingGateway }} |         InputProps={{ readOnly: status !== "disconnected" || switchingGateway }} | ||||||
|         sx={{ mb: 1 }} |         sx={{ mb: 1 }} | ||||||
|       /> |       /> | ||||||
|  |  | ||||||
|       {status === "disconnected" && !switchingGateway && ( |       {status === "disconnected" && !switchingGateway && ( | ||||||
|         <Button |         <Button | ||||||
|           fullWidth |           fullWidth | ||||||
|           type="submit" |           type="submit" | ||||||
|           variant="contained" |           variant="contained" | ||||||
|           disabled={!isOnline} |           disabled={!backgroundServiceStarted} | ||||||
|           sx={{ textTransform: "none" }} |           sx={{ textTransform: "none" }} | ||||||
|         > |         > | ||||||
|           Connect |           Connect | ||||||
|         </Button> |         </Button> | ||||||
|       )} |       )} | ||||||
|       {(processing || switchingGateway) && ( |  | ||||||
|  |       {isProcessing && ( | ||||||
|         <Button |         <Button | ||||||
|           fullWidth |           fullWidth | ||||||
|           variant="outlined" |           variant="outlined" | ||||||
| @@ -74,6 +86,7 @@ export default function PortalForm() { | |||||||
|           Cancel |           Cancel | ||||||
|         </Button> |         </Button> | ||||||
|       )} |       )} | ||||||
|  |  | ||||||
|       {status === "connected" && ( |       {status === "connected" && ( | ||||||
|         <Button |         <Button | ||||||
|           fullWidth |           fullWidth | ||||||
|   | |||||||
| @@ -7,13 +7,13 @@ import { | |||||||
|   MenuList, |   MenuList, | ||||||
| } from "@mui/material"; | } from "@mui/material"; | ||||||
| import { useAtom, useAtomValue, useSetAtom } from "jotai"; | import { useAtom, useAtomValue, useSetAtom } from "jotai"; | ||||||
| import { gatewaySwitcherVisibleAtom } from "../../atoms/gateway"; |  | ||||||
| import { | import { | ||||||
|   GatewayData, |   gatewaySwitcherVisibleAtom, | ||||||
|   portalGatewaysAtom, |   portalGatewaysAtom, | ||||||
|   selectedGatewayAtom, |   selectedGatewayAtom, | ||||||
|   switchToGatewayAtom, |   switchGatewayAtom, | ||||||
| } from "../../atoms/portal"; | } from "../../atoms/gateway"; | ||||||
|  | import { GatewayData } from "../../atoms/portal"; | ||||||
|  |  | ||||||
| export default function GatewaySwitcher() { | export default function GatewaySwitcher() { | ||||||
|   const [visible, setGatewaySwitcherVisible] = useAtom( |   const [visible, setGatewaySwitcherVisible] = useAtom( | ||||||
| @@ -21,7 +21,7 @@ export default function GatewaySwitcher() { | |||||||
|   ); |   ); | ||||||
|   const gateways = useAtomValue(portalGatewaysAtom); |   const gateways = useAtomValue(portalGatewaysAtom); | ||||||
|   const selectedGateway = useAtomValue(selectedGatewayAtom); |   const selectedGateway = useAtomValue(selectedGatewayAtom); | ||||||
|   const switchToGateway = useSetAtom(switchToGatewayAtom); |   const switchGateway = useSetAtom(switchGatewayAtom); | ||||||
|  |  | ||||||
|   const handleClose = () => { |   const handleClose = () => { | ||||||
|     setGatewaySwitcherVisible(false); |     setGatewaySwitcherVisible(false); | ||||||
| @@ -30,18 +30,24 @@ export default function GatewaySwitcher() { | |||||||
|   const handleMenuClick = (gateway: GatewayData) => () => { |   const handleMenuClick = (gateway: GatewayData) => () => { | ||||||
|     setGatewaySwitcherVisible(false); |     setGatewaySwitcherVisible(false); | ||||||
|     if (gateway.name !== selectedGateway) { |     if (gateway.name !== selectedGateway) { | ||||||
|       switchToGateway(gateway); |       switchGateway(gateway); | ||||||
|     } |     } | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Drawer anchor="bottom" open={visible} onClose={handleClose}> |     <Drawer | ||||||
|  |       anchor="bottom" | ||||||
|  |       variant="temporary" | ||||||
|  |       open={visible} | ||||||
|  |       onClose={handleClose} | ||||||
|  |     > | ||||||
|       <MenuList |       <MenuList | ||||||
|         sx={{ |         sx={{ | ||||||
|           maxHeight: 320, |           maxHeight: 320, | ||||||
|         }} |         }} | ||||||
|       > |       > | ||||||
|         {!gateways.length && <MenuItem disabled>No gateways found</MenuItem>} |         {!gateways.length && <MenuItem disabled>No gateways found</MenuItem>} | ||||||
|  |  | ||||||
|         {gateways.map(({ name, address }) => ( |         {gateways.map(({ name, address }) => ( | ||||||
|           <MenuItem key={name} onClick={handleMenuClick({ name, address })}> |           <MenuItem key={name} onClick={handleMenuClick({ name, address })}> | ||||||
|             {selectedGateway === name && ( |             {selectedGateway === name && ( | ||||||
|   | |||||||
| @@ -11,7 +11,7 @@ import { alpha, styled } from "@mui/material/styles"; | |||||||
| import { useAtomValue, useSetAtom } from "jotai"; | import { useAtomValue, useSetAtom } from "jotai"; | ||||||
| import { useState } from "react"; | import { useState } from "react"; | ||||||
| import { openGatewaySwitcherAtom } from "../../atoms/gateway"; | import { openGatewaySwitcherAtom } from "../../atoms/gateway"; | ||||||
| import { quitAtom, resetAtom } from "../../atoms/menu"; | import { openSettingsAtom, quitAtom, resetAtom } from "../../atoms/menu"; | ||||||
| import { isProcessingAtom, statusAtom } from "../../atoms/status"; | import { isProcessingAtom, statusAtom } from "../../atoms/status"; | ||||||
|  |  | ||||||
| const MenuContainer = styled(Box)(({ theme }) => ({ | const MenuContainer = styled(Box)(({ theme }) => ({ | ||||||
| @@ -49,6 +49,7 @@ export default function MainMenu() { | |||||||
|   const isProcessing = useAtomValue(isProcessingAtom); |   const isProcessing = useAtomValue(isProcessingAtom); | ||||||
|   const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); |   const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | ||||||
|   const openGatewaySwitcher = useSetAtom(openGatewaySwitcherAtom); |   const openGatewaySwitcher = useSetAtom(openGatewaySwitcherAtom); | ||||||
|  |   const openSettings = useSetAtom(openSettingsAtom); | ||||||
|   const status = useAtomValue(statusAtom); |   const status = useAtomValue(statusAtom); | ||||||
|   const reset = useSetAtom(resetAtom); |   const reset = useSetAtom(resetAtom); | ||||||
|   const quit = useSetAtom(quitAtom); |   const quit = useSetAtom(quitAtom); | ||||||
| @@ -57,9 +58,7 @@ export default function MainMenu() { | |||||||
|   const handleClick = (event: React.MouseEvent<HTMLElement>) => { |   const handleClick = (event: React.MouseEvent<HTMLElement>) => { | ||||||
|     setAnchorEl(event.currentTarget); |     setAnchorEl(event.currentTarget); | ||||||
|   }; |   }; | ||||||
|   const handleClose = () => { |   const handleClose = () => setAnchorEl(null); | ||||||
|     setAnchorEl(null); |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
| @@ -73,24 +72,20 @@ export default function MainMenu() { | |||||||
|           onClose={handleClose} |           onClose={handleClose} | ||||||
|           onClick={handleClose} |           onClick={handleClose} | ||||||
|         > |         > | ||||||
|           <MenuItem onClick={openGatewaySwitcher} disableRipple> |           <MenuItem onClick={openGatewaySwitcher}> | ||||||
|             <VpnLock /> |             <VpnLock /> | ||||||
|             Switch Gateway |             Switch Gateway | ||||||
|           </MenuItem> |           </MenuItem> | ||||||
|           <MenuItem onClick={handleClose} disableRipple> |           <MenuItem onClick={() => openSettings()}> | ||||||
|             <Settings /> |             <Settings /> | ||||||
|             Settings |             Settings | ||||||
|           </MenuItem> |           </MenuItem> | ||||||
|           <MenuItem |           <MenuItem onClick={reset} disabled={status !== "disconnected"}> | ||||||
|             onClick={reset} |  | ||||||
|             disableRipple |  | ||||||
|             disabled={status !== "disconnected"} |  | ||||||
|           > |  | ||||||
|             <LockReset /> |             <LockReset /> | ||||||
|             Reset |             Reset | ||||||
|           </MenuItem> |           </MenuItem> | ||||||
|           <Divider /> |           <Divider /> | ||||||
|           <MenuItem onClick={quit} disableRipple> |           <MenuItem onClick={quit}> | ||||||
|             <ExitToApp /> |             <ExitToApp /> | ||||||
|             Quit |             Quit | ||||||
|           </MenuItem> |           </MenuItem> | ||||||
|   | |||||||
| @@ -2,11 +2,13 @@ import { | |||||||
|   Alert, |   Alert, | ||||||
|   AlertTitle, |   AlertTitle, | ||||||
|   Box, |   Box, | ||||||
|  |   Link, | ||||||
|   Slide, |   Slide, | ||||||
|   SlideProps, |   SlideProps, | ||||||
|   Snackbar, |   Snackbar, | ||||||
| } from "@mui/material"; | } from "@mui/material"; | ||||||
| import { useAtom, useAtomValue } from "jotai"; | import { useAtom, useAtomValue, useSetAtom } from "jotai"; | ||||||
|  | import { openSettingsAtom } from "../../atoms/menu"; | ||||||
| import { | import { | ||||||
|   closeNotificationAtom, |   closeNotificationAtom, | ||||||
|   notificationConfigAtom, |   notificationConfigAtom, | ||||||
| @@ -22,6 +24,8 @@ export default function Notification() { | |||||||
|     notificationConfigAtom |     notificationConfigAtom | ||||||
|   ); |   ); | ||||||
|   const [visible, closeNotification] = useAtom(closeNotificationAtom); |   const [visible, closeNotification] = useAtom(closeNotificationAtom); | ||||||
|  |   const openSettings = useSetAtom(openSettingsAtom); | ||||||
|  |  | ||||||
|   const handleClose = () => { |   const handleClose = () => { | ||||||
|     if (duration) { |     if (duration) { | ||||||
|       closeNotification(); |       closeNotification(); | ||||||
| @@ -51,7 +55,23 @@ export default function Notification() { | |||||||
|         }} |         }} | ||||||
|       > |       > | ||||||
|         {title && <AlertTitle data-tauri-drag-region>{title}</AlertTitle>} |         {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> |       </Alert> | ||||||
|     </Snackbar> |     </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 { Body, ResponseType, fetch } from "@tauri-apps/api/http"; | ||||||
|  | import ErrorWithTitle from "../utils/ErrorWithTitle"; | ||||||
| import { parseXml } from "../utils/parseXml"; | import { parseXml } from "../utils/parseXml"; | ||||||
| import { Gateway } from "./types"; | import { Gateway } from "./types"; | ||||||
|  |  | ||||||
| @@ -59,12 +60,21 @@ class PortalService { | |||||||
|         }), |         }), | ||||||
|       }); |       }); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       console.error("Failed to prelogin: Network error", err); |       if ( | ||||||
|       throw new Error("Failed to prelogin: Network error"); |         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) { |     if (!response.ok) { | ||||||
|       throw new Error(`Failed to prelogin: ${response.status}`); |       throw new Error(`Status code: ${response.status}`); | ||||||
|     } |     } | ||||||
|     return this.parsePrelogin(response.data); |     return this.parsePrelogin(response.data); | ||||||
|   } |   } | ||||||
| @@ -186,12 +196,12 @@ class PortalService { | |||||||
|     }; |     }; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   preferredGateway( |   chooseGateway( | ||||||
|     gateways: Gateway[], |     gateways: Gateway[], | ||||||
|     { region, previousGateway }: { region: string; previousGateway?: string } |     { region, preferredGateway }: { region: string; preferredGateway?: string } | ||||||
|   ) { |   ) { | ||||||
|     for (const gateway of gateways) { |     for (const gateway of gateways) { | ||||||
|       if (gateway.name === previousGateway) { |       if (gateway.name === preferredGateway) { | ||||||
|         return gateway; |         return gateway; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
| @@ -207,7 +217,7 @@ class PortalService { | |||||||
|       return defaultGateway; |       return defaultGateway; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     let preferredGateway = defaultGateway; |     let finalGateway = defaultGateway; | ||||||
|     let currentPriority = Infinity; |     let currentPriority = Infinity; | ||||||
|     for (const gateway of gateways) { |     for (const gateway of gateways) { | ||||||
|       const priorityRule = gateway.priorityRules.find( |       const priorityRule = gateway.priorityRules.find( | ||||||
| @@ -215,11 +225,11 @@ class PortalService { | |||||||
|       ); |       ); | ||||||
|  |  | ||||||
|       if (priorityRule && priorityRule.priority < currentPriority) { |       if (priorityRule && priorityRule.priority < currentPriority) { | ||||||
|         preferredGateway = gateway; |         finalGateway = gateway; | ||||||
|         currentPriority = priorityRule.priority; |         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/ | // https://vitejs.dev/config/ | ||||||
| export default defineConfig({ | export default defineConfig({ | ||||||
|   plugins: [react()], |   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