mirror of
https://github.com/yuezk/GlobalProtect-openconnect.git
synced 2025-04-02 18:31:50 -04:00
Compare commits
2 Commits
f91f0bcd17
...
bf96a88e21
Author | SHA1 | Date | |
---|---|---|---|
|
bf96a88e21 | ||
|
963b7d5407 |
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -5,6 +5,8 @@
|
|||||||
"clickaway",
|
"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
|
||||||
|
@ -133,8 +133,8 @@ fn build_window(app_handle: &AppHandle, ua: &str) -> tauri::Result<Window> {
|
|||||||
Window::builder(app_handle, AUTH_WINDOW_LABEL, url)
|
Window::builder(app_handle, AUTH_WINDOW_LABEL, url)
|
||||||
.visible(false)
|
.visible(false)
|
||||||
.title("GlobalProtect Login")
|
.title("GlobalProtect Login")
|
||||||
.inner_size(390.0, 694.0)
|
.inner_size(400.0, 647.0)
|
||||||
.min_inner_size(390.0, 600.0)
|
.min_inner_size(370.0, 600.0)
|
||||||
.user_agent(ua)
|
.user_agent(ua)
|
||||||
.always_on_top(true)
|
.always_on_top(true)
|
||||||
.focused(true)
|
.focused(true)
|
||||||
@ -181,7 +181,7 @@ fn setup_window(window: &Window, event_tx: mpsc::Sender<AuthEvent>) -> EventHand
|
|||||||
window.listen_global(AUTH_REQUEST_EVENT, move |event| {
|
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) {
|
||||||
debug!("Got auth data from HTTP headers: {:?}", auth_data);
|
Ok(auth_data) => {
|
||||||
send_auth_data(auth_event_tx, auth_data);
|
debug!("Got auth data from HTTP headers: {:?}", auth_data);
|
||||||
return;
|
send_auth_data(auth_event_tx, auth_data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(AuthError::TokenInvalid) => {
|
||||||
|
debug!("Received invalid token from HTTP headers");
|
||||||
|
send_auth_error(auth_event_tx, AuthError::TokenInvalid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(AuthError::TokenNotFound) => {
|
||||||
|
debug!("Token not found in HTTP headers, trying to read from HTML");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let auth_event_tx = auth_event_tx.clone();
|
|
||||||
main_res.data(Cancellable::NONE, move |data| {
|
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,20 +387,27 @@ 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
|
||||||
let auth_data = AuthData::new(
|
.http_headers()
|
||||||
headers.get("saml-username").map(GString::into),
|
.map_or(Err(AuthError::TokenNotFound), |mut headers| {
|
||||||
headers.get("prelogin-cookie").map(GString::into),
|
let saml_status: Option<String> = headers.get("saml-auth-status").map(GString::into);
|
||||||
headers.get("portal-userauthcookie").map(GString::into),
|
if saml_status == Some("-1".to_string()) {
|
||||||
);
|
return Err(AuthError::TokenInvalid);
|
||||||
|
}
|
||||||
|
|
||||||
if auth_data.check() {
|
let auth_data = AuthData::new(
|
||||||
Some(auth_data)
|
headers.get("saml-username").map(GString::into),
|
||||||
} else {
|
headers.get("prelogin-cookie").map(GString::into),
|
||||||
None
|
headers.get("portal-userauthcookie").map(GString::into),
|
||||||
}
|
);
|
||||||
})
|
|
||||||
|
if auth_data.check() {
|
||||||
|
Ok(auth_data)
|
||||||
|
} else {
|
||||||
|
Err(AuthError::TokenNotFound)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the authentication data from the HTML content
|
/// Read the authentication data from the HTML content
|
||||||
@ -441,7 +457,7 @@ fn send_auth_error(auth_event_tx: mpsc::Sender<AuthEvent>, err: AuthError) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn send_auth_event(auth_event_tx: mpsc::Sender<AuthEvent>, auth_event: AuthEvent) {
|
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,15 +63,12 @@ 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(())
|
||||||
@ -61,7 +76,6 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
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
|
||||||
|
);
|
||||||
|
|
||||||
|
if (portalIndex === -1) {
|
||||||
|
portals.push(update);
|
||||||
|
} else {
|
||||||
|
portals[portalIndex] = update;
|
||||||
}
|
}
|
||||||
await set(portalLoginAtom, cachedCredential, prelogin);
|
|
||||||
|
await set(appDataAtom, (appData) => ({
|
||||||
|
...appData,
|
||||||
|
portal: update.address,
|
||||||
|
portals,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const passwordPreloginAtom = atom<PasswordPrelogin>({
|
export const clearCookiesAtom = atom(
|
||||||
isSamlAuth: false,
|
async (get) => {
|
||||||
region: "",
|
const { clearCookies } = await get(appDataAtom);
|
||||||
authMessage: "",
|
return clearCookies;
|
||||||
labelUsername: "",
|
},
|
||||||
labelPassword: "",
|
async (_get, set, update: boolean) => {
|
||||||
});
|
await set(appDataAtom, (appData) => ({
|
||||||
|
...appData,
|
||||||
export const cancelConnectPortalAtom = atom(null, (_get, set) => {
|
clearCookies: update,
|
||||||
set(statusAtom, "disconnected");
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
export const usernameAtom = atomWithDefault(
|
|
||||||
(get) => get(currentPortalDataAtom).cachedCredential?.user ?? ""
|
|
||||||
);
|
|
||||||
|
|
||||||
export const passwordAtom = atomWithDefault(
|
|
||||||
(get) => get(currentPortalDataAtom).cachedCredential?.passwd ?? ""
|
|
||||||
);
|
|
||||||
|
|
||||||
const passwordAuthVisibleAtom = atom(false);
|
|
||||||
|
|
||||||
const launchPasswordAuthAtom = atom(
|
|
||||||
null,
|
|
||||||
async (_get, set, prelogin: PasswordPrelogin) => {
|
|
||||||
set(passwordAuthVisibleAtom, true);
|
|
||||||
set(passwordPreloginAtom, prelogin);
|
|
||||||
set(statusAtom, "authenticating-password");
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const cancelPasswordAuthAtom = atom(
|
|
||||||
(get) => get(passwordAuthVisibleAtom),
|
|
||||||
(_get, set) => {
|
|
||||||
set(passwordAuthVisibleAtom, false);
|
|
||||||
set(cancelConnectPortalAtom);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const passwordLoginAtom = atom(
|
|
||||||
(get) => get(portalConfigLoadingAtom),
|
|
||||||
async (get, set, username: string, password: string) => {
|
|
||||||
const portal = get(portalAddressAtom);
|
|
||||||
if (!portal) {
|
|
||||||
set(notifyErrorAtom, "Portal is empty");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!username) {
|
|
||||||
set(notifyErrorAtom, "Username is empty");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const credential = { user: username, passwd: password };
|
|
||||||
const prelogin = get(passwordPreloginAtom);
|
|
||||||
await set(portalLoginAtom, credential, prelogin);
|
|
||||||
} catch (err) {
|
|
||||||
set(cancelConnectPortalAtom);
|
|
||||||
set(notifyErrorAtom, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const launchSamlAuthAtom = atom(
|
|
||||||
null,
|
|
||||||
async (get, set, prelogin: SamlPrelogin) => {
|
|
||||||
const { samlAuthMethod, samlRequest } = prelogin;
|
|
||||||
let authData: AuthData;
|
|
||||||
|
|
||||||
try {
|
|
||||||
set(statusAtom, "authenticating-saml");
|
|
||||||
const clearCookies = get(clearCookiesAtom);
|
|
||||||
authData = await authService.samlLogin(
|
|
||||||
samlAuthMethod,
|
|
||||||
samlRequest,
|
|
||||||
clearCookies
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error("SAML login failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!authData) {
|
|
||||||
// User closed the SAML login window, cancel the login
|
|
||||||
set(cancelConnectPortalAtom);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAML login success, update clearCookies to false to reuse the SAML session
|
|
||||||
set(clearCookiesAtom, false);
|
|
||||||
|
|
||||||
const credential = {
|
|
||||||
user: authData.username,
|
|
||||||
"prelogin-cookie": authData.prelogin_cookie,
|
|
||||||
"portal-userauthcookie": authData.portal_userauthcookie,
|
|
||||||
};
|
|
||||||
|
|
||||||
await set(portalLoginAtom, credential, prelogin);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const retrySamlAuthAtom = atom(null, async (get) => {
|
|
||||||
const portal = get(portalAddressAtom);
|
|
||||||
const prelogin = await portalService.prelogin(portal);
|
|
||||||
if (prelogin.isSamlAuth) {
|
|
||||||
await authService.emitAuthRequest({
|
|
||||||
samlBinding: prelogin.samlAuthMethod,
|
|
||||||
samlRequest: prelogin.samlRequest,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const portalConfigLoadingAtom = atom(false);
|
|
||||||
const portalLoginAtom = atom(
|
|
||||||
(get) => get(portalConfigLoadingAtom),
|
|
||||||
async (get, set, credential: PortalCredential, prelogin: Prelogin) => {
|
|
||||||
set(statusAtom, "portal-config");
|
|
||||||
set(portalConfigLoadingAtom, true);
|
|
||||||
|
|
||||||
const portalAddress = get(portalAddressAtom);
|
|
||||||
let portalConfig;
|
|
||||||
try {
|
|
||||||
portalConfig = await portalService.fetchConfig(portalAddress, credential);
|
|
||||||
// Ensure the password auth window is closed
|
|
||||||
set(passwordAuthVisibleAtom, false);
|
|
||||||
} finally {
|
|
||||||
set(portalConfigLoadingAtom, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isProcessing = get(isProcessingAtom);
|
|
||||||
if (!isProcessing) {
|
|
||||||
console.info("Request cancelled");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { gateways, userAuthCookie, prelogonUserAuthCookie } = portalConfig;
|
|
||||||
if (!gateways.length) {
|
|
||||||
throw new Error("No gateway found");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userAuthCookie === "empty" || prelogonUserAuthCookie === "empty") {
|
|
||||||
throw new Error("Failed to login, please try again");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Previous selected gateway
|
|
||||||
const previousGateway = get(selectedGatewayAtom);
|
|
||||||
// Update the app data to persist the portal data
|
|
||||||
set(updateAppDataAtom, {
|
|
||||||
type: "PORTAL",
|
|
||||||
payload: {
|
|
||||||
address: portalAddress,
|
|
||||||
gateways: gateways.map(({ name, address }) => ({
|
|
||||||
name,
|
|
||||||
address,
|
|
||||||
})),
|
|
||||||
cachedCredential: {
|
|
||||||
user: credential.user,
|
|
||||||
passwd: credential.passwd,
|
|
||||||
"portal-userauthcookie": userAuthCookie,
|
|
||||||
"portal-prelogonuserauthcookie": prelogonUserAuthCookie,
|
|
||||||
},
|
|
||||||
selectedGateway: previousGateway,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { region } = prelogin;
|
|
||||||
const { name, address } = portalService.preferredGateway(gateways, {
|
|
||||||
region,
|
|
||||||
previousGateway,
|
|
||||||
});
|
|
||||||
await set(gatewayLoginAtom, address, {
|
|
||||||
user: credential.user,
|
|
||||||
userAuthCookie,
|
|
||||||
prelogonUserAuthCookie,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the app data to persist the gateway data
|
|
||||||
set(updateAppDataAtom, {
|
|
||||||
type: "SELECTED_GATEWAY",
|
|
||||||
payload: name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const switchingGatewayAtom = atom(false);
|
|
||||||
export const switchToGatewayAtom = atom(
|
|
||||||
(get) => get(switchingGatewayAtom),
|
|
||||||
async (get, set, gateway: GatewayData) => {
|
|
||||||
set(updateAppDataAtom, {
|
|
||||||
type: "SELECTED_GATEWAY",
|
|
||||||
payload: gateway.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (get(statusAtom) === "connected") {
|
|
||||||
try {
|
|
||||||
set(switchingGatewayAtom, true);
|
|
||||||
await set(disconnectVpnAtom);
|
|
||||||
await set(connectPortalAtom);
|
|
||||||
} finally {
|
|
||||||
set(switchingGatewayAtom, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
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";
|
||||||
|
|
||||||
|
function normalizePortalAddress(input: string) {
|
||||||
|
const address = input.trim();
|
||||||
|
if (/^https?:\/\//.test(address)) {
|
||||||
|
try {
|
||||||
|
return new URL(address).hostname;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
export default function PortalForm() {
|
export default function PortalForm() {
|
||||||
const isOnline = useAtomValue(isOnlineAtom);
|
const backgroundServiceStarted = useAtomValue(backgroundServiceStartedAtom);
|
||||||
const [portalAddress, setPortalAddress] = useAtom(portalAddressAtom);
|
const [portalAddress, setPortalAddress] = useAtom(portalAddressAtom);
|
||||||
const status = useAtomValue(statusAtom);
|
// Use useAtom instead of useSetAtom, otherwise the onMount of the atom is not triggered
|
||||||
const [processing, connectPortal] = useAtom(connectPortalAtom);
|
const [, connectPortal] = useAtom(connectPortalAtom);
|
||||||
const cancelConnectPortal = useSetAtom(cancelConnectPortalAtom);
|
const cancelConnectPortal = useSetAtom(cancelConnectPortalAtom);
|
||||||
|
const isProcessing = useAtomValue(isProcessingAtom);
|
||||||
|
const status = useAtomValue(statusAtom);
|
||||||
const disconnectVpn = useSetAtom(disconnectVpnAtom);
|
const disconnectVpn = useSetAtom(disconnectVpnAtom);
|
||||||
const switchingGateway = useAtomValue(switchingGatewayAtom);
|
const switchingGateway = useAtomValue(switchGatewayAtom);
|
||||||
|
|
||||||
function handlePortalAddressChange(e: ChangeEvent<HTMLInputElement>) {
|
function handlePortalAddressChange(e: ChangeEvent<HTMLInputElement>) {
|
||||||
let host = e.target.value.trim();
|
setPortalAddress(normalizePortalAddress(e.target.value));
|
||||||
if (/^https?:\/\//.test(host)) {
|
|
||||||
try {
|
|
||||||
host = new URL(host).hostname;
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
setPortalAddress(host);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user