From 1c1e74d06f6e6738a1976d2c3af7bf9b6f1e2286 Mon Sep 17 00:00:00 2001 From: Pokamest Nikak Date: Fri, 6 Dec 2024 12:40:04 +0000 Subject: [PATCH 01/10] ru readme --- README_RU.md | 191 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 README_RU.md diff --git a/README_RU.md b/README_RU.md new file mode 100644 index 00000000..8b453907 --- /dev/null +++ b/README_RU.md @@ -0,0 +1,191 @@ +# Amnezia VPN +## _The best client for self-hosted VPN_ + +[![Build Status](https://github.com/amnezia-vpn/amnezia-client/actions/workflows/deploy.yml/badge.svg?branch=dev)](https://github.com/amnezia-vpn/amnezia-client/actions/workflows/deploy.yml?query=branch:dev) +[![Gitpod ready-to-code](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/amnezia-vpn/amnezia-client) + +[Amnezia](https://amnezia.org) is an open-source VPN client, with a key feature that enables you to deploy your own VPN server on your server. + +[![Image](https://github.com/amnezia-vpn/amnezia-client/blob/dev/metadata/img-readme/uipic4.png)](https://amnezia.org) + +### [Website](https://amnezia.org) | [Alt website link](https://storage.googleapis.com/kldscp/amnezia.org) | [Documentation](https://docs.amnezia.org) | [Troubleshooting](https://docs.amnezia.org/troubleshooting) + +> [!TIP] +> If the [Amnezia website](https://amnezia.org) is blocked in your region, you can use an [Alternative website link](https://storage.googleapis.com/kldscp/amnezia.org). + + + + +[All releases](https://github.com/amnezia-vpn/amnezia-client/releases) + +
+ + + +## Features + +- Very easy to use - enter your IP address, SSH login, password and Amnezia will automatically install VPN docker containers to your server and connect to the VPN. +- Classic VPN-protocols: OpenVPN, WireGuard and IKEv2 protocols. +- Protocols with traffic Masking (Obfuscation): OpenVPN over [Cloak](https://github.com/cbeuw/Cloak) plugin, Shadowsocks (OpenVPN over Shadowsocks), [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/) and XRay. +- Split tunneling support - add any sites to the client to enable VPN only for them or add Apps (only for Android and Desktop). +- Windows, MacOS, Linux, Android, iOS releases. +- Support for AmneziaWG protocol configuration on [Keenetic beta firmware](https://docs.keenetic.com/ua/air/kn-1611/en/6319-latest-development-release.html#UUID-186c4108-5afd-c10b-f38a-cdff6c17fab3_section-idm33192196168192-improved). + +## Links + +- [https://amnezia.org](https://amnezia.org) - Project website | [Alternative link (mirror)](https://storage.googleapis.com/kldscp/amnezia.org) +- [https://docs.amnezia.org](https://docs.amnezia.org) - Documentation +- [https://www.reddit.com/r/AmneziaVPN](https://www.reddit.com/r/AmneziaVPN) - Reddit +- [https://t.me/amnezia_vpn_en](https://t.me/amnezia_vpn_en) - Telegram support channel (English) +- [https://t.me/amnezia_vpn_ir](https://t.me/amnezia_vpn_ir) - Telegram support channel (Farsi) +- [https://t.me/amnezia_vpn_mm](https://t.me/amnezia_vpn_mm) - Telegram support channel (Myanmar) +- [https://t.me/amnezia_vpn](https://t.me/amnezia_vpn) - Telegram support channel (Russian) +- [https://vpnpay.io/en/amnezia-premium/](https://vpnpay.io/en/amnezia-premium/) - Amnezia Premium + +## Tech + +AmneziaVPN uses several open-source projects to work: + +- [OpenSSL](https://www.openssl.org/) +- [OpenVPN](https://openvpn.net/) +- [Shadowsocks](https://shadowsocks.org/) +- [Qt](https://www.qt.io/) +- [LibSsh](https://libssh.org) - forked from Qt Creator +- and more... + +## Checking out the source code + +Make sure to pull all submodules after checking out the repo. + +```bash +git submodule update --init --recursive +``` + +## Development + +Want to contribute? Welcome! + +### Help with translations + +Download the most actual translation files. + +Go to ["Actions" tab](https://github.com/amnezia-vpn/amnezia-client/actions?query=is%3Asuccess+branch%3Adev), click on the first line. +Then scroll down to the "Artifacts" section and download "AmneziaVPN_translations". + +Unzip this file. +Each *.ts file contains strings for one corresponding language. + +Translate or correct some strings in one or multiple *.ts files and commit them back to this repository into the ``client/translations`` folder. +You can do it via a web-interface or any other method you're familiar with. + +### Building sources and deployment + +Check deploy folder for build scripts. + +### How to build an iOS app from source code on MacOS + +1. First, make sure you have [XCode](https://developer.apple.com/xcode/) installed, at least version 14 or higher. + +2. We use QT to generate the XCode project. We need QT version 6.6.2. Install QT for MacOS [here](https://doc.qt.io/qt-6/macos.html) or [QT Online Installer](https://www.qt.io/download-open-source). Required modules: + - MacOS + - iOS + - Qt 5 Compatibility Module + - Qt Shader Tools + - Additional Libraries: + - Qt Image Formats + - Qt Multimedia + - Qt Remote Objects + +3. Install CMake if required. We recommend CMake version 3.25. You can install CMake [here](https://cmake.org/download/) + +4. You also need to install go >= v1.16. If you don't have it installed already, +download go from the [official website](https://golang.org/dl/) or use Homebrew. +The latest version is recommended. Install gomobile +```bash +export PATH=$PATH:~/go/bin +go install golang.org/x/mobile/cmd/gomobile@latest +gomobile init +``` + +5. Build the project +```bash +export QT_BIN_DIR="/Qt//ios/bin" +export QT_MACOS_ROOT_DIR="/Qt//macos" +export QT_IOS_BIN=$QT_BIN_DIR +export PATH=$PATH:~/go/bin +mkdir build-ios +$QT_IOS_BIN/qt-cmake . -B build-ios -GXcode -DQT_HOST_PATH=$QT_MACOS_ROOT_DIR +``` +Replace PATH-TO-QT-FOLDER and QT-VERSION to your environment + + +If you get `gomobile: command not found` make sure to set PATH to the location +of the bin folder where gomobile was installed. Usually, it's in `GOPATH`. +```bash +export PATH=$(PATH):/path/to/GOPATH/bin +``` + +6. Open the XCode project. You can then run /test/archive/ship the app. + +If the build fails with the following error +``` +make: *** +[$(PROJECTDIR)/client/build/AmneziaVPN.build/Debug-iphoneos/wireguard-go-bridge/goroot/.prepared] +Error 1 +``` +Add a user-defined variable to both AmneziaVPN and WireGuardNetworkExtension targets' build settings with +key `PATH` and value `${PATH}/path/to/bin/folder/with/go/executable`, e.g. `${PATH}:/usr/local/go/bin`. + +if the above error persists on your M1 Mac, then most probably you need to install arch based CMake +``` +arch -arm64 brew install cmake +``` + +Build might fail with the "source files not found" error the first time you try it, because the modern XCode build system compiles dependencies in parallel, and some dependencies end up being built after the ones that +require them. In this case, simply restart the build. + +## How to build the Android app + +_Tested on Mac OS_ + +The Android app has the following requirements: +* JDK 11 +* Android platform SDK 33 +* CMake 3.25.0 + +After you have installed QT, QT Creator, and Android Studio, you need to configure QT Creator correctly. + +- Click in the top menu bar on `QT Creator` -> `Preferences` -> `Devices` and select the tab `Android`. +- Set path to JDK 11 +- Set path to Android SDK (`$ANDROID_HOME`) + +In case you get errors regarding missing SDK or 'SDK manager not running', you cannot fix them by correcting the paths. If you have some spare GBs on your disk, you can let QT Creator install all requirements by choosing an empty folder for `Android SDK location` and clicking on `Set Up SDK`. Be aware: This will install a second Android SDK and NDK on your machine!  +Double-check that the right CMake version is configured:  Click on `QT Creator` -> `Preferences` and click on the side menu on `Kits`. Under the center content view's `Kits` tab, you'll find an entry for `CMake Tool`. If the default selected CMake version is lower than 3.25.0, install on your system CMake >= 3.25.0 and choose `System CMake at ` from the drop-down list. If this entry is missing, you either have not installed CMake yet or QT Creator hasn't found the path to it. In that case, click in the preferences window on the side menu item `CMake`, then on the tab `Tools` in the center content view, and finally on the button `Add` to set the path to your installed CMake.  +Please make sure that you have selected Android Platform SDK 33 for your project: click in the main view's side menu on `Projects`, and on the left, you'll see a section `Build & Run` showing different Android build targets. You can select any of them, Amnezia VPN's project setup is designed in a way that all Android targets will be built. Click on the targets submenu item `Build` and scroll in the center content view to `Build Steps`. Click on `Details` at the end of the headline `Build Android APK` (the `Details` button might be hidden in case the QT Creator Window is not running in full screen!). Here we are: Choose `android-33` as `Android Build Platform SDK`. + +That's it! You should be ready to compile the project from QT Creator! + +### Development flow + +After you've hit the build button, QT-Creator copies the whole project to a folder in the repository parent directory. The folder should look something like `build-amnezia-client-Android_Qt__Clang_-`. +If you want to develop Amnezia VPNs Android components written in Kotlin, such as components using system APIs, you need to import the generated project in Android Studio with `build-amnezia-client-Android_Qt__Clang_-/client/android-build` as the projects root directory. While you should be able to compile the generated project from Android Studio, you cannot work directly in the repository's Android project. So whenever you are confident with your work in the generated project, you'll need to copy and paste the affected files to the corresponding path in the repository's Android project so that you can add and commit your changes! + +You may face compiling issues in QT Creator after you've worked in Android Studio on the generated project. Just do a `./gradlew clean` in the generated project's root directory (`/client/android-build/.`) and you should be good to go. + +## License + +GPL v3.0 + +## Donate + +Patreon: [https://www.patreon.com/amneziavpn](https://www.patreon.com/amneziavpn) + +Bitcoin: bc1q26eevjcg9j0wuyywd2e3uc9cs2w58lpkpjxq6p
+USDT BEP20: 0x6abD576765a826f87D1D95183438f9408C901bE4
+USDT TRC20: TELAitazF1MZGmiNjTcnxDjEiH5oe7LC9d
+XMR: 48spms39jt1L2L5vyw2RQW6CXD6odUd4jFu19GZcDyKKQV9U88wsJVjSbL4CfRys37jVMdoaWVPSvezCQPhHXUW5UKLqUp3
+TON: UQDpU1CyKRmg7L8mNScKk9FRc2SlESuI7N-Hby4nX-CcVmns +## Acknowledgments + +This project is tested with BrowserStack. +We express our gratitude to [BrowserStack](https://www.browserstack.com) for supporting our project. From ea910ba30054d5a88a9bfc62f35b04e1ece2986f Mon Sep 17 00:00:00 2001 From: KsZnak Date: Fri, 6 Dec 2024 22:15:01 +0200 Subject: [PATCH 02/10] Update README_RU.md --- README_RU.md | 181 +++++++++------------------------------------------ 1 file changed, 30 insertions(+), 151 deletions(-) diff --git a/README_RU.md b/README_RU.md index 8b453907..6ebdb97f 100644 --- a/README_RU.md +++ b/README_RU.md @@ -1,182 +1,60 @@ # Amnezia VPN -## _The best client for self-hosted VPN_ +## _Лучший клиент для создания VPN на собственном сервере_ -[![Build Status](https://github.com/amnezia-vpn/amnezia-client/actions/workflows/deploy.yml/badge.svg?branch=dev)](https://github.com/amnezia-vpn/amnezia-client/actions/workflows/deploy.yml?query=branch:dev) -[![Gitpod ready-to-code](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/amnezia-vpn/amnezia-client) - -[Amnezia](https://amnezia.org) is an open-source VPN client, with a key feature that enables you to deploy your own VPN server on your server. +[AmneziaVPN](https://amnezia.org) — это open sourse VPN-клиент, ключевая особенность которого заключается в возможности развернуть собственный VPN на вашем сервере. [![Image](https://github.com/amnezia-vpn/amnezia-client/blob/dev/metadata/img-readme/uipic4.png)](https://amnezia.org) -### [Website](https://amnezia.org) | [Alt website link](https://storage.googleapis.com/kldscp/amnezia.org) | [Documentation](https://docs.amnezia.org) | [Troubleshooting](https://docs.amnezia.org/troubleshooting) +### [Сайт](https://amnezia.org) | [Зеркало на сайт](https://storage.googleapis.com/kldscp/amnezia.org) | [Документация](https://docs.amnezia.org) | [Решение проблем](https://docs.amnezia.org/troubleshooting) > [!TIP] -> If the [Amnezia website](https://amnezia.org) is blocked in your region, you can use an [Alternative website link](https://storage.googleapis.com/kldscp/amnezia.org). +> Если [сайт Amnezia](https://amnezia.org) заблокирован в вашем регионе, вы можете воспользоваться [ссылкой на зеркало](https://storage.googleapis.com/kldscp/amnezia.org). -[All releases](https://github.com/amnezia-vpn/amnezia-client/releases) +[Все релизы](https://github.com/amnezia-vpn/amnezia-client/releases)
-## Features +## Особенности -- Very easy to use - enter your IP address, SSH login, password and Amnezia will automatically install VPN docker containers to your server and connect to the VPN. -- Classic VPN-protocols: OpenVPN, WireGuard and IKEv2 protocols. -- Protocols with traffic Masking (Obfuscation): OpenVPN over [Cloak](https://github.com/cbeuw/Cloak) plugin, Shadowsocks (OpenVPN over Shadowsocks), [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/) and XRay. -- Split tunneling support - add any sites to the client to enable VPN only for them or add Apps (only for Android and Desktop). -- Windows, MacOS, Linux, Android, iOS releases. -- Support for AmneziaWG protocol configuration on [Keenetic beta firmware](https://docs.keenetic.com/ua/air/kn-1611/en/6319-latest-development-release.html#UUID-186c4108-5afd-c10b-f38a-cdff6c17fab3_section-idm33192196168192-improved). +- Простой в использовании — введите IP-адрес, SSH-логин и пароль, и Amnezia автоматически установит VPN-контейнеры Docker на ваш сервер и подключится к VPN. +- Классические VPN-протоколы: OpenVPN, WireGuard и IKEv2. +- Протоколы с маскировкой трафика (обфускацией): OpenVPN с плагином [Cloak](https://github.com/cbeuw/Cloak), Shadowsocks (OpenVPN over Shadowsocks), [AmneziaWG](https://docs.amnezia.org/documentation/amnezia-wg/) and XRay. +- Поддержка Split Tunneling — добавляйте любые сайты или приложения в список, чтобы включить VPN только для них. +- Поддерживает платформы: Windows, MacOS, Linux, Android, iOS. +- Поддержка конфигурации протокола AmneziaWG на [бета-прошивке Keenetic](https://docs.keenetic.com/ua/air/kn-1611/en/6319-latest-development-release.html#UUID-186c4108-5afd-c10b-f38a-cdff6c17fab3_section-idm33192196168192-improved). -## Links +## Ссылки -- [https://amnezia.org](https://amnezia.org) - Project website | [Alternative link (mirror)](https://storage.googleapis.com/kldscp/amnezia.org) -- [https://docs.amnezia.org](https://docs.amnezia.org) - Documentation +- [https://amnezia.org](https://amnezia.org) - Веб-сайт проекта | [Альтернативная ссылка (зеркало)](https://storage.googleapis.com/kldscp/amnezia.org) +- [https://docs.amnezia.org](https://docs.amnezia.org) - Документация - [https://www.reddit.com/r/AmneziaVPN](https://www.reddit.com/r/AmneziaVPN) - Reddit -- [https://t.me/amnezia_vpn_en](https://t.me/amnezia_vpn_en) - Telegram support channel (English) -- [https://t.me/amnezia_vpn_ir](https://t.me/amnezia_vpn_ir) - Telegram support channel (Farsi) -- [https://t.me/amnezia_vpn_mm](https://t.me/amnezia_vpn_mm) - Telegram support channel (Myanmar) -- [https://t.me/amnezia_vpn](https://t.me/amnezia_vpn) - Telegram support channel (Russian) -- [https://vpnpay.io/en/amnezia-premium/](https://vpnpay.io/en/amnezia-premium/) - Amnezia Premium +- [https://t.me/amnezia_vpn_en](https://t.me/amnezia_vpn_en) - Канал поддржки в Telegram (Английский) +- [https://t.me/amnezia_vpn_ir](https://t.me/amnezia_vpn_ir) - Канал поддржки в Telegram (Фарси) +- [https://t.me/amnezia_vpn_mm](https://t.me/amnezia_vpn_mm) - Канал поддржки в Telegram (Мьянма) +- [https://t.me/amnezia_vpn](https://t.me/amnezia_vpn) - Канал поддржки в Telegram (Русский) +- [https://vpnpay.io/en/amnezia-premium/](https://vpnpay.io/en/amnezia-premium/) - Amnezia Premium | [Зеркало](https://storage.googleapis.com/kldscp/vpnpay.io/ru/amnezia-premium\) -## Tech +## Технологии -AmneziaVPN uses several open-source projects to work: +AmneziaVPN использует несколько проектов с открытым исходным кодом: - [OpenSSL](https://www.openssl.org/) - [OpenVPN](https://openvpn.net/) - [Shadowsocks](https://shadowsocks.org/) - [Qt](https://www.qt.io/) -- [LibSsh](https://libssh.org) - forked from Qt Creator -- and more... +- [LibSsh](https://libssh.org) +- и другие... -## Checking out the source code - -Make sure to pull all submodules after checking out the repo. - -```bash -git submodule update --init --recursive -``` - -## Development - -Want to contribute? Welcome! - -### Help with translations - -Download the most actual translation files. - -Go to ["Actions" tab](https://github.com/amnezia-vpn/amnezia-client/actions?query=is%3Asuccess+branch%3Adev), click on the first line. -Then scroll down to the "Artifacts" section and download "AmneziaVPN_translations". - -Unzip this file. -Each *.ts file contains strings for one corresponding language. - -Translate or correct some strings in one or multiple *.ts files and commit them back to this repository into the ``client/translations`` folder. -You can do it via a web-interface or any other method you're familiar with. - -### Building sources and deployment - -Check deploy folder for build scripts. - -### How to build an iOS app from source code on MacOS - -1. First, make sure you have [XCode](https://developer.apple.com/xcode/) installed, at least version 14 or higher. - -2. We use QT to generate the XCode project. We need QT version 6.6.2. Install QT for MacOS [here](https://doc.qt.io/qt-6/macos.html) or [QT Online Installer](https://www.qt.io/download-open-source). Required modules: - - MacOS - - iOS - - Qt 5 Compatibility Module - - Qt Shader Tools - - Additional Libraries: - - Qt Image Formats - - Qt Multimedia - - Qt Remote Objects - -3. Install CMake if required. We recommend CMake version 3.25. You can install CMake [here](https://cmake.org/download/) - -4. You also need to install go >= v1.16. If you don't have it installed already, -download go from the [official website](https://golang.org/dl/) or use Homebrew. -The latest version is recommended. Install gomobile -```bash -export PATH=$PATH:~/go/bin -go install golang.org/x/mobile/cmd/gomobile@latest -gomobile init -``` - -5. Build the project -```bash -export QT_BIN_DIR="/Qt//ios/bin" -export QT_MACOS_ROOT_DIR="/Qt//macos" -export QT_IOS_BIN=$QT_BIN_DIR -export PATH=$PATH:~/go/bin -mkdir build-ios -$QT_IOS_BIN/qt-cmake . -B build-ios -GXcode -DQT_HOST_PATH=$QT_MACOS_ROOT_DIR -``` -Replace PATH-TO-QT-FOLDER and QT-VERSION to your environment - - -If you get `gomobile: command not found` make sure to set PATH to the location -of the bin folder where gomobile was installed. Usually, it's in `GOPATH`. -```bash -export PATH=$(PATH):/path/to/GOPATH/bin -``` - -6. Open the XCode project. You can then run /test/archive/ship the app. - -If the build fails with the following error -``` -make: *** -[$(PROJECTDIR)/client/build/AmneziaVPN.build/Debug-iphoneos/wireguard-go-bridge/goroot/.prepared] -Error 1 -``` -Add a user-defined variable to both AmneziaVPN and WireGuardNetworkExtension targets' build settings with -key `PATH` and value `${PATH}/path/to/bin/folder/with/go/executable`, e.g. `${PATH}:/usr/local/go/bin`. - -if the above error persists on your M1 Mac, then most probably you need to install arch based CMake -``` -arch -arm64 brew install cmake -``` - -Build might fail with the "source files not found" error the first time you try it, because the modern XCode build system compiles dependencies in parallel, and some dependencies end up being built after the ones that -require them. In this case, simply restart the build. - -## How to build the Android app - -_Tested on Mac OS_ - -The Android app has the following requirements: -* JDK 11 -* Android platform SDK 33 -* CMake 3.25.0 - -After you have installed QT, QT Creator, and Android Studio, you need to configure QT Creator correctly. - -- Click in the top menu bar on `QT Creator` -> `Preferences` -> `Devices` and select the tab `Android`. -- Set path to JDK 11 -- Set path to Android SDK (`$ANDROID_HOME`) - -In case you get errors regarding missing SDK or 'SDK manager not running', you cannot fix them by correcting the paths. If you have some spare GBs on your disk, you can let QT Creator install all requirements by choosing an empty folder for `Android SDK location` and clicking on `Set Up SDK`. Be aware: This will install a second Android SDK and NDK on your machine!  -Double-check that the right CMake version is configured:  Click on `QT Creator` -> `Preferences` and click on the side menu on `Kits`. Under the center content view's `Kits` tab, you'll find an entry for `CMake Tool`. If the default selected CMake version is lower than 3.25.0, install on your system CMake >= 3.25.0 and choose `System CMake at ` from the drop-down list. If this entry is missing, you either have not installed CMake yet or QT Creator hasn't found the path to it. In that case, click in the preferences window on the side menu item `CMake`, then on the tab `Tools` in the center content view, and finally on the button `Add` to set the path to your installed CMake.  -Please make sure that you have selected Android Platform SDK 33 for your project: click in the main view's side menu on `Projects`, and on the left, you'll see a section `Build & Run` showing different Android build targets. You can select any of them, Amnezia VPN's project setup is designed in a way that all Android targets will be built. Click on the targets submenu item `Build` and scroll in the center content view to `Build Steps`. Click on `Details` at the end of the headline `Build Android APK` (the `Details` button might be hidden in case the QT Creator Window is not running in full screen!). Here we are: Choose `android-33` as `Android Build Platform SDK`. - -That's it! You should be ready to compile the project from QT Creator! - -### Development flow - -After you've hit the build button, QT-Creator copies the whole project to a folder in the repository parent directory. The folder should look something like `build-amnezia-client-Android_Qt__Clang_-`. -If you want to develop Amnezia VPNs Android components written in Kotlin, such as components using system APIs, you need to import the generated project in Android Studio with `build-amnezia-client-Android_Qt__Clang_-/client/android-build` as the projects root directory. While you should be able to compile the generated project from Android Studio, you cannot work directly in the repository's Android project. So whenever you are confident with your work in the generated project, you'll need to copy and paste the affected files to the corresponding path in the repository's Android project so that you can add and commit your changes! - -You may face compiling issues in QT Creator after you've worked in Android Studio on the generated project. Just do a `./gradlew clean` in the generated project's root directory (`/client/android-build/.`) and you should be good to go. - -## License +## Лицензия GPL v3.0 -## Donate +## Донаты Patreon: [https://www.patreon.com/amneziavpn](https://www.patreon.com/amneziavpn) @@ -185,7 +63,8 @@ USDT BEP20: 0x6abD576765a826f87D1D95183438f9408C901bE4
USDT TRC20: TELAitazF1MZGmiNjTcnxDjEiH5oe7LC9d
XMR: 48spms39jt1L2L5vyw2RQW6CXD6odUd4jFu19GZcDyKKQV9U88wsJVjSbL4CfRys37jVMdoaWVPSvezCQPhHXUW5UKLqUp3
TON: UQDpU1CyKRmg7L8mNScKk9FRc2SlESuI7N-Hby4nX-CcVmns -## Acknowledgments -This project is tested with BrowserStack. -We express our gratitude to [BrowserStack](https://www.browserstack.com) for supporting our project. +## Благодарности + +Этот проект тестируется с помощью BrowserStack. +Мы выражаем благодарность [BrowserStack](https://www.browserstack.com) за поддержку нашего проекта. From 569d63ef0f750f9938dfdda2fc69c9559a7be4c8 Mon Sep 17 00:00:00 2001 From: KsZnak Date: Sat, 7 Dec 2024 15:53:40 +0200 Subject: [PATCH 03/10] Add files via upload --- metadata/img-readme/download-website-ru.svg | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 metadata/img-readme/download-website-ru.svg diff --git a/metadata/img-readme/download-website-ru.svg b/metadata/img-readme/download-website-ru.svg new file mode 100644 index 00000000..386ae4fe --- /dev/null +++ b/metadata/img-readme/download-website-ru.svg @@ -0,0 +1,8 @@ + + + + + + + + From d67201ede9dc2f39b525ec55e04ac0225e551462 Mon Sep 17 00:00:00 2001 From: KsZnak Date: Sun, 8 Dec 2024 05:34:18 +0200 Subject: [PATCH 04/10] Update README.md --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8b453907..8f887808 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,14 @@ # Amnezia VPN -## _The best client for self-hosted VPN_ + +### _The best client for self-hosted VPN_ + [![Build Status](https://github.com/amnezia-vpn/amnezia-client/actions/workflows/deploy.yml/badge.svg?branch=dev)](https://github.com/amnezia-vpn/amnezia-client/actions/workflows/deploy.yml?query=branch:dev) [![Gitpod ready-to-code](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/amnezia-vpn/amnezia-client) +### [English]([https://github.com/amnezia-vpn/amnezia-client/blob/dev/README_RU.md](https://github.com/amnezia-vpn/amnezia-client/tree/dev?tab=readme-ov-file#)) | [Русский](https://github.com/amnezia-vpn/amnezia-client/blob/dev/README_RU.md) + + [Amnezia](https://amnezia.org) is an open-source VPN client, with a key feature that enables you to deploy your own VPN server on your server. [![Image](https://github.com/amnezia-vpn/amnezia-client/blob/dev/metadata/img-readme/uipic4.png)](https://amnezia.org) From c5aa070bf4cd7ff1de931dc22a887aea3104ae92 Mon Sep 17 00:00:00 2001 From: KsZnak Date: Sun, 8 Dec 2024 05:49:26 +0200 Subject: [PATCH 05/10] Update README_RU.md --- README_RU.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README_RU.md b/README_RU.md index 6ebdb97f..fe9dd286 100644 --- a/README_RU.md +++ b/README_RU.md @@ -1,6 +1,11 @@ # Amnezia VPN -## _Лучший клиент для создания VPN на собственном сервере_ +### _Лучший клиент для создания VPN на собственном сервере_ + +[![Build Status](https://github.com/amnezia-vpn/amnezia-client/actions/workflows/deploy.yml/badge.svg?branch=dev)](https://github.com/amnezia-vpn/amnezia-client/actions/workflows/deploy.yml?query=branch:dev) +[![Gitpod ready-to-code](https://img.shields.io/badge/Gitpod-ready--to--code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/amnezia-vpn/amnezia-client) + +### [English](https://github.com/amnezia-vpn/amnezia-client/blob/dev/README.md) | Русский [AmneziaVPN](https://amnezia.org) — это open sourse VPN-клиент, ключевая особенность которого заключается в возможности развернуть собственный VPN на вашем сервере. [![Image](https://github.com/amnezia-vpn/amnezia-client/blob/dev/metadata/img-readme/uipic4.png)](https://amnezia.org) @@ -10,8 +15,8 @@ > [!TIP] > Если [сайт Amnezia](https://amnezia.org) заблокирован в вашем регионе, вы можете воспользоваться [ссылкой на зеркало](https://storage.googleapis.com/kldscp/amnezia.org). - - + + [Все релизы](https://github.com/amnezia-vpn/amnezia-client/releases) From 6ea6ab1bd983fd2be880e9e14a9184bda9b79349 Mon Sep 17 00:00:00 2001 From: Nethius Date: Sun, 8 Dec 2024 08:14:22 +0300 Subject: [PATCH 06/10] chore: added clang-format config files (#1293) --- .clang-format | 39 +++++++++++++++++++++++++++++++++++++++ .clang-format-ignore | 20 ++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 .clang-format create mode 100644 .clang-format-ignore diff --git a/.clang-format b/.clang-format new file mode 100644 index 00000000..5c459fd2 --- /dev/null +++ b/.clang-format @@ -0,0 +1,39 @@ +BasedOnStyle: WebKit +AccessModifierOffset: '-4' +AlignAfterOpenBracket: Align +AlignConsecutiveMacros: 'true' +AlignTrailingComments: 'true' +AllowAllArgumentsOnNextLine: 'true' +AllowAllParametersOfDeclarationOnNextLine: 'true' +AllowShortBlocksOnASingleLine: 'false' +AllowShortCaseLabelsOnASingleLine: 'true' +AllowShortEnumsOnASingleLine: 'false' +AllowShortFunctionsOnASingleLine: None +AlwaysBreakTemplateDeclarations: 'No' +BreakBeforeBinaryOperators: NonAssignment +BreakBeforeBraces: Custom +BraceWrapping: + AfterClass: true + AfterControlStatement: false + AfterEnum: false + AfterFunction: true + AfterNamespace: true + AfterObjCDeclaration: false + AfterStruct: true + AfterUnion: false + BeforeCatch: false + BeforeElse: false + IndentBraces: false +BreakConstructorInitializers: BeforeColon +ColumnLimit: '120' +CommentPragmas: '"^!|^:"' +ConstructorInitializerAllOnOneLineOrOnePerLine: 'true' +ConstructorInitializerIndentWidth: '4' +ContinuationIndentWidth: '8' +IndentPPDirectives: BeforeHash +NamespaceIndentation: All +PenaltyExcessCharacter: '10' +PointerAlignment: Right +SortIncludes: 'true' +SpaceAfterTemplateKeyword: 'false' +Standard: Auto diff --git a/.clang-format-ignore b/.clang-format-ignore new file mode 100644 index 00000000..4019357f --- /dev/null +++ b/.clang-format-ignore @@ -0,0 +1,20 @@ +/client/3rd +/client/3rd-prebuild +/client/android +/client/cmake +/client/core/serialization +/client/daemon +/client/fonts +/client/images +/client/ios +/client/mozilla +/client/platforms/dummy +/client/platforms/linux +/client/platforms/macos +/client/platforms/windows +/client/server_scripts +/client/translations +/deploy +/docs +/metadata +/service/src From 2db99715b1fc5a7ef1f8b800c72d6e5b3422ce1f Mon Sep 17 00:00:00 2001 From: Nethius Date: Mon, 9 Dec 2024 09:32:49 +0300 Subject: [PATCH 07/10] feature: added subscription expiration date for premium v2 (#1261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feature: added subscription expiration date for premium v2 * feature: added a check for the presence of the “services” field in the response body of the getServicesList() function * feature: added prohibition to change location when connection is active * bugfix: renamed public_key->end_date to public_key->expires_at according to the changes on the backend --- client/core/controllers/apiController.cpp | 7 + client/core/defs.h | 1 + client/core/errorstrings.cpp | 3 +- .../ui/controllers/connectionController.cpp | 2 +- client/ui/models/apiServicesModel.cpp | 114 ++++++--- client/ui/models/apiServicesModel.h | 40 ++- client/ui/models/servers_model.cpp | 33 ++- client/ui/models/servers_model.h | 5 + .../Pages2/PageSettingsApiLanguageList.qml | 6 + .../qml/Pages2/PageSettingsApiServerInfo.qml | 7 +- .../ui/qml/Pages2/PageSettingsServerInfo.qml | 227 +++++++++--------- 11 files changed, 285 insertions(+), 160 deletions(-) diff --git a/client/core/controllers/apiController.cpp b/client/core/controllers/apiController.cpp index c50165e7..6562632a 100644 --- a/client/core/controllers/apiController.cpp +++ b/client/core/controllers/apiController.cpp @@ -379,6 +379,13 @@ ErrorCode ApiController::getServicesList(QByteArray &responseBody) auto errorCode = checkErrors(sslErrors, reply); reply->deleteLater(); + + if (errorCode == ErrorCode::NoError) { + if (!responseBody.contains("services")) { + return ErrorCode::ApiServicesMissingError; + } + } + return errorCode; } diff --git a/client/core/defs.h b/client/core/defs.h index d00d347b..c0db2e12 100644 --- a/client/core/defs.h +++ b/client/core/defs.h @@ -109,6 +109,7 @@ namespace amnezia ApiConfigSslError = 1104, ApiMissingAgwPublicKey = 1105, ApiConfigDecryptionError = 1106, + ApiServicesMissingError = 1107, // QFile errors OpenError = 1200, diff --git a/client/core/errorstrings.cpp b/client/core/errorstrings.cpp index 49534606..70f433c6 100644 --- a/client/core/errorstrings.cpp +++ b/client/core/errorstrings.cpp @@ -63,7 +63,8 @@ QString errorString(ErrorCode code) { case (ErrorCode::ApiConfigTimeoutError): errorMessage = QObject::tr("Server response timeout on api request"); break; case (ErrorCode::ApiMissingAgwPublicKey): errorMessage = QObject::tr("Missing AGW public key"); break; case (ErrorCode::ApiConfigDecryptionError): errorMessage = QObject::tr("Failed to decrypt response payload"); break; - + case (ErrorCode::ApiServicesMissingError): errorMessage = QObject::tr("Missing list of available services"); break; + // QFile errors case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break; case(ErrorCode::ReadError): errorMessage = QObject::tr("QFile error: An error occurred when reading from the file"); break; diff --git a/client/ui/controllers/connectionController.cpp b/client/ui/controllers/connectionController.cpp index f8516f6e..f9491d4e 100644 --- a/client/ui/controllers/connectionController.cpp +++ b/client/ui/controllers/connectionController.cpp @@ -55,7 +55,7 @@ void ConnectionController::openConnection() && !m_serversModel->data(serverIndex, ServersModel::Roles::HasInstalledContainers).toBool()) { emit updateApiConfigFromGateway(); } else if (configVersion && m_serversModel->isApiKeyExpired(serverIndex)) { - qDebug() << "attempt to update api config by end_date event"; + qDebug() << "attempt to update api config by expires_at event"; if (configVersion == ApiConfigSources::Telegram) { emit updateApiConfigFromTelegram(); } else { diff --git a/client/ui/models/apiServicesModel.cpp b/client/ui/models/apiServicesModel.cpp index 2a87bde3..81a10f87 100644 --- a/client/ui/models/apiServicesModel.cpp +++ b/client/ui/models/apiServicesModel.cpp @@ -27,6 +27,9 @@ namespace constexpr char storeEndpoint[] = "store_endpoint"; constexpr char isAvailable[] = "is_available"; + + constexpr char subscription[] = "subscription"; + constexpr char endDate[] = "end_date"; } namespace serviceType @@ -51,23 +54,23 @@ QVariant ApiServicesModel::data(const QModelIndex &index, int role) const if (!index.isValid() || index.row() < 0 || index.row() >= static_cast(rowCount())) return QVariant(); - QJsonObject service = m_services.at(index.row()).toObject(); - QJsonObject serviceInfo = service.value(configKey::serviceInfo).toObject(); - auto serviceType = service.value(configKey::serviceType).toString(); + auto apiServiceData = m_services.at(index.row()); + auto serviceType = apiServiceData.type; + auto isServiceAvailable = apiServiceData.isServiceAvailable; switch (role) { case NameRole: { - return serviceInfo.value(configKey::name).toString(); + return apiServiceData.serviceInfo.name; } case CardDescriptionRole: { - auto speed = serviceInfo.value(configKey::speed).toString(); + auto speed = apiServiceData.serviceInfo.speed; if (serviceType == serviceType::amneziaPremium) { return tr("Classic VPN for comfortable work, downloading large files and watching videos. " "Works for any sites. Speed up to %1 MBit/s") .arg(speed); } else if (serviceType == serviceType::amneziaFree){ QString description = tr("VPN to access blocked sites in regions with high levels of Internet censorship. "); - if (service.value(configKey::isAvailable).isBool() && !service.value(configKey::isAvailable).toBool()) { + if (isServiceAvailable) { description += tr("

Not available in your region. If you have VPN enabled, disable it, return to the previous screen, and try again."); } return description; @@ -83,25 +86,24 @@ QVariant ApiServicesModel::data(const QModelIndex &index, int role) const } case IsServiceAvailableRole: { if (serviceType == serviceType::amneziaFree) { - if (service.value(configKey::isAvailable).isBool() && !service.value(configKey::isAvailable).toBool()) { + if (isServiceAvailable) { return false; } } return true; } case SpeedRole: { - auto speed = serviceInfo.value(configKey::speed).toString(); - return tr("%1 MBit/s").arg(speed); + return tr("%1 MBit/s").arg(apiServiceData.serviceInfo.speed); } - case WorkPeriodRole: { - auto timelimit = serviceInfo.value(configKey::timelimit).toString(); - if (timelimit == "0") { + case TimeLimitRole: { + auto timeLimit = apiServiceData.serviceInfo.timeLimit; + if (timeLimit == "0") { return ""; } - return tr("%1 days").arg(timelimit); + return tr("%1 days").arg(timeLimit); } case RegionRole: { - return serviceInfo.value(configKey::region).toString(); + return apiServiceData.serviceInfo.region; } case FeaturesRole: { if (serviceType == serviceType::amneziaPremium) { @@ -113,12 +115,15 @@ QVariant ApiServicesModel::data(const QModelIndex &index, int role) const } } case PriceRole: { - auto price = serviceInfo.value(configKey::price).toString(); + auto price = apiServiceData.serviceInfo.price; if (price == "free") { return tr("Free"); } return tr("%1 $/month").arg(price); } + case EndDateRole: { + return QDateTime::fromString(apiServiceData.subscription.endDate, Qt::ISODate).toLocalTime().toString("d MMM yyyy"); + } } return QVariant(); @@ -128,15 +133,18 @@ void ApiServicesModel::updateModel(const QJsonObject &data) { beginResetModel(); - m_countryCode = data.value(configKey::userCountryCode).toString(); - m_services = data.value(configKey::services).toArray(); - if (m_services.isEmpty()) { - QJsonObject service; - service.insert(configKey::serviceInfo, data.value(configKey::serviceInfo)); - service.insert(configKey::serviceType, data.value(configKey::serviceType)); + m_services.clear(); - m_services.push_back(service); + m_countryCode = data.value(configKey::userCountryCode).toString(); + auto services = data.value(configKey::services).toArray(); + + if (services.isEmpty()) { + m_services.push_back(getApiServicesData(data)); m_selectedServiceIndex = 0; + } else { + for (const auto &service : services) { + m_services.push_back(getApiServicesData(service.toObject())); + } } endResetModel(); @@ -149,32 +157,32 @@ void ApiServicesModel::setServiceIndex(const int index) QJsonObject ApiServicesModel::getSelectedServiceInfo() { - QJsonObject service = m_services.at(m_selectedServiceIndex).toObject(); - return service.value(configKey::serviceInfo).toObject(); + auto service = m_services.at(m_selectedServiceIndex); + return service.serviceInfo.object; } QString ApiServicesModel::getSelectedServiceType() { - QJsonObject service = m_services.at(m_selectedServiceIndex).toObject(); - return service.value(configKey::serviceType).toString(); + auto service = m_services.at(m_selectedServiceIndex); + return service.type; } QString ApiServicesModel::getSelectedServiceProtocol() { - QJsonObject service = m_services.at(m_selectedServiceIndex).toObject(); - return service.value(configKey::serviceProtocol).toString(); + auto service = m_services.at(m_selectedServiceIndex); + return service.protocol; } QString ApiServicesModel::getSelectedServiceName() { - auto modelIndex = index(m_selectedServiceIndex, 0); - return data(modelIndex, ApiServicesModel::Roles::NameRole).toString(); + auto service = m_services.at(m_selectedServiceIndex); + return service.serviceInfo.name; } QJsonArray ApiServicesModel::getSelectedServiceCountries() { - QJsonObject service = m_services.at(m_selectedServiceIndex).toObject(); - return service.value(configKey::availableCountries).toArray(); + auto service = m_services.at(m_selectedServiceIndex); + return service.availableCountries; } QString ApiServicesModel::getCountryCode() @@ -184,8 +192,8 @@ QString ApiServicesModel::getCountryCode() QString ApiServicesModel::getStoreEndpoint() { - QJsonObject service = m_services.at(m_selectedServiceIndex).toObject(); - return service.value(configKey::storeEndpoint).toString(); + auto service = m_services.at(m_selectedServiceIndex); + return service.storeEndpoint; } QVariant ApiServicesModel::getSelectedServiceData(const QString roleString) @@ -209,10 +217,46 @@ QHash ApiServicesModel::roleNames() const roles[ServiceDescriptionRole] = "serviceDescription"; roles[IsServiceAvailableRole] = "isServiceAvailable"; roles[SpeedRole] = "speed"; - roles[WorkPeriodRole] = "workPeriod"; + roles[TimeLimitRole] = "timeLimit"; roles[RegionRole] = "region"; roles[FeaturesRole] = "features"; roles[PriceRole] = "price"; + roles[EndDateRole] = "endDate"; return roles; } + +ApiServicesModel::ApiServicesData ApiServicesModel::getApiServicesData(const QJsonObject &data) +{ + auto serviceInfo = data.value(configKey::serviceInfo).toObject(); + auto serviceType = data.value(configKey::serviceType).toString(); + auto serviceProtocol = data.value(configKey::serviceProtocol).toString(); + auto availableCountries = data.value(configKey::availableCountries).toArray(); + + auto subscriptionObject = data.value(configKey::subscription).toObject(); + + ApiServicesData serviceData; + serviceData.serviceInfo.name = serviceInfo.value(configKey::name).toString(); + serviceData.serviceInfo.price = serviceInfo.value(configKey::price).toString(); + serviceData.serviceInfo.region = serviceInfo.value(configKey::region).toString(); + serviceData.serviceInfo.speed = serviceInfo.value(configKey::speed).toString(); + serviceData.serviceInfo.timeLimit = serviceInfo.value(configKey::timelimit).toString(); + + serviceData.type = serviceType; + serviceData.protocol = serviceProtocol; + + serviceData.storeEndpoint = serviceInfo.value(configKey::storeEndpoint).toString(); + + if (serviceInfo.value(configKey::isAvailable).isBool()) { + serviceData.isServiceAvailable = data.value(configKey::isAvailable).toBool(); + } else { + serviceData.isServiceAvailable = true; + } + + serviceData.serviceInfo.object = serviceInfo; + serviceData.availableCountries = availableCountries; + + serviceData.subscription.endDate = subscriptionObject.value(configKey::endDate).toString(); + + return serviceData; +} diff --git a/client/ui/models/apiServicesModel.h b/client/ui/models/apiServicesModel.h index 49918940..c96a49ab 100644 --- a/client/ui/models/apiServicesModel.h +++ b/client/ui/models/apiServicesModel.h @@ -3,6 +3,7 @@ #include #include +#include class ApiServicesModel : public QAbstractListModel { @@ -15,10 +16,11 @@ public: ServiceDescriptionRole, IsServiceAvailableRole, SpeedRole, - WorkPeriodRole, + TimeLimitRole, RegionRole, FeaturesRole, - PriceRole + PriceRole, + EndDateRole }; explicit ApiServicesModel(QObject *parent = nullptr); @@ -48,8 +50,40 @@ protected: QHash roleNames() const override; private: + struct ServiceInfo + { + QString name; + QString speed; + QString timeLimit; + QString region; + QString price; + + QJsonObject object; + }; + + struct Subscription + { + QString endDate; + }; + + struct ApiServicesData + { + bool isServiceAvailable; + + QString type; + QString protocol; + QString storeEndpoint; + + ServiceInfo serviceInfo; + Subscription subscription; + + QJsonArray availableCountries; + }; + + ApiServicesData getApiServicesData(const QJsonObject &data); + QString m_countryCode; - QJsonArray m_services; + QVector m_services; int m_selectedServiceIndex; }; diff --git a/client/ui/models/servers_model.cpp b/client/ui/models/servers_model.cpp index c87499a7..b72b10c3 100644 --- a/client/ui/models/servers_model.cpp +++ b/client/ui/models/servers_model.cpp @@ -22,7 +22,7 @@ namespace constexpr char serviceProtocol[] = "service_protocol"; constexpr char publicKeyInfo[] = "public_key"; - constexpr char endDate[] = "end_date"; + constexpr char expiresAt[] = "expires_at"; } } @@ -39,6 +39,9 @@ ServersModel::ServersModel(std::shared_ptr settings, QObject *parent) emit ServersModel::defaultServerNameChanged(); updateDefaultServerContainersModel(); }); + + connect(this, &ServersModel::processedServerIndexChanged, this, &ServersModel::processedServerChanged); + connect(this, &ServersModel::dataChanged, this, &ServersModel::processedServerChanged); } int ServersModel::rowCount(const QModelIndex &parent) const @@ -79,6 +82,12 @@ bool ServersModel::setData(const QModelIndex &index, const QVariant &value, int return true; } +bool ServersModel::setData(const int index, const QVariant &value, int role) +{ + QModelIndex modelIndex = this->index(index); + return setData(modelIndex, value, role); +} + QVariant ServersModel::data(const QModelIndex &index, int role) const { if (!index.isValid() || index.row() < 0 || index.row() >= static_cast(m_servers.size())) { @@ -679,6 +688,18 @@ QVariant ServersModel::getProcessedServerData(const QString roleString) return {}; } +bool ServersModel::setProcessedServerData(const QString &roleString, const QVariant &value) +{ + const auto roles = roleNames(); + for (auto it = roles.begin(); it != roles.end(); it++) { + if (QString(it.value()) == roleString) { + return setData(m_processedServerIndex, value, it.key()); + } + } + + return false; +} + bool ServersModel::isDefaultServerDefaultContainerHasSplitTunneling() { auto server = m_servers.at(m_defaultServerIndex).toObject(); @@ -718,9 +739,9 @@ bool ServersModel::isApiKeyExpired(const int serverIndex) auto apiConfig = serverConfig.value(configKey::apiConfig).toObject(); auto publicKeyInfo = apiConfig.value(configKey::publicKeyInfo).toObject(); - const QString endDate = publicKeyInfo.value(configKey::endDate).toString(); - if (endDate.isEmpty()) { - publicKeyInfo.insert(configKey::endDate, QDateTime::currentDateTimeUtc().addDays(1).toString(Qt::ISODate)); + const QString expiresAt = publicKeyInfo.value(configKey::expiresAt).toString(); + if (expiresAt.isEmpty()) { + publicKeyInfo.insert(configKey::expiresAt, QDateTime::currentDateTimeUtc().addDays(1).toString(Qt::ISODate)); apiConfig.insert(configKey::publicKeyInfo, publicKeyInfo); serverConfig.insert(configKey::apiConfig, apiConfig); editServer(serverConfig, serverIndex); @@ -728,8 +749,8 @@ bool ServersModel::isApiKeyExpired(const int serverIndex) return false; } - auto endDateDateTime = QDateTime::fromString(endDate, Qt::ISODate).toUTC(); - if (endDateDateTime < QDateTime::currentDateTimeUtc()) { + auto expiresAtDateTime = QDateTime::fromString(expiresAt, Qt::ISODate).toUTC(); + if (expiresAtDateTime < QDateTime::currentDateTimeUtc()) { return true; } return false; diff --git a/client/ui/models/servers_model.h b/client/ui/models/servers_model.h index 0f18ea30..78bc22cc 100644 --- a/client/ui/models/servers_model.h +++ b/client/ui/models/servers_model.h @@ -46,6 +46,7 @@ public: int rowCount(const QModelIndex &parent = QModelIndex()) const override; bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override; + bool setData(const int index, const QVariant &value, int role = Qt::EditRole); QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; QVariant data(const int index, int role = Qt::DisplayRole) const; @@ -115,6 +116,7 @@ public slots: QVariant getDefaultServerData(const QString roleString); QVariant getProcessedServerData(const QString roleString); + bool setProcessedServerData(const QString &roleString, const QVariant &value); bool isDefaultServerDefaultContainerHasSplitTunneling(); @@ -127,6 +129,9 @@ protected: signals: void processedServerIndexChanged(const int index); + // emitted when the processed server index or processed server data is changed + void processedServerChanged(); + void defaultServerIndexChanged(const int index); void defaultServerNameChanged(); void defaultServerDescriptionChanged(); diff --git a/client/ui/qml/Pages2/PageSettingsApiLanguageList.qml b/client/ui/qml/Pages2/PageSettingsApiLanguageList.qml index 120313cd..600db85d 100644 --- a/client/ui/qml/Pages2/PageSettingsApiLanguageList.qml +++ b/client/ui/qml/Pages2/PageSettingsApiLanguageList.qml @@ -54,8 +54,14 @@ PageType { imageSource: "qrc:/images/controls/download.svg" checked: index === ApiCountryModel.currentIndex + checkable: !ConnectionController.isConnected onClicked: { + if (ConnectionController.isConnected) { + PageController.showNotificationMessage(qsTr("Unable change server location while there is an active connection")) + return + } + if (index !== ApiCountryModel.currentIndex) { PageController.showBusyIndicator(true) var prevIndex = ApiCountryModel.currentIndex diff --git a/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml b/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml index 2d6c1d9b..167e56e5 100644 --- a/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml +++ b/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml @@ -56,12 +56,15 @@ PageType { } LabelWithImageType { + property bool showSubscriptionEndDate: ServersModel.getProcessedServerData("isCountrySelectionAvailable") + Layout.fillWidth: true Layout.margins: 16 imageSource: "qrc:/images/controls/history.svg" - leftText: qsTr("Work period") - rightText: ApiServicesModel.getSelectedServiceData("workPeriod") + leftText: showSubscriptionEndDate ? qsTr("Valid until") : qsTr("Work period") + rightText: showSubscriptionEndDate ? ApiServicesModel.getSelectedServiceData("endDate") + : ApiServicesModel.getSelectedServiceData("workPeriod") visible: rightText !== "" } diff --git a/client/ui/qml/Pages2/PageSettingsServerInfo.qml b/client/ui/qml/Pages2/PageSettingsServerInfo.qml index 95ae5c8a..ffcfb441 100644 --- a/client/ui/qml/Pages2/PageSettingsServerInfo.qml +++ b/client/ui/qml/Pages2/PageSettingsServerInfo.qml @@ -25,6 +25,8 @@ PageType { property int pageSettingsApiServerInfo: 3 property int pageSettingsApiLanguageList: 4 + property var processedServer + defaultActiveFocusItem: focusItem Connections { @@ -35,8 +37,18 @@ PageType { } } + Connections { + target: ServersModel + + function onProcessedServerChanged() { + root.processedServer = proxyServersModel.get(0) + } + } + SortFilterProxyModel { id: proxyServersModel + objectName: "proxyServersModel" + sourceModel: ServersModel filters: [ ValueFilter { @@ -44,147 +56,139 @@ PageType { value: true } ] + + Component.onCompleted: { + root.processedServer = proxyServersModel.get(0) + } } Item { id: focusItem - KeyNavigation.tab: header + //KeyNavigation.tab: header } ColumnLayout { anchors.fill: parent - spacing: 16 + spacing: 4 - Repeater { - id: header - model: proxyServersModel + BackButtonType { + id: backButton - activeFocusOnTab: true - onFocusChanged: { - header.itemAt(0).focusItem.forceActiveFocus() + Layout.topMargin: 20 + KeyNavigation.tab: headerContent.actionButton + + backButtonFunction: function() { + if (nestedStackView.currentIndex === root.pageSettingsApiServerInfo && + root.processedServer.isCountrySelectionAvailable) { + nestedStackView.currentIndex = root.pageSettingsApiLanguageList + } else { + PageController.closePage() + } + } + } + + HeaderType { + id: headerContent + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + + actionButtonImage: nestedStackView.currentIndex === root.pageSettingsApiLanguageList ? "qrc:/images/controls/settings.svg" + : "qrc:/images/controls/edit-3.svg" + + headerText: root.processedServer.name + descriptionText: { + if (root.processedServer.isServerFromGatewayApi) { + if (nestedStackView.currentIndex === root.pageSettingsApiLanguageList) { + return qsTr("Subscription is valid until ") + ApiServicesModel.getSelectedServiceData("endDate") + } else { + return ApiServicesModel.getSelectedServiceData("serviceDescription") + } + } else if (root.processedServer.isServerFromTelegramApi) { + return root.processedServer.serverDescription + } else if (root.processedServer.hasWriteAccess) { + return root.processedServer.credentialsLogin + " · " + root.processedServer.hostName + } else { + return root.processedServer.hostName + } } - delegate: ColumnLayout { + KeyNavigation.tab: tabBar - property alias focusItem: backButton + actionButtonFunction: function() { + if (nestedStackView.currentIndex === root.pageSettingsApiLanguageList) { + nestedStackView.currentIndex = root.pageSettingsApiServerInfo + } else { + serverNameEditDrawer.open() + } + } + } - id: content + DrawerType2 { + id: serverNameEditDrawer - Layout.topMargin: 20 + parent: root - BackButtonType { - id: backButton - KeyNavigation.tab: headerContent.actionButton + anchors.fill: parent + expandedHeight: root.height * 0.35 - backButtonFunction: function() { - if (nestedStackView.currentIndex === root.pageSettingsApiServerInfo && - ServersModel.getProcessedServerData("isCountrySelectionAvailable")) { - nestedStackView.currentIndex = root.pageSettingsApiLanguageList - } else { - PageController.closePage() - } + onClosed: { + if (!GC.isMobile()) { + headerContent.actionButton.forceActiveFocus() + } + } + + expandedContent: ColumnLayout { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 32 + anchors.leftMargin: 16 + anchors.rightMargin: 16 + + Connections { + target: serverNameEditDrawer + enabled: !GC.isMobile() + function onOpened() { + serverName.textField.forceActiveFocus() } } - HeaderType { - id: headerContent + Item { + id: focusItem1 + KeyNavigation.tab: serverName.textField + } + + TextFieldWithHeaderType { + id: serverName + Layout.fillWidth: true - Layout.leftMargin: 16 - Layout.rightMargin: 16 + headerText: qsTr("Server name") + textFieldText: root.processedServer.name + textField.maximumLength: 30 + checkEmptyText: true - actionButtonImage: nestedStackView.currentIndex === root.pageSettingsApiLanguageList ? "qrc:/images/controls/settings.svg" : "qrc:/images/controls/edit-3.svg" - - headerText: name - descriptionText: { - if (ServersModel.getProcessedServerData("isServerFromGatewayApi")) { - return ApiServicesModel.getSelectedServiceData("serviceDescription") - } else if (ServersModel.getProcessedServerData("isServerFromTelegramApi")) { - return serverDescription - } else if (ServersModel.isProcessedServerHasWriteAccess()) { - return credentialsLogin + " · " + hostName - } else { - return hostName - } - } - - KeyNavigation.tab: tabBar - - actionButtonFunction: function() { - if (nestedStackView.currentIndex === root.pageSettingsApiLanguageList) { - nestedStackView.currentIndex = root.pageSettingsApiServerInfo - } else { - serverNameEditDrawer.open() - } - } + KeyNavigation.tab: saveButton } - DrawerType2 { - id: serverNameEditDrawer + BasicButtonType { + id: saveButton - parent: root + Layout.fillWidth: true - anchors.fill: parent - expandedHeight: root.height * 0.35 + text: qsTr("Save") + KeyNavigation.tab: focusItem1 - onClosed: { - if (!GC.isMobile()) { - headerContent.actionButton.forceActiveFocus() - } - } - - expandedContent: ColumnLayout { - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.topMargin: 32 - anchors.leftMargin: 16 - anchors.rightMargin: 16 - - Connections { - target: serverNameEditDrawer - enabled: !GC.isMobile() - function onOpened() { - serverName.textField.forceActiveFocus() - } + clickedFunc: function() { + if (serverName.textFieldText === "") { + return } - Item { - id: focusItem1 - KeyNavigation.tab: serverName.textField - } - - TextFieldWithHeaderType { - id: serverName - - Layout.fillWidth: true - headerText: qsTr("Server name") - textFieldText: name - textField.maximumLength: 30 - checkEmptyText: true - - KeyNavigation.tab: saveButton - } - - BasicButtonType { - id: saveButton - - Layout.fillWidth: true - - text: qsTr("Save") - KeyNavigation.tab: focusItem1 - - clickedFunc: function() { - if (serverName.textFieldText === "") { - return - } - - if (serverName.textFieldText !== name) { - name = serverName.textFieldText - } - serverNameEditDrawer.close() - } + if (serverName.textFieldText !== root.processedServer.name) { + ServersModel.setProcessedServerData("name", serverName.textFieldText); } + serverNameEditDrawer.close() } } } @@ -257,8 +261,7 @@ PageType { StackLayout { id: nestedStackView - Layout.preferredWidth: root.width - Layout.preferredHeight: root.height - tabBar.implicitHeight - header.implicitHeight + Layout.fillWidth: true currentIndex: ServersModel.getProcessedServerData("isServerFromGatewayApi") ? (ServersModel.getProcessedServerData("isCountrySelectionAvailable") ? From d06924c59dd8684c28b6257efe5d1a11db34b19b Mon Sep 17 00:00:00 2001 From: Cyril Anisimov Date: Tue, 10 Dec 2024 03:17:16 +0100 Subject: [PATCH 08/10] feature/xray user management (#972) * feature: implement client management functionality for Xray --------- Co-authored-by: aiamnezia Co-authored-by: vladimir.kuznetsov --- client/configurators/xray_configurator.cpp | 165 +++++++++- client/configurators/xray_configurator.h | 4 + client/ui/controllers/exportController.cpp | 9 +- client/ui/controllers/exportController.h | 2 +- client/ui/models/clientManagementModel.cpp | 353 +++++++++++++++++++-- client/ui/models/clientManagementModel.h | 6 + client/ui/qml/Pages2/PageShare.qml | 2 +- 7 files changed, 495 insertions(+), 46 deletions(-) diff --git a/client/configurators/xray_configurator.cpp b/client/configurators/xray_configurator.cpp index 786da47c..514aa821 100644 --- a/client/configurators/xray_configurator.cpp +++ b/client/configurators/xray_configurator.cpp @@ -3,38 +3,169 @@ #include #include #include +#include +#include "logger.h" #include "containers/containers_defs.h" #include "core/controllers/serverController.h" #include "core/scripts_registry.h" +namespace { +Logger logger("XrayConfigurator"); +} + XrayConfigurator::XrayConfigurator(std::shared_ptr settings, const QSharedPointer &serverController, QObject *parent) : ConfiguratorBase(settings, serverController, parent) { } -QString XrayConfigurator::createConfig(const ServerCredentials &credentials, DockerContainer container, const QJsonObject &containerConfig, - ErrorCode &errorCode) +QString XrayConfigurator::prepareServerConfig(const ServerCredentials &credentials, DockerContainer container, + const QJsonObject &containerConfig, ErrorCode &errorCode) { - QString config = m_serverController->replaceVars(amnezia::scriptData(ProtocolScriptType::xray_template, container), - m_serverController->genVarsForScript(credentials, container, containerConfig)); - - QString xrayPublicKey = - m_serverController->getTextFileFromContainer(container, credentials, amnezia::protocols::xray::PublicKeyPath, errorCode); - xrayPublicKey.replace("\n", ""); - - QString xrayUuid = m_serverController->getTextFileFromContainer(container, credentials, amnezia::protocols::xray::uuidPath, errorCode); - xrayUuid.replace("\n", ""); - - QString xrayShortId = - m_serverController->getTextFileFromContainer(container, credentials, amnezia::protocols::xray::shortidPath, errorCode); - xrayShortId.replace("\n", ""); - + // Generate new UUID for client + QString clientId = QUuid::createUuid().toString(QUuid::WithoutBraces); + + // Get current server config + QString currentConfig = m_serverController->getTextFileFromContainer( + container, credentials, amnezia::protocols::xray::serverConfigPath, errorCode); + if (errorCode != ErrorCode::NoError) { + logger.error() << "Failed to get server config file"; return ""; } - config.replace("$XRAY_CLIENT_ID", xrayUuid); + // Parse current config as JSON + QJsonDocument doc = QJsonDocument::fromJson(currentConfig.toUtf8()); + if (doc.isNull() || !doc.isObject()) { + logger.error() << "Failed to parse server config JSON"; + errorCode = ErrorCode::InternalError; + return ""; + } + + QJsonObject serverConfig = doc.object(); + + // Validate server config structure + if (!serverConfig.contains("inbounds")) { + logger.error() << "Server config missing 'inbounds' field"; + errorCode = ErrorCode::InternalError; + return ""; + } + + QJsonArray inbounds = serverConfig["inbounds"].toArray(); + if (inbounds.isEmpty()) { + logger.error() << "Server config has empty 'inbounds' array"; + errorCode = ErrorCode::InternalError; + return ""; + } + + QJsonObject inbound = inbounds[0].toObject(); + if (!inbound.contains("settings")) { + logger.error() << "Inbound missing 'settings' field"; + errorCode = ErrorCode::InternalError; + return ""; + } + + QJsonObject settings = inbound["settings"].toObject(); + if (!settings.contains("clients")) { + logger.error() << "Settings missing 'clients' field"; + errorCode = ErrorCode::InternalError; + return ""; + } + + QJsonArray clients = settings["clients"].toArray(); + + // Create configuration for new client + QJsonObject clientConfig { + {"id", clientId}, + {"flow", "xtls-rprx-vision"} + }; + + clients.append(clientConfig); + + // Update config + settings["clients"] = clients; + inbound["settings"] = settings; + inbounds[0] = inbound; + serverConfig["inbounds"] = inbounds; + + // Save updated config to server + QString updatedConfig = QJsonDocument(serverConfig).toJson(); + errorCode = m_serverController->uploadTextFileToContainer( + container, + credentials, + updatedConfig, + amnezia::protocols::xray::serverConfigPath, + libssh::ScpOverwriteMode::ScpOverwriteExisting + ); + if (errorCode != ErrorCode::NoError) { + logger.error() << "Failed to upload updated config"; + return ""; + } + + // Restart container + QString restartScript = QString("sudo docker restart $CONTAINER_NAME"); + errorCode = m_serverController->runScript( + credentials, + m_serverController->replaceVars(restartScript, m_serverController->genVarsForScript(credentials, container)) + ); + + if (errorCode != ErrorCode::NoError) { + logger.error() << "Failed to restart container"; + return ""; + } + + return clientId; +} + +QString XrayConfigurator::createConfig(const ServerCredentials &credentials, DockerContainer container, + const QJsonObject &containerConfig, ErrorCode &errorCode) +{ + // Get client ID from prepareServerConfig + QString xrayClientId = prepareServerConfig(credentials, container, containerConfig, errorCode); + if (errorCode != ErrorCode::NoError || xrayClientId.isEmpty()) { + logger.error() << "Failed to prepare server config"; + errorCode = ErrorCode::InternalError; + return ""; + } + + QString config = m_serverController->replaceVars(amnezia::scriptData(ProtocolScriptType::xray_template, container), + m_serverController->genVarsForScript(credentials, container, containerConfig)); + + if (config.isEmpty()) { + logger.error() << "Failed to get config template"; + errorCode = ErrorCode::InternalError; + return ""; + } + + QString xrayPublicKey = + m_serverController->getTextFileFromContainer(container, credentials, amnezia::protocols::xray::PublicKeyPath, errorCode); + if (errorCode != ErrorCode::NoError || xrayPublicKey.isEmpty()) { + logger.error() << "Failed to get public key"; + errorCode = ErrorCode::InternalError; + return ""; + } + xrayPublicKey.replace("\n", ""); + + QString xrayShortId = + m_serverController->getTextFileFromContainer(container, credentials, amnezia::protocols::xray::shortidPath, errorCode); + if (errorCode != ErrorCode::NoError || xrayShortId.isEmpty()) { + logger.error() << "Failed to get short ID"; + errorCode = ErrorCode::InternalError; + return ""; + } + xrayShortId.replace("\n", ""); + + // Validate all required variables are present + if (!config.contains("$XRAY_CLIENT_ID") || !config.contains("$XRAY_PUBLIC_KEY") || !config.contains("$XRAY_SHORT_ID")) { + logger.error() << "Config template missing required variables:" + << "XRAY_CLIENT_ID:" << !config.contains("$XRAY_CLIENT_ID") + << "XRAY_PUBLIC_KEY:" << !config.contains("$XRAY_PUBLIC_KEY") + << "XRAY_SHORT_ID:" << !config.contains("$XRAY_SHORT_ID"); + errorCode = ErrorCode::InternalError; + return ""; + } + + config.replace("$XRAY_CLIENT_ID", xrayClientId); config.replace("$XRAY_PUBLIC_KEY", xrayPublicKey); config.replace("$XRAY_SHORT_ID", xrayShortId); diff --git a/client/configurators/xray_configurator.h b/client/configurators/xray_configurator.h index 2acfdf71..8ed4e775 100644 --- a/client/configurators/xray_configurator.h +++ b/client/configurators/xray_configurator.h @@ -14,6 +14,10 @@ public: QString createConfig(const ServerCredentials &credentials, DockerContainer container, const QJsonObject &containerConfig, ErrorCode &errorCode); + +private: + QString prepareServerConfig(const ServerCredentials &credentials, DockerContainer container, const QJsonObject &containerConfig, + ErrorCode &errorCode); }; #endif // XRAY_CONFIGURATOR_H diff --git a/client/ui/controllers/exportController.cpp b/client/ui/controllers/exportController.cpp index 2690b5b1..8681406e 100644 --- a/client/ui/controllers/exportController.cpp +++ b/client/ui/controllers/exportController.cpp @@ -121,9 +121,8 @@ ErrorCode ExportController::generateNativeConfig(const DockerContainer container jsonNativeConfig = QJsonDocument::fromJson(protocolConfigString.toUtf8()).object(); - if (protocol == Proto::OpenVpn || protocol == Proto::WireGuard || protocol == Proto::Awg) { - auto clientId = jsonNativeConfig.value(config_key::clientId).toString(); - errorCode = m_clientManagementModel->appendClient(clientId, clientName, container, credentials, serverController); + if (protocol == Proto::OpenVpn || protocol == Proto::WireGuard || protocol == Proto::Awg || protocol == Proto::Xray) { + errorCode = m_clientManagementModel->appendClient(jsonNativeConfig, clientName, container, credentials, serverController); } return errorCode; } @@ -248,10 +247,10 @@ void ExportController::generateCloakConfig() emit exportConfigChanged(); } -void ExportController::generateXrayConfig() +void ExportController::generateXrayConfig(const QString &clientName) { QJsonObject nativeConfig; - ErrorCode errorCode = generateNativeConfig(DockerContainer::Xray, "", Proto::Xray, nativeConfig); + ErrorCode errorCode = generateNativeConfig(DockerContainer::Xray, clientName, Proto::Xray, nativeConfig); if (errorCode) { emit exportErrorOccurred(errorCode); return; diff --git a/client/ui/controllers/exportController.h b/client/ui/controllers/exportController.h index b031ea39..a2c9fcfa 100644 --- a/client/ui/controllers/exportController.h +++ b/client/ui/controllers/exportController.h @@ -28,7 +28,7 @@ public slots: void generateAwgConfig(const QString &clientName); void generateShadowSocksConfig(); void generateCloakConfig(); - void generateXrayConfig(); + void generateXrayConfig(const QString &clientName); QString getConfig(); QString getNativeConfigString(); diff --git a/client/ui/models/clientManagementModel.cpp b/client/ui/models/clientManagementModel.cpp index 7445d60f..f07eae71 100644 --- a/client/ui/models/clientManagementModel.cpp +++ b/client/ui/models/clientManagementModel.cpp @@ -106,6 +106,8 @@ ErrorCode ClientManagementModel::updateModel(const DockerContainer container, co error = getOpenVpnClients(container, credentials, serverController, count); } else if (container == DockerContainer::WireGuard || container == DockerContainer::Awg) { error = getWireGuardClients(container, credentials, serverController, count); + } else if (container == DockerContainer::Xray) { + error = getXrayClients(container, credentials, serverController, count); } if (error != ErrorCode::NoError) { endResetModel(); @@ -239,6 +241,68 @@ ErrorCode ClientManagementModel::getWireGuardClients(const DockerContainer conta } return error; } +ErrorCode ClientManagementModel::getXrayClients(const DockerContainer container, const ServerCredentials& credentials, + const QSharedPointer &serverController, int &count) +{ + ErrorCode error = ErrorCode::NoError; + + const QString serverConfigPath = amnezia::protocols::xray::serverConfigPath; + const QString configString = serverController->getTextFileFromContainer(container, credentials, serverConfigPath, error); + if (error != ErrorCode::NoError) { + logger.error() << "Failed to get the xray server config file from the server"; + return error; + } + + QJsonDocument serverConfig = QJsonDocument::fromJson(configString.toUtf8()); + if (serverConfig.isNull()) { + logger.error() << "Failed to parse xray server config JSON"; + return ErrorCode::InternalError; + } + + if (!serverConfig.object().contains("inbounds") || serverConfig.object()["inbounds"].toArray().isEmpty()) { + logger.error() << "Invalid xray server config structure"; + return ErrorCode::InternalError; + } + + const QJsonObject inbound = serverConfig.object()["inbounds"].toArray()[0].toObject(); + if (!inbound.contains("settings")) { + logger.error() << "Missing settings in xray inbound config"; + return ErrorCode::InternalError; + } + + const QJsonObject settings = inbound["settings"].toObject(); + if (!settings.contains("clients")) { + logger.error() << "Missing clients in xray settings config"; + return ErrorCode::InternalError; + } + + const QJsonArray clients = settings["clients"].toArray(); + for (const auto &clientValue : clients) { + const QJsonObject clientObj = clientValue.toObject(); + if (!clientObj.contains("id")) { + logger.error() << "Missing id in xray client config"; + continue; + } + QString clientId = clientObj["id"].toString(); + + QString xrayDefaultUuid = serverController->getTextFileFromContainer(container, credentials, amnezia::protocols::xray::uuidPath, error); + xrayDefaultUuid.replace("\n", ""); + + if (!isClientExists(clientId) && clientId != xrayDefaultUuid) { + QJsonObject client; + client[configKey::clientId] = clientId; + + QJsonObject userData; + userData[configKey::clientName] = QString("Client %1").arg(count); + client[configKey::userData] = userData; + + m_clientsTable.push_back(client); + count++; + } + } + + return error; +} ErrorCode ClientManagementModel::wgShow(const DockerContainer container, const ServerCredentials &credentials, const QSharedPointer &serverController, std::vector &data) @@ -326,17 +390,67 @@ ErrorCode ClientManagementModel::appendClient(const DockerContainer container, c const QSharedPointer &serverController) { Proto protocol; - if (container == DockerContainer::ShadowSocks || container == DockerContainer::Cloak) { - protocol = Proto::OpenVpn; - } else if (container == DockerContainer::OpenVpn || container == DockerContainer::WireGuard || container == DockerContainer::Awg) { - protocol = ContainerProps::defaultProtocol(container); - } else { - return ErrorCode::NoError; + switch (container) { + case DockerContainer::ShadowSocks: + case DockerContainer::Cloak: + protocol = Proto::OpenVpn; + break; + case DockerContainer::OpenVpn: + case DockerContainer::WireGuard: + case DockerContainer::Awg: + case DockerContainer::Xray: + protocol = ContainerProps::defaultProtocol(container); + break; + default: + return ErrorCode::NoError; } auto protocolConfig = ContainerProps::getProtocolConfigFromContainer(protocol, containerConfig); + return appendClient(protocolConfig, clientName, container, credentials, serverController); +} - return appendClient(protocolConfig.value(config_key::clientId).toString(), clientName, container, credentials, serverController); +ErrorCode ClientManagementModel::appendClient(QJsonObject &protocolConfig, const QString &clientName, const DockerContainer container, + const ServerCredentials &credentials, const QSharedPointer &serverController) +{ + QString clientId; + if (container == DockerContainer::Xray) { + if (!protocolConfig.contains("outbounds")) { + return ErrorCode::InternalError; + } + QJsonArray outbounds = protocolConfig.value("outbounds").toArray(); + if (outbounds.isEmpty()) { + return ErrorCode::InternalError; + } + QJsonObject outbound = outbounds[0].toObject(); + if (!outbound.contains("settings")) { + return ErrorCode::InternalError; + } + QJsonObject settings = outbound["settings"].toObject(); + if (!settings.contains("vnext")) { + return ErrorCode::InternalError; + } + QJsonArray vnext = settings["vnext"].toArray(); + if (vnext.isEmpty()) { + return ErrorCode::InternalError; + } + QJsonObject vnextObj = vnext[0].toObject(); + if (!vnextObj.contains("users")) { + return ErrorCode::InternalError; + } + QJsonArray users = vnextObj["users"].toArray(); + if (users.isEmpty()) { + return ErrorCode::InternalError; + } + QJsonObject user = users[0].toObject(); + if (!user.contains("id")) { + return ErrorCode::InternalError; + } + clientId = user["id"].toString(); + } else { + clientId = protocolConfig.value(config_key::clientId).toString(); + } + + return appendClient(clientId, clientName, container, credentials, serverController); } ErrorCode ClientManagementModel::appendClient(const QString &clientId, const QString &clientName, const DockerContainer container, @@ -422,10 +536,27 @@ ErrorCode ClientManagementModel::revokeClient(const int row, const DockerContain auto client = m_clientsTable.at(row).toObject(); QString clientId = client.value(configKey::clientId).toString(); - if (container == DockerContainer::OpenVpn || container == DockerContainer::ShadowSocks || container == DockerContainer::Cloak) { - errorCode = revokeOpenVpn(row, container, credentials, serverIndex, serverController); - } else if (container == DockerContainer::WireGuard || container == DockerContainer::Awg) { - errorCode = revokeWireGuard(row, container, credentials, serverController); + switch(container) + { + case DockerContainer::OpenVpn: + case DockerContainer::ShadowSocks: + case DockerContainer::Cloak: { + errorCode = revokeOpenVpn(row, container, credentials, serverIndex, serverController); + break; + } + case DockerContainer::WireGuard: + case DockerContainer::Awg: { + errorCode = revokeWireGuard(row, container, credentials, serverController); + break; + } + case DockerContainer::Xray: { + errorCode = revokeXray(row, container, credentials, serverController); + break; + } + default: { + logger.error() << "Internal error: received unexpected container type"; + return ErrorCode::InternalError; + } } if (errorCode == ErrorCode::NoError) { @@ -463,19 +594,69 @@ ErrorCode ClientManagementModel::revokeClient(const QJsonObject &containerConfig } Proto protocol; - if (container == DockerContainer::ShadowSocks || container == DockerContainer::Cloak) { - protocol = Proto::OpenVpn; - } else if (container == DockerContainer::OpenVpn || container == DockerContainer::WireGuard || container == DockerContainer::Awg) { - protocol = ContainerProps::defaultProtocol(container); - } else { - return ErrorCode::NoError; + + switch(container) + { + case DockerContainer::ShadowSocks: + case DockerContainer::Cloak: { + protocol = Proto::OpenVpn; + break; + } + case DockerContainer::OpenVpn: + case DockerContainer::WireGuard: + case DockerContainer::Awg: + case DockerContainer::Xray: { + protocol = ContainerProps::defaultProtocol(container); + break; + } + default: { + logger.error() << "Internal error: received unexpected container type"; + return ErrorCode::InternalError; + } } auto protocolConfig = ContainerProps::getProtocolConfigFromContainer(protocol, containerConfig); + QString clientId; + if (container == DockerContainer::Xray) { + if (!protocolConfig.contains("outbounds")) { + return ErrorCode::InternalError; + } + QJsonArray outbounds = protocolConfig.value("outbounds").toArray(); + if (outbounds.isEmpty()) { + return ErrorCode::InternalError; + } + QJsonObject outbound = outbounds[0].toObject(); + if (!outbound.contains("settings")) { + return ErrorCode::InternalError; + } + QJsonObject settings = outbound["settings"].toObject(); + if (!settings.contains("vnext")) { + return ErrorCode::InternalError; + } + QJsonArray vnext = settings["vnext"].toArray(); + if (vnext.isEmpty()) { + return ErrorCode::InternalError; + } + QJsonObject vnextObj = vnext[0].toObject(); + if (!vnextObj.contains("users")) { + return ErrorCode::InternalError; + } + QJsonArray users = vnextObj["users"].toArray(); + if (users.isEmpty()) { + return ErrorCode::InternalError; + } + QJsonObject user = users[0].toObject(); + if (!user.contains("id")) { + return ErrorCode::InternalError; + } + clientId = user["id"].toString(); + } else { + clientId = protocolConfig.value(config_key::clientId).toString(); + } + int row; bool clientExists = false; - QString clientId = protocolConfig.value(config_key::clientId).toString(); for (row = 0; row < rowCount(); row++) { auto client = m_clientsTable.at(row).toObject(); if (clientId == client.value(configKey::clientId).toString()) { @@ -487,11 +668,28 @@ ErrorCode ClientManagementModel::revokeClient(const QJsonObject &containerConfig return errorCode; } - if (container == DockerContainer::OpenVpn || container == DockerContainer::ShadowSocks || container == DockerContainer::Cloak) { + switch (container) + { + case DockerContainer::OpenVpn: + case DockerContainer::ShadowSocks: + case DockerContainer::Cloak: { errorCode = revokeOpenVpn(row, container, credentials, serverIndex, serverController); - } else if (container == DockerContainer::WireGuard || container == DockerContainer::Awg) { - errorCode = revokeWireGuard(row, container, credentials, serverController); + break; } + case DockerContainer::WireGuard: + case DockerContainer::Awg: { + errorCode = revokeWireGuard(row, container, credentials, serverController); + break; + } + case DockerContainer::Xray: { + errorCode = revokeXray(row, container, credentials, serverController); + break; + } + default: + logger.error() << "Internal error: received unexpected container type"; + return ErrorCode::InternalError; + } + return errorCode; } @@ -594,6 +792,117 @@ ErrorCode ClientManagementModel::revokeWireGuard(const int row, const DockerCont return ErrorCode::NoError; } +ErrorCode ClientManagementModel::revokeXray(const int row, + const DockerContainer container, + const ServerCredentials &credentials, + const QSharedPointer &serverController) +{ + ErrorCode error = ErrorCode::NoError; + + // Get server config + const QString serverConfigPath = amnezia::protocols::xray::serverConfigPath; + const QString configString = serverController->getTextFileFromContainer(container, credentials, serverConfigPath, error); + if (error != ErrorCode::NoError) { + logger.error() << "Failed to get the xray server config file"; + return error; + } + + QJsonDocument serverConfig = QJsonDocument::fromJson(configString.toUtf8()); + if (serverConfig.isNull()) { + logger.error() << "Failed to parse xray server config JSON"; + return ErrorCode::InternalError; + } + + // Get client ID to remove + auto client = m_clientsTable.at(row).toObject(); + QString clientId = client.value(configKey::clientId).toString(); + + // Remove client from server config + QJsonObject configObj = serverConfig.object(); + if (!configObj.contains("inbounds")) { + logger.error() << "Missing inbounds in xray config"; + return ErrorCode::InternalError; + } + + QJsonArray inbounds = configObj["inbounds"].toArray(); + if (inbounds.isEmpty()) { + logger.error() << "Empty inbounds array in xray config"; + return ErrorCode::InternalError; + } + + QJsonObject inbound = inbounds[0].toObject(); + if (!inbound.contains("settings")) { + logger.error() << "Missing settings in xray inbound config"; + return ErrorCode::InternalError; + } + + QJsonObject settings = inbound["settings"].toObject(); + if (!settings.contains("clients")) { + logger.error() << "Missing clients in xray settings"; + return ErrorCode::InternalError; + } + + QJsonArray clients = settings["clients"].toArray(); + if (clients.isEmpty()) { + logger.error() << "Empty clients array in xray config"; + return ErrorCode::InternalError; + } + + for (int i = 0; i < clients.size(); ++i) { + QJsonObject clientObj = clients[i].toObject(); + if (clientObj.contains("id") && clientObj["id"].toString() == clientId) { + clients.removeAt(i); + break; + } + } + + // Update server config + settings["clients"] = clients; + inbound["settings"] = settings; + inbounds[0] = inbound; + configObj["inbounds"] = inbounds; + + // Upload updated config + error = serverController->uploadTextFileToContainer( + container, + credentials, + QJsonDocument(configObj).toJson(), + serverConfigPath + ); + if (error != ErrorCode::NoError) { + logger.error() << "Failed to upload updated xray config"; + return error; + } + + // Remove from local table + beginRemoveRows(QModelIndex(), row, row); + m_clientsTable.removeAt(row); + endRemoveRows(); + + // Update clients table file on server + const QByteArray clientsTableString = QJsonDocument(m_clientsTable).toJson(); + QString clientsTableFile = QString("/opt/amnezia/%1/clientsTable") + .arg(ContainerProps::containerTypeToString(container)); + + error = serverController->uploadTextFileToContainer(container, credentials, clientsTableString, clientsTableFile); + if (error != ErrorCode::NoError) { + logger.error() << "Failed to upload the clientsTable file"; + } + + // Restart container + QString restartScript = QString("sudo docker restart $CONTAINER_NAME"); + error = serverController->runScript( + credentials, + serverController->replaceVars(restartScript, serverController->genVarsForScript(credentials, container)) + ); + if (error != ErrorCode::NoError) { + logger.error() << "Failed to restart xray container"; + return error; + } + + return error; +} + QHash ClientManagementModel::roleNames() const { QHash roles; @@ -604,4 +913,4 @@ QHash ClientManagementModel::roleNames() const roles[DataSentRole] = "dataSent"; roles[AllowedIpsRole] = "allowedIps"; return roles; -} +} \ No newline at end of file diff --git a/client/ui/models/clientManagementModel.h b/client/ui/models/clientManagementModel.h index 60132abe..989120a9 100644 --- a/client/ui/models/clientManagementModel.h +++ b/client/ui/models/clientManagementModel.h @@ -40,6 +40,8 @@ public slots: const QSharedPointer &serverController); ErrorCode appendClient(const DockerContainer container, const ServerCredentials &credentials, const QJsonObject &containerConfig, const QString &clientName, const QSharedPointer &serverController); + ErrorCode appendClient(QJsonObject &protocolConfig, const QString &clientName,const DockerContainer container, + const ServerCredentials &credentials, const QSharedPointer &serverController); ErrorCode appendClient(const QString &clientId, const QString &clientName, const DockerContainer container, const ServerCredentials &credentials, const QSharedPointer &serverController); ErrorCode renameClient(const int row, const QString &userName, const DockerContainer container, const ServerCredentials &credentials, @@ -64,11 +66,15 @@ private: const QSharedPointer &serverController); ErrorCode revokeWireGuard(const int row, const DockerContainer container, const ServerCredentials &credentials, const QSharedPointer &serverController); + ErrorCode revokeXray(const int row, const DockerContainer container, const ServerCredentials &credentials, + const QSharedPointer &serverController); ErrorCode getOpenVpnClients(const DockerContainer container, const ServerCredentials &credentials, const QSharedPointer &serverController, int &count); ErrorCode getWireGuardClients(const DockerContainer container, const ServerCredentials &credentials, const QSharedPointer &serverController, int &count); + ErrorCode getXrayClients(const DockerContainer container, const ServerCredentials& credentials, + const QSharedPointer &serverController, int &count); ErrorCode wgShow(const DockerContainer container, const ServerCredentials &credentials, const QSharedPointer &serverController, std::vector &data); diff --git a/client/ui/qml/Pages2/PageShare.qml b/client/ui/qml/Pages2/PageShare.qml index 995fa3e7..d6ce7848 100644 --- a/client/ui/qml/Pages2/PageShare.qml +++ b/client/ui/qml/Pages2/PageShare.qml @@ -92,7 +92,7 @@ PageType { break } case PageShare.ConfigType.Xray: { - ExportController.generateXrayConfig() + ExportController.generateXrayConfig(clientNameTextField.textFieldText) shareConnectionDrawer.configCaption = qsTr("Save XRay config") shareConnectionDrawer.configExtension = ".json" shareConnectionDrawer.configFileName = "amnezia_for_xray" From 2cfdc1df6421b306403778137b4ba35833ef6d22 Mon Sep 17 00:00:00 2001 From: "vladimir.kuznetsov" Date: Fri, 13 Dec 2024 16:08:52 +0700 Subject: [PATCH 09/10] feature: added ui for DefaultVPN --- client/amnezia_application.cpp | 6 +- client/fonts/VelaSans-GX.ttf | Bin 0 -> 190452 bytes client/images/controls/connect-button.svg | 4 + client/resources.qrc | 22 ++ client/ui/controllers/pageController.cpp | 2 +- .../Components/BlueButtonNoBorder.qml | 36 ++++ .../Components/WhiteButtonNoBorder.qml | 36 ++++ .../Components/WhiteButtonWithBorder.qml | 37 ++++ .../ui/qml/DefaultVpn/Config/DeviceInfo.qml | 37 ++++ client/ui/qml/DefaultVpn/Config/Style.qml | 30 +++ client/ui/qml/DefaultVpn/Config/qmldir | 4 + .../DefaultVpn/Controls/BusyIndicatorType.qml | 70 +++++++ .../ui/qml/DefaultVpn/Controls/ButtonType.qml | 154 ++++++++++++++ .../qml/DefaultVpn/Controls/DropDownType.qml | 99 +++++++++ .../ui/qml/DefaultVpn/Controls/InputType.qml | 58 ++++++ .../ui/qml/DefaultVpn/Controls/PopupType.qml | 96 +++++++++ .../Controls/TextTypes/Header1TextType.qml | 15 ++ .../Controls/TextTypes/Header3TextType.qml | 15 ++ .../Controls/TextTypes/MediumTextType.qml | 15 ++ .../Controls/TextTypes/XSmallTextType.qml | 15 ++ client/ui/qml/DefaultVpn/Pages/PageHome.qml | 122 +++++++++++ .../Pages/PageSettingsServerInfo.qml | 103 +++++++++ .../Pages/PageSettingsServersList.qml | 165 +++++++++++++++ .../Pages/PageSetupWizardConfigSource.qml | 112 ++++++++++ client/ui/qml/DefaultVpn/main.qml | 195 ++++++++++++++++++ 25 files changed, 1444 insertions(+), 4 deletions(-) create mode 100644 client/fonts/VelaSans-GX.ttf create mode 100644 client/images/controls/connect-button.svg create mode 100644 client/ui/qml/DefaultVpn/Components/BlueButtonNoBorder.qml create mode 100644 client/ui/qml/DefaultVpn/Components/WhiteButtonNoBorder.qml create mode 100644 client/ui/qml/DefaultVpn/Components/WhiteButtonWithBorder.qml create mode 100644 client/ui/qml/DefaultVpn/Config/DeviceInfo.qml create mode 100644 client/ui/qml/DefaultVpn/Config/Style.qml create mode 100644 client/ui/qml/DefaultVpn/Config/qmldir create mode 100644 client/ui/qml/DefaultVpn/Controls/BusyIndicatorType.qml create mode 100644 client/ui/qml/DefaultVpn/Controls/ButtonType.qml create mode 100644 client/ui/qml/DefaultVpn/Controls/DropDownType.qml create mode 100644 client/ui/qml/DefaultVpn/Controls/InputType.qml create mode 100644 client/ui/qml/DefaultVpn/Controls/PopupType.qml create mode 100644 client/ui/qml/DefaultVpn/Controls/TextTypes/Header1TextType.qml create mode 100644 client/ui/qml/DefaultVpn/Controls/TextTypes/Header3TextType.qml create mode 100644 client/ui/qml/DefaultVpn/Controls/TextTypes/MediumTextType.qml create mode 100644 client/ui/qml/DefaultVpn/Controls/TextTypes/XSmallTextType.qml create mode 100644 client/ui/qml/DefaultVpn/Pages/PageHome.qml create mode 100644 client/ui/qml/DefaultVpn/Pages/PageSettingsServerInfo.qml create mode 100644 client/ui/qml/DefaultVpn/Pages/PageSettingsServersList.qml create mode 100644 client/ui/qml/DefaultVpn/Pages/PageSetupWizardConfigSource.qml create mode 100644 client/ui/qml/DefaultVpn/main.qml diff --git a/client/amnezia_application.cpp b/client/amnezia_application.cpp index 4e25097d..3fee3724 100644 --- a/client/amnezia_application.cpp +++ b/client/amnezia_application.cpp @@ -69,7 +69,7 @@ void AmneziaApplication::init() { m_engine = new QQmlApplicationEngine; - const QUrl url(QStringLiteral("qrc:/ui/qml/main2.qml")); + const QUrl url(QStringLiteral("qrc:/ui/qml/DefaultVpn/main.qml")); QObject::connect( m_engine, &QQmlApplicationEngine::objectCreated, this, [url](QObject *obj, const QUrl &objUrl) { @@ -154,7 +154,7 @@ void AmneziaApplication::init() connect(this, &AmneziaApplication::translationsUpdated, m_notificationHandler.get(), &NotificationHandler::onTranslationsUpdated); #endif - m_engine->addImportPath("qrc:/ui/qml/Modules/"); + m_engine->addImportPath("qrc:/ui/qml/DefaultVpn"); m_engine->load(url); m_systemController->setQmlRoot(m_engine->rootObjects().value(0)); @@ -228,7 +228,7 @@ void AmneziaApplication::loadFonts() { QQuickStyle::setStyle("Basic"); - QFontDatabase::addApplicationFont(":/fonts/pt-root-ui_vf.ttf"); + QFontDatabase::addApplicationFont(":/fonts/VelaSans-GX.ttf"); } void AmneziaApplication::loadTranslator() diff --git a/client/fonts/VelaSans-GX.ttf b/client/fonts/VelaSans-GX.ttf new file mode 100644 index 0000000000000000000000000000000000000000..0a75bb2e63beb32a3cf04843ece3731e4e916fb0 GIT binary patch literal 190452 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 Xfdh{|eb;#G@c)yvjPZmffxi7e_p>x! literal 0 HcmV?d00001 diff --git a/client/images/controls/connect-button.svg b/client/images/controls/connect-button.svg new file mode 100644 index 00000000..74727c36 --- /dev/null +++ b/client/images/controls/connect-button.svg @@ -0,0 +1,4 @@ + + + + diff --git a/client/resources.qrc b/client/resources.qrc index a10a784d..e8a3522e 100644 --- a/client/resources.qrc +++ b/client/resources.qrc @@ -220,6 +220,28 @@ ui/qml/Pages2/PageSettingsApiLanguageList.qml images/controls/archive-restore.svg images/controls/help-circle.svg + ui/qml/DefaultVpn/Controls/DropDownType.qml + ui/qml/DefaultVpn/main.qml + ui/qml/DefaultVpn/Pages/PageHome.qml + ui/qml/DefaultVpn/Controls/TextTypes/MediumTextType.qml + ui/qml/DefaultVpn/Config/DeviceInfo.qml + ui/qml/DefaultVpn/Config/qmldir + ui/qml/DefaultVpn/Controls/TextTypes/XSmallTextType.qml + ui/qml/DefaultVpn/Controls/ButtonType.qml + ui/qml/DefaultVpn/Pages/PageSettingsServersList.qml + ui/qml/DefaultVpn/Controls/TextTypes/Header1TextType.qml + ui/qml/DefaultVpn/Controls/TextTypes/Header3TextType.qml + ui/qml/DefaultVpn/Config/Style.qml + ui/qml/DefaultVpn/Components/WhiteButtonWithBorder.qml + ui/qml/DefaultVpn/Components/WhiteButtonNoBorder.qml + ui/qml/DefaultVpn/Pages/PageSetupWizardConfigSource.qml + ui/qml/DefaultVpn/Components/BlueButtonNoBorder.qml + ui/qml/DefaultVpn/Controls/InputType.qml + ui/qml/DefaultVpn/Controls/PopupType.qml + ui/qml/DefaultVpn/Pages/PageSettingsServerInfo.qml + ui/qml/DefaultVpn/Controls/BusyIndicatorType.qml + images/controls/connect-button.svg + fonts/VelaSans-GX.ttf images/flagKit/ZW.svg diff --git a/client/ui/controllers/pageController.cpp b/client/ui/controllers/pageController.cpp index bbcc55a1..34450fea 100644 --- a/client/ui/controllers/pageController.cpp +++ b/client/ui/controllers/pageController.cpp @@ -51,7 +51,7 @@ QString PageController::getPagePath(PageLoader::PageEnum page) { QMetaEnum metaEnum = QMetaEnum::fromType(); QString pageName = metaEnum.valueToKey(static_cast(page)); - return "qrc:/ui/qml/Pages2/" + pageName + ".qml"; + return "qrc:/ui/qml/DefaultVpn/Pages/" + pageName + ".qml"; } void PageController::closeWindow() diff --git a/client/ui/qml/DefaultVpn/Components/BlueButtonNoBorder.qml b/client/ui/qml/DefaultVpn/Components/BlueButtonNoBorder.qml new file mode 100644 index 00000000..6e8b4234 --- /dev/null +++ b/client/ui/qml/DefaultVpn/Components/BlueButtonNoBorder.qml @@ -0,0 +1,36 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects + +import Config 1.0 + +import "../Controls/TextTypes" +import "../Controls" + +ButtonType { + defaultBackgroundColor: Style.color.accent1 + defaultBorderColor: Style.color.gray3 + defaultTextColor: Style.color.white + defaultImageColor: Style.color.white + + hoveredBackgroundColor: Style.color.accent2 + hoveredBorderColor: Style.color.gray3 + hoveredTextColor: Style.color.white + hoveredImageColor: Style.color.white + + pressedBackgroundColor: Style.color.accent3 + pressedBorderColor: Style.color.gray3 + pressedTextColor: Style.color.white + pressedImageColor: Style.color.white + + disabledBackgroundColor: Style.color.gray6 + disabledBorderColor: Style.color.gray3 + disabledTextColor: Style.color.gray2 + disabledImageColor: Style.color.gray2 + + defaultBorderWidth: 0 + disabledBorderWidth: 0 +} diff --git a/client/ui/qml/DefaultVpn/Components/WhiteButtonNoBorder.qml b/client/ui/qml/DefaultVpn/Components/WhiteButtonNoBorder.qml new file mode 100644 index 00000000..95099321 --- /dev/null +++ b/client/ui/qml/DefaultVpn/Components/WhiteButtonNoBorder.qml @@ -0,0 +1,36 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects + +import Config 1.0 + +import "../Controls/TextTypes" +import "../Controls" + +ButtonType { + defaultBackgroundColor: Style.color.white + defaultBorderColor: Style.color.gray3 + defaultTextColor: Style.color.accent1 + defaultImageColor: Style.color.accent1 + + hoveredBackgroundColor: Style.color.gray1 + hoveredBorderColor: Style.color.gray3 + hoveredTextColor: Style.color.accent2 + hoveredImageColor: Style.color.accent2 + + pressedBackgroundColor: Style.color.gray2 + pressedBorderColor: Style.color.gray3 + pressedTextColor: Style.color.accent3 + pressedImageColor: Style.color.accent3 + + disabledBackgroundColor: Style.color.white + disabledBorderColor: Style.color.gray3 + disabledTextColor: Style.color.gray8 + disabledImageColor: Style.color.gray8 + + defaultBorderWidth: 0 + disabledBorderWidth: 0 +} diff --git a/client/ui/qml/DefaultVpn/Components/WhiteButtonWithBorder.qml b/client/ui/qml/DefaultVpn/Components/WhiteButtonWithBorder.qml new file mode 100644 index 00000000..7c05c271 --- /dev/null +++ b/client/ui/qml/DefaultVpn/Components/WhiteButtonWithBorder.qml @@ -0,0 +1,37 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects + +import Config 1.0 + +import "../Controls/TextTypes" +import "../Controls" + +ButtonType { + defaultBackgroundColor: Style.color.white + defaultBorderColor: Style.color.gray3 + defaultTextColor: Style.color.black + defaultImageColor: Style.color.black + + hoveredBackgroundColor: Style.color.white + hoveredBorderColor: Style.color.gray6 + hoveredTextColor: Style.color.black + hoveredImageColor: Style.color.black + + pressedBackgroundColor: Style.color.gray1 + pressedBorderColor: Style.color.gray6 + pressedTextColor: Style.color.black + pressedImageColor: Style.color.black + + disabledBackgroundColor: Style.color.gray3 + disabledBorderColor: Style.color.gray2 + disabledTextColor: Style.color.gray9 + disabledImageColor: Style.color.gray9 + + defaultBorderWidth: 1 + disabledBorderWidth: 1 + hoveredBorderWidth: 1 +} diff --git a/client/ui/qml/DefaultVpn/Config/DeviceInfo.qml b/client/ui/qml/DefaultVpn/Config/DeviceInfo.qml new file mode 100644 index 00000000..b46bdf58 --- /dev/null +++ b/client/ui/qml/DefaultVpn/Config/DeviceInfo.qml @@ -0,0 +1,37 @@ +pragma Singleton + +import QtQuick + +Item { + readonly property int screenWidth: 380 + readonly property int screenHeight: 680 + + function isMobile() { + if (Qt.platform.os === "android" || + Qt.platform.os === "ios") { + return true + } + return false + } + + function isDesktop() { + if (Qt.platform.os === "windows" || + Qt.platform.os === "linux" || + Qt.platform.os === "osx") { + return true + } + return false + } + + TextEdit { + id: clipboard + visible: false + } + + function copyToClipBoard(text) { + clipboard.text = text + clipboard.selectAll() + clipboard.copy() + clipboard.select(0, 0) + } +} diff --git a/client/ui/qml/DefaultVpn/Config/Style.qml b/client/ui/qml/DefaultVpn/Config/Style.qml new file mode 100644 index 00000000..b4a32679 --- /dev/null +++ b/client/ui/qml/DefaultVpn/Config/Style.qml @@ -0,0 +1,30 @@ +pragma Singleton + +import QtQuick + +QtObject { + property QtObject color: QtObject { + readonly property color transparent: 'transparent' + readonly property color gray1: '#F2F2F7' + readonly property color gray2: '#E5E5EA' + readonly property color gray3: '#D1D1D6' + readonly property color gray4: '#C7C7CC' + readonly property color gray5: '#AEAEB2' + readonly property color gray6: '#8E8E93' + readonly property color gray7: '#7C7C83' + readonly property color gray8: '#707075' + readonly property color gray9: '#57575B' + readonly property color accent1: '#007AFF' + readonly property color accent2: '#0B6EDA' + readonly property color accent3: '#1256A1' + readonly property color error: '#FF3B30' + readonly property color warning: '#FF9500' + readonly property color success: '#34C759' + readonly property color black: '#000000' + readonly property color white: '#FFFFFF' + + readonly property color transparentBlack: Qt.rgba(14/255, 14/255, 17/255, 0.8) + } + + readonly property string font: "Vela Sans GX" +} diff --git a/client/ui/qml/DefaultVpn/Config/qmldir b/client/ui/qml/DefaultVpn/Config/qmldir new file mode 100644 index 00000000..beaa3d4e --- /dev/null +++ b/client/ui/qml/DefaultVpn/Config/qmldir @@ -0,0 +1,4 @@ +module Config + +singleton DeviceInfo 1.0 DeviceInfo.qml +singleton Style 1.0 Style.qml diff --git a/client/ui/qml/DefaultVpn/Controls/BusyIndicatorType.qml b/client/ui/qml/DefaultVpn/Controls/BusyIndicatorType.qml new file mode 100644 index 00000000..ca4e9516 --- /dev/null +++ b/client/ui/qml/DefaultVpn/Controls/BusyIndicatorType.qml @@ -0,0 +1,70 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Shapes + +import Config 1.0 + +Popup { + id: root + anchors.centerIn: parent + + modal: true + closePolicy: Popup.NoAutoClose + + visible: false + + Overlay.modal: Rectangle { + color: Style.color.transparentBlack + } + + background: Rectangle { + color: Style.color.transparent + } + + BusyIndicator { + id: busyIndicator + + visible: true + running: true + + contentItem: Item { + implicitWidth: 46 + implicitHeight: 46 + transformOrigin: Item.Center + + Shape { + id: shape + width: parent.implicitWidth + height: parent.implicitHeight + anchors.bottom: parent.bottom + anchors.right: parent.right + layer.enabled: true + layer.samples: 4 + + ShapePath { + fillColor: Style.color.transparent + strokeColor: Style.color.gray3 + strokeWidth: 3 + capStyle: ShapePath.RoundCap + + PathAngleArc { + centerX: shape.width / 2 + centerY: shape.height / 2 + radiusX: 18 + radiusY: 18 + startAngle: 225 + sweepAngle: -90 + } + } + RotationAnimator { + target: shape + running: busyIndicator.visible && busyIndicator.running + from: 0 + to: 360 + loops: Animation.Infinite + duration: 1250 + } + } + } + } +} diff --git a/client/ui/qml/DefaultVpn/Controls/ButtonType.qml b/client/ui/qml/DefaultVpn/Controls/ButtonType.qml new file mode 100644 index 00000000..15f46248 --- /dev/null +++ b/client/ui/qml/DefaultVpn/Controls/ButtonType.qml @@ -0,0 +1,154 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects + +import Config 1.0 + +import "TextTypes" + +Button { + id: root + + property string defaultBackgroundColor: Style.color.white + property string defaultBorderColor: Style.color.gray3 + property string defaultTextColor: Style.color.accent1 + property string defaultImageColor: Style.color.accent1 + + property string hoveredBackgroundColor: Style.color.gray1 + property string hoveredBorderColor: Style.color.gray3 + property string hoveredTextColor: Style.color.accent2 + property string hoveredImageColor: Style.color.accent2 + + property string pressedBackgroundColor: Style.color.gray2 + property string pressedBorderColor: Style.color.gray3 + property string pressedTextColor: Style.color.accent3 + property string pressedImageColor: Style.color.accent3 + + property string disabledBackgroundColor: Style.color.white + property string disabledBorderColor: Style.color.gray3 + property string disabledTextColor: Style.color.gray8 + property string disabledImageColor: Style.color.gray8 + + property int defaultBorderWidth: 0 + property int disabledBorderWidth: 0 + property int hoveredBorderWidth: 0 + + property string imageSource: "" + + readonly property bool isImageOnly: root.text !== "" + + background: Rectangle { + id: background + + anchors.fill: parent + + radius: 6 + + color: root.enabled ? root.defaultBackgroundColor : root.disabledBackgroundColor + border.color: root.enabled ? root.defaultBorderColor : root.disabledBorderColor + border.width: root.enabled ? root.defaultBorderWidth : root.disabledBorderWidth + } + + MouseArea { + id: mouseArea + + anchors.fill: background + cursorShape: Qt.PointingHandCursor + + hoverEnabled: true + enabled: root.enabled + + onEntered: { + background.color = root.hoveredBackgroundColor + background.border.color = root.hoveredBorderColor + background.border.width = root.hoveredBorderWidth + image.imageColor = root.hoveredImageColor + buttonText.color = root.hoveredTextColor + } + + onExited: { + background.color = root.defaultBackgroundColor + background.border.color = root.defaultBorderColor + background.border.width = root.defaultBorderWidth + image.imageColor = root.defaultImageColor + buttonText.color = root.defaultTextColor + } + + onPressedChanged: { + if (pressed) { + background.color = root.pressedBackgroundColor + background.border.color = root.pressedBorderColor + image.imageColor = root.pressedImageColor + buttonText.color = root.pressedTextColor + } else if (entered) { + background.color = root.hoveredBackgroundColor + background.border.color = root.hoveredBorderColor + image.imageColor = root.hoveredImageColor + buttonText.color = root.hoveredTextColor + } else { + background.color = root.defaultBackgroundColor + background.border.color = root.defaultBorderColor + image.imageColor = root.defaultImageColor + buttonText.color = root.defaultTextColor + } + } + + onClicked: { + root.clicked() + } + } + + contentItem: Item { + implicitWidth: content.implicitWidth + implicitHeight: content.implicitHeight + + RowLayout { + id: content + anchors.fill: parent + + MediumTextType { + id: buttonText + + Layout.fillWidth: true + Layout.topMargin: 12 + Layout.bottomMargin: 12 + Layout.leftMargin: 12 + Layout.rightMargin: 12 + visible: root.isImageOnly + + color: root.defaultTextColor + text: root.text + + horizontalAlignment: Qt.AlignHCenter + verticalAlignment: Qt.AlignVCenter + } + + Image { + id: image + + property color imageColor: root.enabled ? root.defaultImageColor : root.disabledImageColor + + Layout.preferredHeight: 22 + Layout.preferredWidth: 22 + Layout.alignment: Qt.AlignCenter + Layout.topMargin: 12 + Layout.bottomMargin: 12 + Layout.leftMargin: 12 + Layout.rightMargin: 12 + + source: root.imageSource + visible: root.imageSource === "" ? false : true + + layer { + enabled: true + effect: ColorOverlay { + color: image.imageColor + } + } + } + } + } +} diff --git a/client/ui/qml/DefaultVpn/Controls/DropDownType.qml b/client/ui/qml/DefaultVpn/Controls/DropDownType.qml new file mode 100644 index 00000000..2f593921 --- /dev/null +++ b/client/ui/qml/DefaultVpn/Controls/DropDownType.qml @@ -0,0 +1,99 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects + +import Config 1.0 + +import "TextTypes" + +Button { + id: root + + property string defaultBackgroundColor: "#FFFFFF" + property string defaultBorderColor: "#D1D1D6" + property string defaultTextColor: "#000000" + property string defaultImageColor: "#000000" + + property string hoveredBackgroundColor: "#FFFFFF" + property string hoveredBorderColor: "#D1D1D6" + property string hoveredTextColor: "#D1D1D6" + property string hoveredImageColor: "#D1D1D6" + + property string pressedBackgroundColor: "#FFFFFF" + property string pressedBorderColor: "#D1D1D6" + property string pressedTextColor: "#D1D1D6" + property string pressedImageColor: "#D1D1D6" + + property string disabledBackgroundColor: "#FFFFFF" + property string disabledBorderColor: "#D1D1D6" + property string disabledTextColor: "#D1D1D6" + property string disabledImageColor: "#D1D1D6" + + property string imageSource: "qrc:/images/controls/chevron-down.svg" + + hoverEnabled: true + + background: Rectangle { + id: focusBorder + + color: root.defaultBackgroundColor + border.color: root.defaultBorderColor + border.width: 1 + + anchors.fill: parent + + radius: 6 + } + + MouseArea { + anchors.fill: focusBorder + enabled: false + cursorShape: Qt.PointingHandCursor + } + + contentItem: Item { + anchors.fill: focusBorder + + implicitWidth: content.implicitWidth + implicitHeight: content.implicitHeight + + RowLayout { + id: content + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + + MediumTextType { + id: buttonText + + Layout.fillWidth: true + Layout.topMargin: 12 + Layout.bottomMargin: 12 + + color: root.defaultTextColor + text: root.text + + horizontalAlignment: Qt.AlignLeft + verticalAlignment: Qt.AlignVCenter + } + + Image { + Layout.preferredHeight: 22 + Layout.preferredWidth: 22 + + source: root.imageSource + visible: root.imageSource === "" ? false : true + + layer { + enabled: true + effect: ColorOverlay { + color: root.defaultImageColor + } + } + } + } + } +} diff --git a/client/ui/qml/DefaultVpn/Controls/InputType.qml b/client/ui/qml/DefaultVpn/Controls/InputType.qml new file mode 100644 index 00000000..9e447b06 --- /dev/null +++ b/client/ui/qml/DefaultVpn/Controls/InputType.qml @@ -0,0 +1,58 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects + +import Config 1.0 + +import "TextTypes" + +ScrollView { + id: root + + property string defaultBackgroundColor: Style.color.white + property string defaultBorderColor: Style.color.gray3 + property string defaultTextColor: Style.color.gray6 + + property string hoveredBackgroundColor: Style.color.white + property string hoveredBorderColor: Style.color.gray6 + property string hoveredTextColor: Style.color.black + + property string disabledBackgroundColor: Style.color.gray2 + property string disabledBorderColor: Style.color.gray3 + property string disabledTextColor: Style.color.gray9 + + property string placeholderText + + TextArea { + color: root.enabled ? root.defaultTextColor : (root.hovered || root.pressed) ? root.hoveredTextColor : root.disabledTextColor + background: Rectangle { + anchors.fill: parent + + color: root.enabled ? root.defaultBackgroundColor : (root.hovered || root.pressed) ? root.hoveredBackgroundColor : root.disabledBackgroundColor + border.color: root.enabled ? root.defaultBorderColor : (root.hovered || root.pressed) ? root.hoveredBorderColor : root.disabledBorderColor + border.width: 1 + radius: 6 + } + + topPadding: 12 + bottomPadding: 12 + leftPadding: 16 + rightPadding: 16 + + inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhSensitiveData | Qt.ImhNoPredictiveText + + selectionColor: Style.color.accent1 + selectedTextColor: Style.color.white + + font.pixelSize: 17 + font.weight: 400 + font.family: Style.font + + wrapMode: TextEdit.Wrap + + placeholderText: root.placeholderText + } +} diff --git a/client/ui/qml/DefaultVpn/Controls/PopupType.qml b/client/ui/qml/DefaultVpn/Controls/PopupType.qml new file mode 100644 index 00000000..3c461b23 --- /dev/null +++ b/client/ui/qml/DefaultVpn/Controls/PopupType.qml @@ -0,0 +1,96 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects + +import Config 1.0 + +import "TextTypes" +import "../Components" + +Popup { + id: root + + property string text + property bool closeButtonVisible: true + + leftMargin: 25 + rightMargin: 25 + bottomMargin: 70 + + width: parent.width - leftMargin - rightMargin + + anchors.centerIn: parent + modal: root.closeButtonVisible + closePolicy: Popup.CloseOnEscape + + Overlay.modal: Rectangle { + visible: root.closeButtonVisible + color: Qt.rgba(14/255, 14/255, 17/255, 0.8) + } + + background: Rectangle { + anchors.fill: parent + color: Style.color.white + radius: 8 + + layer.enabled: true + layer.effect: DropShadow { + color: Style.color.gray3 + horizontalOffset: 0 + verticalOffset: 1 + radius: 10 + samples: 25 + } + } + + contentItem: Item { + implicitWidth: content.implicitWidth + implicitHeight: content.implicitHeight + + anchors.fill: parent + + RowLayout { + id: content + + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + + XSmallTextType { + horizontalAlignment: Text.AlignLeft + Layout.fillWidth: true + + onLinkActivated: function(link) { + Qt.openUrlExternally(link) + } + + text: root.text + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor + } + } + + Item { + id: focusItem + KeyNavigation.tab: closeButton + } + + WhiteButtonNoBorder { + id: closeButton + visible: closeButtonVisible + + imageSource: "qrc:/images/controls/x-circle.svg" + + onClicked: function() { + root.close() + } + } + } + } +} diff --git a/client/ui/qml/DefaultVpn/Controls/TextTypes/Header1TextType.qml b/client/ui/qml/DefaultVpn/Controls/TextTypes/Header1TextType.qml new file mode 100644 index 00000000..ecb24fb7 --- /dev/null +++ b/client/ui/qml/DefaultVpn/Controls/TextTypes/Header1TextType.qml @@ -0,0 +1,15 @@ +import QtQuick + +import Config 1.0 + +Text { + lineHeight: 34 + lineHeightMode: Text.FixedHeight + + color: Style.color.black + font.pixelSize: 28 + font.weight: 700 + font.family: Style.font + + wrapMode: Text.WordWrap +} diff --git a/client/ui/qml/DefaultVpn/Controls/TextTypes/Header3TextType.qml b/client/ui/qml/DefaultVpn/Controls/TextTypes/Header3TextType.qml new file mode 100644 index 00000000..48405079 --- /dev/null +++ b/client/ui/qml/DefaultVpn/Controls/TextTypes/Header3TextType.qml @@ -0,0 +1,15 @@ +import QtQuick + +import Config 1.0 + +Text { + lineHeight: 24 + lineHeightMode: Text.FixedHeight + + color: Style.color.black + font.pixelSize: 20 + font.weight: 700 + font.family: Style.font + + wrapMode: Text.WordWrap +} diff --git a/client/ui/qml/DefaultVpn/Controls/TextTypes/MediumTextType.qml b/client/ui/qml/DefaultVpn/Controls/TextTypes/MediumTextType.qml new file mode 100644 index 00000000..7a2aad61 --- /dev/null +++ b/client/ui/qml/DefaultVpn/Controls/TextTypes/MediumTextType.qml @@ -0,0 +1,15 @@ +import QtQuick + +import Config 1.0 + +Text { + lineHeight: 22 + lineHeightMode: Text.FixedHeight + + color: Style.color.black + font.pixelSize: 17 + font.weight: 400 + font.family: Style.font + + wrapMode: Text.WordWrap +} diff --git a/client/ui/qml/DefaultVpn/Controls/TextTypes/XSmallTextType.qml b/client/ui/qml/DefaultVpn/Controls/TextTypes/XSmallTextType.qml new file mode 100644 index 00000000..22e37a86 --- /dev/null +++ b/client/ui/qml/DefaultVpn/Controls/TextTypes/XSmallTextType.qml @@ -0,0 +1,15 @@ +import QtQuick + +import Config 1.0 + +Text { + lineHeight: 18 + lineHeightMode: Text.FixedHeight + + color: Style.color.black + font.pixelSize: 13 + font.weight: 400 + font.family: Style.font + + wrapMode: Text.WordWrap +} diff --git a/client/ui/qml/DefaultVpn/Pages/PageHome.qml b/client/ui/qml/DefaultVpn/Pages/PageHome.qml new file mode 100644 index 00000000..bfe49afd --- /dev/null +++ b/client/ui/qml/DefaultVpn/Pages/PageHome.qml @@ -0,0 +1,122 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import Config 1.0 + +import "../Components" +import "../Controls" +import "../Controls/TextTypes" + +Page { + id: root + + ColumnLayout { + anchors.fill: parent + anchors.topMargin: 8 + anchors.bottomMargin: 36 + anchors.leftMargin: 16 + anchors.rightMargin: 16 + + spacing: 0 + + Text { + lineHeight: 68 + lineHeightMode: Text.FixedHeight + + color: Style.color.gray2 + font.pixelSize: 56 + font.weight: 700 + font.family: Style.font + + horizontalAlignment: Qt.AlignLeft + + text: ConnectionController.isConnected ? qsTr("Online") : qsTr("Offline") + } + + Item { + Layout.fillHeight: true + } + + XSmallTextType { + text: qsTr("Connection to") + + horizontalAlignment: Qt.AlignLeft + verticalAlignment: Qt.AlignVCenter + } + + RowLayout { + DropDownType { + Layout.fillWidth: true + + text: ServersModel.defaultServerName + + onClicked: function() { + PageController.goToPage(PageEnum.PageSettingsServersList) + } + } + + WhiteButtonWithBorder { + imageSource: "qrc:/images/controls/plus.svg" + + onClicked: function() { + PageController.goToPage(PageEnum.PageSetupWizardConfigSource) + } + } + } + + Button { + id: connectButton + + Layout.fillWidth: true + implicitHeight: 358 + + Layout.topMargin: 16 + + background: Rectangle { + anchors.fill: parent + + radius: 16 + + color: { + if (ConnectionController.isConnectionInProgress) { + return Style.color.accent3 + } else if (ConnectionController.isConnected) { + return Style.color.accent1 + } else { + return Style.color.black + } + } + + ColumnLayout { + anchors.centerIn: parent + + Image { + Layout.alignment: Qt.AlignCenter + + source: "qrc:/images/controls/connect-button.svg" + } + + Header3TextType { + Layout.alignment: Qt.AlignCenter + Layout.topMargin: 24 + + text: ConnectionController.connectionStateText + + color: Style.color.white + } + + Item { + Layout.fillWidth: true + } + } + } + + onClicked: function() { + ServersModel.setProcessedServerIndex(ServersModel.defaultIndex) + ConnectionController.connectButtonClicked() + } + } + } +} diff --git a/client/ui/qml/DefaultVpn/Pages/PageSettingsServerInfo.qml b/client/ui/qml/DefaultVpn/Pages/PageSettingsServerInfo.qml new file mode 100644 index 00000000..2b2e5881 --- /dev/null +++ b/client/ui/qml/DefaultVpn/Pages/PageSettingsServerInfo.qml @@ -0,0 +1,103 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import Config 1.0 + +import "../Components" +import "../Controls" +import "../Controls/TextTypes" + +Page { + id: root + + Connections { + target: InstallController + + function onRemoveProcessedServerFinished(finishedMessage) { + if (!ServersModel.getServersCount()) { + PageController.goToStartPage() + } else { + PageController.closePage() + } + PageController.showNotificationMessage(finishedMessage) + } + } + + ColumnLayout { + anchors.fill: parent + + spacing: 0 + + RowLayout { + Layout.leftMargin: 8 + Layout.rightMargin: 8 + Layout.topMargin: 8 + + WhiteButtonNoBorder { + id: backButton + imageSource: "qrc:/images/controls/arrow-left.svg" + + onClicked: PageController.closePage() + } + + Item { + Layout.fillWidth: true + } + } + + Header1TextType { + id: header + + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + Layout.fillWidth: true + + text: qsTr("Server settings") + + horizontalAlignment: Qt.AlignLeft + verticalAlignment: Qt.AlignVCenter + } + + XSmallTextType { + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 8 + Layout.fillWidth: true + + text: qsTr("Name") + } + + InputType { + id: textKey + + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.fillWidth: true + } + + WhiteButtonWithBorder { + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 24 + Layout.fillWidth: true + + text: qsTr("Remove server") + + onClicked: function() { + PageController.showBusyIndicator(true) + InstallController.removeProcessedServer() + PageController.showBusyIndicator(false) + } + } + + Item { + Layout.fillHeight: true + } + } +} diff --git a/client/ui/qml/DefaultVpn/Pages/PageSettingsServersList.qml b/client/ui/qml/DefaultVpn/Pages/PageSettingsServersList.qml new file mode 100644 index 00000000..fa82cc1e --- /dev/null +++ b/client/ui/qml/DefaultVpn/Pages/PageSettingsServersList.qml @@ -0,0 +1,165 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import Config 1.0 + +import "../Components" +import "../Controls" +import "../Controls/TextTypes" + +Page { + id: root + + ColumnLayout { + anchors.fill: parent + + RowLayout { + Layout.leftMargin: 8 + Layout.rightMargin: 8 + Layout.topMargin: 8 + + WhiteButtonNoBorder { + id: backButton + imageSource: "qrc:/images/controls/arrow-left.svg" + + onClicked: PageController.closePage() + } + + Item { + Layout.fillWidth: true + } + + WhiteButtonNoBorder { + imageSource: "qrc:/images/controls/plus.svg" + + onClicked: function() { + PageController.goToPage(PageEnum.PageSetupWizardConfigSource) + } + } + } + + Header1TextType { + id: header + + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.fillWidth: true + + text: qsTr("Connect to") + + horizontalAlignment: Qt.AlignLeft + verticalAlignment: Qt.AlignVCenter + } + + ButtonGroup { + id: serversRadioButtonGroup + } + + ListView { + id: serversListView + + Layout.topMargin: 16 + Layout.fillHeight: true + Layout.fillWidth: true + + model: ServersModel + currentIndex: ServersModel.defaultIndex + + ScrollBar.vertical: ScrollBar {} + + Connections { + target: ServersModel + function onDefaultServerIndexChanged(serverIndex) { + serversListView.currentIndex = serverIndex + serversListView.positionViewAtIndex(serversListView.currentIndex, ListView.Contain) + } + } + + Component.onCompleted: positionViewAtIndex(currentIndex, ListView.Center) + + delegate: Item { + id: menuContentDelegate + required property string name + required property int index + + implicitWidth: serversListView.width + implicitHeight: serverItem.implicitHeight + + RadioButton { + id: serverItem + + anchors.fill: parent + anchors.rightMargin: 16 + anchors.leftMargin: 16 + + ButtonGroup.group: serversRadioButtonGroup + + checked: index === serversListView.currentIndex + + indicator: Item { } + + contentItem: Item { + id: contentContainer + + anchors.left: parent.left + anchors.right: parent.right + + implicitHeight: content.implicitHeight + + Rectangle { + anchors.fill: parent + + radius: 8 + + color: serverItem.checked ? Style.color.gray1 : Style.color.transparent + } + + RowLayout { + id: content + anchors.fill: parent + + Header3TextType { + Layout.fillWidth: true + Layout.leftMargin: 8 + Layout.topMargin: 19 + Layout.bottomMargin: 19 + + text: name + + color: serverItem.hovered ? Style.color.gray9 : Style.color.black + } + + ButtonType { + Layout.rightMargin: 8 + imageSource: "qrc:/images/controls/edit-3.svg" + + hoveredBorderColor: Style.color.gray2 + hoveredBorderWidth: 1 + + onClicked: function() { + ServersModel.processedIndex = index + PageController.goToPage(PageEnum.PageSettingsServerInfo) + } + } + } + } + + onClicked: function() { + ServersModel.defaultIndex = index + } + + MouseArea { + anchors.fill: serverItem + cursorShape: Qt.PointingHandCursor + enabled: false + } + } + } + } + } +} diff --git a/client/ui/qml/DefaultVpn/Pages/PageSetupWizardConfigSource.qml b/client/ui/qml/DefaultVpn/Pages/PageSetupWizardConfigSource.qml new file mode 100644 index 00000000..795fab9a --- /dev/null +++ b/client/ui/qml/DefaultVpn/Pages/PageSetupWizardConfigSource.qml @@ -0,0 +1,112 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import Config 1.0 + +import "../Components" +import "../Controls" +import "../Controls/TextTypes" + +Page { + id: root + + Connections { + target: ImportController + + function onImportErrorOccurred(error, goToPageHome) { + PageController.showErrorMessage(error) + } + + function onImportFinished() { + if (!ConnectionController.isConnected) { + ServersModel.setDefaultServerIndex(ServersModel.getServersCount() - 1); + ServersModel.processedIndex = ServersModel.defaultIndex + } + + PageController.goToStartPage() + } + } + + ColumnLayout { + anchors.fill: parent + + spacing: 0 + + RowLayout { + Layout.leftMargin: 8 + Layout.rightMargin: 8 + Layout.topMargin: 8 + + WhiteButtonNoBorder { + id: backButton + imageSource: "qrc:/images/controls/arrow-left.svg" + + onClicked: PageController.closePage() + } + + Item { + Layout.fillWidth: true + } + } + + Header1TextType { + id: header + + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + Layout.fillWidth: true + + text: qsTr("Adding a server to connect to") + + horizontalAlignment: Qt.AlignLeft + verticalAlignment: Qt.AlignVCenter + } + + XSmallTextType { + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 8 + Layout.fillWidth: true + + text: qsTr("Key") + } + + InputType { + id: textKey + + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.fillWidth: true + Layout.preferredHeight: 308 + + placeholderText: qsTr("VPN://") + } + + BlueButtonNoBorder { + Layout.topMargin: 24 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.fillWidth: true + + text: qsTr("Add") + + onClicked: function() { + if (ImportController.extractConfigFromData(textKey.text)) { + ImportController.importConfig() + } else { + PageController.showErrorMessage(qsTr("Unsupported config file")) + } + } + } + + Item { + Layout.fillHeight: true + } + } +} diff --git a/client/ui/qml/DefaultVpn/main.qml b/client/ui/qml/DefaultVpn/main.qml new file mode 100644 index 00000000..cb958bdd --- /dev/null +++ b/client/ui/qml/DefaultVpn/main.qml @@ -0,0 +1,195 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs + +import Config 1.0 +import PageEnum 1.0 + +import "Controls" +import "Pages" + +ApplicationWindow { + id: root + objectName: "mainWindow" + visible: true + width: DeviceInfo.screenWidth + height: DeviceInfo.screenHeight + minimumWidth: DeviceInfo.isDesktop() ? 360 : 0 + minimumHeight: DeviceInfo.isDesktop() ? 640 : 0 + maximumWidth: 600 + maximumHeight: 800 + + color: Style.color.white + + onClosing: function() { + console.debug("QML onClosing signal") + PageController.closeWindow() + } + + title: "DefaultVPN" + + Connections { + target: PageController + + function onRaiseMainWindow() { + root.show() + root.raise() + root.requestActivate() + } + + function onHideMainWindow() { + root.hide() + } + + function onShowErrorMessage(errorMessage) { + popupErrorMessage.text = errorMessage + popupErrorMessage.open() + } + + function onShowNotificationMessage(message) { + popupNotificationMessage.text = message + popupNotificationMessage.closeButtonVisible = false + popupNotificationMessage.open() + popupNotificationTimer.start() + } + + function onShowBusyIndicator(visible) { + busyIndicator.visible = visible + PageController.disableControls(visible) + } + + function onClosePage() { + if (stackview.depth <= 1) { + PageController.hideWindow() + return + } + stackview.pop() + } + + function onGoToPage(page, slide) { + var pagePath = PageController.getPagePath(page) + + if (slide) { + stackview.push(pagePath, { "objectName" : pagePath }, StackView.PushTransition) + } else { + stackview.push(pagePath, { "objectName" : pagePath }, StackView.Immediate) + } + } + + function onGoToStartPage() { + while (stackview.depth > 1) { + stackview.pop() + } + } + } + + Connections { + target: SettingsController + + function onChangeSettingsFinished(finishedMessage) { + PageController.showNotificationMessage(finishedMessage) + } + } + + StackView { + id: stackview + anchors.fill: parent + + Component.onCompleted: { + var pagePath = PageController.getPagePath(PageEnum.PageHome) + ServersModel.processedIndex = ServersModel.defaultIndex + + stackview.push(pagePath, { "objectName" : pagePath }) + } + } + + Item { + anchors.right: parent.right + anchors.left: parent.left + anchors.bottom: parent.bottom + + implicitHeight: popupNotificationMessage.height + + PopupType { + id: popupNotificationMessage + } + + Timer { + id: popupNotificationTimer + + interval: 3000 + repeat: false + running: false + onTriggered: { + popupNotificationMessage.close() + } + } + } + + Item { + anchors.right: parent.right + anchors.left: parent.left + anchors.bottom: parent.bottom + + implicitHeight: popupErrorMessage.height + + PopupType { + id: popupErrorMessage + } + } + + // Item { + // anchors.fill: parent + + // QuestionDrawer { + // id: questionDrawer + + // anchors.fill: parent + // } + // } + + Item { + anchors.fill: parent + + BusyIndicatorType { + id: busyIndicator + anchors.centerIn: parent + z: 1 + } + } + + // function showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) { + // questionDrawer.headerText = headerText + // questionDrawer.descriptionText = descriptionText + // questionDrawer.yesButtonText = yesButtonText + // questionDrawer.noButtonText = noButtonText + + // questionDrawer.yesButtonFunction = function() { + // questionDrawer.close() + // if (yesButtonFunction && typeof yesButtonFunction === "function") { + // yesButtonFunction() + // } + // } + // questionDrawer.noButtonFunction = function() { + // questionDrawer.close() + // if (noButtonFunction && typeof noButtonFunction === "function") { + // noButtonFunction() + // } + // } + // questionDrawer.open() + // } + + FileDialog { + id: mainFileDialog + + property bool isSaveMode: false + + objectName: "mainFileDialog" + fileMode: isSaveMode ? FileDialog.SaveFile : FileDialog.OpenFile + + onAccepted: SystemController.fileDialogClosed(true) + onRejected: SystemController.fileDialogClosed(false) + } +} From 1bac3dc32fcfbbc706b80203da54c30fe1c3cace Mon Sep 17 00:00:00 2001 From: "vladimir.kuznetsov" Date: Thu, 19 Dec 2024 13:10:32 +0700 Subject: [PATCH 10/10] chore: changed textarea to textfield --- .../ui/qml/DefaultVpn/Controls/InputType.qml | 56 +++++++++---------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/client/ui/qml/DefaultVpn/Controls/InputType.qml b/client/ui/qml/DefaultVpn/Controls/InputType.qml index 9e447b06..5670a7db 100644 --- a/client/ui/qml/DefaultVpn/Controls/InputType.qml +++ b/client/ui/qml/DefaultVpn/Controls/InputType.qml @@ -9,7 +9,7 @@ import Config 1.0 import "TextTypes" -ScrollView { +TextField { id: root property string defaultBackgroundColor: Style.color.white @@ -24,35 +24,33 @@ ScrollView { property string disabledBorderColor: Style.color.gray3 property string disabledTextColor: Style.color.gray9 - property string placeholderText - TextArea { - color: root.enabled ? root.defaultTextColor : (root.hovered || root.pressed) ? root.hoveredTextColor : root.disabledTextColor - background: Rectangle { - anchors.fill: parent + color: root.enabled ? root.defaultTextColor : (root.hovered || root.pressed) ? root.hoveredTextColor : root.disabledTextColor + background: Rectangle { + anchors.fill: parent - color: root.enabled ? root.defaultBackgroundColor : (root.hovered || root.pressed) ? root.hoveredBackgroundColor : root.disabledBackgroundColor - border.color: root.enabled ? root.defaultBorderColor : (root.hovered || root.pressed) ? root.hoveredBorderColor : root.disabledBorderColor - border.width: 1 - radius: 6 - } - - topPadding: 12 - bottomPadding: 12 - leftPadding: 16 - rightPadding: 16 - - inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhSensitiveData | Qt.ImhNoPredictiveText - - selectionColor: Style.color.accent1 - selectedTextColor: Style.color.white - - font.pixelSize: 17 - font.weight: 400 - font.family: Style.font - - wrapMode: TextEdit.Wrap - - placeholderText: root.placeholderText + color: root.enabled ? root.defaultBackgroundColor : (root.hovered || root.pressed) ? root.hoveredBackgroundColor : root.disabledBackgroundColor + border.color: root.enabled ? root.defaultBorderColor : (root.hovered || root.pressed) ? root.hoveredBorderColor : root.disabledBorderColor + border.width: 1 + radius: 6 } + + topPadding: 12 + bottomPadding: 12 + leftPadding: 16 + rightPadding: 16 + + inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhSensitiveData | Qt.ImhNoPredictiveText + + selectionColor: Style.color.accent1 + selectedTextColor: Style.color.white + + font.pixelSize: 17 + font.weight: 400 + font.family: Style.font + + wrapMode: TextEdit.Wrap + + verticalAlignment: Text.AlignTop + }