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 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) diff --git a/README_RU.md b/README_RU.md new file mode 100644 index 00000000..fe9dd286 --- /dev/null +++ b/README_RU.md @@ -0,0 +1,75 @@ +# Amnezia 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) + +### [Сайт](https://amnezia.org) | [Зеркало на сайт](https://storage.googleapis.com/kldscp/amnezia.org) | [Документация](https://docs.amnezia.org) | [Решение проблем](https://docs.amnezia.org/troubleshooting) + +> [!TIP] +> Если [сайт Amnezia](https://amnezia.org) заблокирован в вашем регионе, вы можете воспользоваться [ссылкой на зеркало](https://storage.googleapis.com/kldscp/amnezia.org). + + + + +[Все релизы](https://github.com/amnezia-vpn/amnezia-client/releases) + +
+ + + +## Особенности + +- Простой в использовании — введите 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). + +## Ссылки + +- [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 (Английский) +- [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\) + +## Технологии + +AmneziaVPN использует несколько проектов с открытым исходным кодом: + +- [OpenSSL](https://www.openssl.org/) +- [OpenVPN](https://openvpn.net/) +- [Shadowsocks](https://shadowsocks.org/) +- [Qt](https://www.qt.io/) +- [LibSsh](https://libssh.org) +- и другие... + +## Лицензия + +GPL v3.0 + +## Донаты + +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 + +## Благодарности + +Этот проект тестируется с помощью BrowserStack. +Мы выражаем благодарность [BrowserStack](https://www.browserstack.com) за поддержку нашего проекта. diff --git a/client/amnezia_application.cpp b/client/amnezia_application.cpp index 24dc531a..6e84563e 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/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/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/fonts/VelaSans-GX.ttf b/client/fonts/VelaSans-GX.ttf new file mode 100644 index 00000000..0a75bb2e Binary files /dev/null and b/client/fonts/VelaSans-GX.ttf differ 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/translations/amneziavpn_ar_EG.ts b/client/translations/amneziavpn_ar_EG.ts index 6d4634c4..df41239d 100644 --- a/client/translations/amneziavpn_ar_EG.ts +++ b/client/translations/amneziavpn_ar_EG.ts @@ -4,52 +4,52 @@ ApiServicesModel - + Classic VPN for comfortable work, downloading large files and watching videos. Works for any sites. Speed up to %1 MBit/s شبكة VPN كلاسيكية للعمل المريح وتنزيل الملفات الكبيرة ومشاهدة مقاطع الفيديو. تعمل مع أي موقع. تصل السرعة إلى %1 ميجابت/ثانية - + VPN to access blocked sites in regions with high levels of Internet censorship. شبكة VPN للولوج للمواقع المحظورة في بلاد ذو مستوي عالي من الرقابة علي الانترنت. - + <p><a style="color: #EB5757;">Not available in your region. If you have VPN enabled, disable it, return to the previous screen, and try again.</a> - + Amnezia Premium - A classic VPN for comfortable work, downloading large files, and watching videos in high resolution. It works for all websites, even in countries with the highest level of internet censorship. Amenzia Premium - شبكة VPN للعمل المريح, تحميل ملفات كبيرة الحجم, ومشاهدة مقاطع الفيديو ب جودة عالية. تعمل لجميع المواقع, حتي في البلاد ذو مستوي عالي من الرقابة علي الانترنت - + Amnezia Free is a free VPN to bypass blocking in countries with high levels of internet censorship Amnezia Free هو VPN مجاني لتخطي الحظر في البلاد ذو مستوي عالي من الرقابة علي الانترنت - + %1 MBit/s %1 ميجابت/ثانية - + %1 days %1 ايام - + VPN will open only popular sites blocked in your region, such as Instagram, Facebook, Twitter and others. Other sites will be opened from your real IP address, <a href="%1/free" style="color: #FBB26A;">more details on the website.</a> سيقوم VPN فقط بفتح المواقع المشهورة المحظورة في بلدك, مثل Instagram, Facebook, Twitter و مواقع اخري. المواقع الاخري ستٌفتح من عنوان ال IP الحقيقي الخاص بك, <a href="%1/free" style="color: #FBB26A;">معلومات اخري علي الموقع.</a> - + Free مجاني - + %1 $/month %1 دولار/الشهر @@ -482,6 +482,21 @@ Already installed containers were found on the server. All installed containers Unable change server while there is an active connection لا يمكن تغير الخادم بينما هناك اتصال مفعل + + + Online + + + + + Offline + + + + + Connection to + + PageProtocolAwgClientSettings @@ -1376,6 +1391,14 @@ Already installed containers were found on the server. All installed containers سياسات الخصوصية + + PageSettingsApiLanguageList + + + Unable change server location while there is an active connection + + + PageSettingsApiServerInfo @@ -1389,64 +1412,69 @@ Already installed containers were found on the server. All installed containers السعر - + Work period مدة العمل - + + Valid until + + + + Speed السرعة - + Support tag علامة الدعم - + Copied تم النسخ - + Reload API config إعادة تحميل تكوين API - + Reload API config? إعادة تحميل تكوين API - - + + Continue واصل - - + + Cancel إلغاء - + Cannot reload API config during active connection لا يمكن إعادة تحميل تكوين API اثناء تواجد اتصال نشط - + Remove from application احذف من التطبيق - + Remove from application? احذف من التطبيق؟ - + Cannot remove server during active connection لا يمكن إزالة الخادم أثناء الاتصال النشط @@ -2048,30 +2076,50 @@ Already installed containers were found on the server. All installed containers PageSettingsServerInfo - + + Subscription is valid until + + + + Server name اسم الخادم - + Save احفظ - + Protocols البروتوكولات - + Services الخدمات - + Management الإدارة + + + Server settings + + + + + Name + + + + + Remove server + + PageSettingsServerProtocol @@ -2164,6 +2212,11 @@ Already installed containers were found on the server. All installed containers Servers الخوادم + + + Connect to + + PageSettingsSplitTunneling @@ -2419,6 +2472,31 @@ Already installed containers were found on the server. All installed containers I have nothing ليس لدي اي شئ + + + Adding a server to connect to + + + + + Key + مفتاح + + + + VPN:// + + + + + Add + + + + + Unsupported config file + + PageSetupWizardCredentials @@ -3315,7 +3393,7 @@ Already installed containers were found on the server. All installed containers هذا التكوين بالفعل تمت إضافتة للبرنامج - + ErrorCode: %1. @@ -3420,37 +3498,42 @@ Already installed containers were found on the server. All installed containers - + + Missing list of available services + + + + QFile error: The file could not be opened خطأ QFile: لا يمكن فتح الملف - + QFile error: An error occurred when reading from the file خطأ QFile: ظهر خطأ اثناء القراءه من الملف - + QFile error: The file could not be accessed خطأ QFile: لا يمكن الوصول للملف - + QFile error: An unspecified error occurred خطأ QFile: ظهر خطأ غير محدد - + QFile error: A fatal error occurred خطأ QFile: حدث خطأ فادح - + QFile error: The operation was aborted خطأ QFile: تم إحباط العملية - + Internal error خطأ داخلي diff --git a/client/translations/amneziavpn_fa_IR.ts b/client/translations/amneziavpn_fa_IR.ts index 5a65e415..789e52a1 100644 --- a/client/translations/amneziavpn_fa_IR.ts +++ b/client/translations/amneziavpn_fa_IR.ts @@ -4,52 +4,52 @@ ApiServicesModel - + Classic VPN for comfortable work, downloading large files and watching videos. Works for any sites. Speed up to %1 MBit/s برای کار راحت، دانلود فایل‌های بزرگ و تماشای ویدیوها، از VPN کلاسیک استفاده کنید. این VPN برای هر سایتی کار می‌کند و سرعت آن تا %1 مگابیت بر ثانیه است. - + VPN to access blocked sites in regions with high levels of Internet censorship. وی پی ان برای دسترسی به سایت‌های مسدود شده در مناطق با سانسور شدید اینترنت. - + <p><a style="color: #EB5757;">Not available in your region. If you have VPN enabled, disable it, return to the previous screen, and try again.</a> - + Amnezia Premium - A classic VPN for comfortable work, downloading large files, and watching videos in high resolution. It works for all websites, even in countries with the highest level of internet censorship. امنزیا پریمیوم - یک وی پی ان کلاسیک برای کار راحت، دانلود فایل‌های بزرگ و تماشای ویدیو با کیفیت بالا. قابل استفاده برای تمامی سایت‌ها، حتی در کشورهایی با بالاترین سطح سانسور اینترنت. - + Amnezia Free is a free VPN to bypass blocking in countries with high levels of internet censorship امنزیا رایگان یک وی پی ان رایگان برای دور زدن مسدودیت‌ها در کشورهایی با سطح بالای سانسور اینترنت است. - + %1 MBit/s %1 MBit/s - + %1 days %1 روز - + VPN will open only popular sites blocked in your region, such as Instagram, Facebook, Twitter and others. Other sites will be opened from your real IP address, <a href="%1/free" style="color: #FBB26A;">more details on the website.</a> وی پی ان فقط سایت‌های محبوبی را که در منطقه شما مسدود شده‌اند، مانند اینستاگرام، فیسبوک، توییتر و غیره باز می‌کند. سایر سایت‌ها با آدرس آی‌پی واقعی شما باز خواهند شد. <a href="%1/free" style="color: #FBB26A;">more details on the website.</a> - + Free رایگان - + %1 $/month %1 $/ماه @@ -486,6 +486,21 @@ Already installed containers were found on the server. All installed containers Unable change server while there is an active connection امکان تغییر سرور در هنگام متصل بودن وجود ندارد + + + Online + + + + + Offline + + + + + Connection to + + PageProtocolAwgClientSettings @@ -1459,6 +1474,14 @@ Already installed containers were found on the server. All installed containers + + PageSettingsApiLanguageList + + + Unable change server location while there is an active connection + + + PageSettingsApiServerInfo @@ -1472,64 +1495,69 @@ Already installed containers were found on the server. All installed containers قیمت - + Work period مدت زمان کار - + + Valid until + + + + Speed سرعت - + Support tag - + Copied کپی شد - + Reload API config بارگذاری مجدد پیکربندی API - + Reload API config? آیا می‌خواهید پیکربندی API را دوباره بارگذاری کنید؟ - - + + Continue ادامه دهید - - + + Cancel لغو - + Cannot reload API config during active connection نمی‌توان پیکربندی API را در حین اتصال فعال دوباره بارگذاری کرد. - + Remove from application حذف از برنامه - + Remove from application? آیا می‌خواهید از برنامه حذف کنید؟ - + Cannot remove server during active connection نمی‌توان سرور را در حین اتصال فعال حذف کرد. @@ -2143,30 +2171,50 @@ Already installed containers were found on the server. All installed containers PageSettingsServerInfo - + + Subscription is valid until + + + + Server name نام سرور - + Save ذخیره - + Protocols پروتکل‎ها - + Services سرویس‎ها - + Management مدیریت + + + Server settings + + + + + Name + + + + + Remove server + + PageSettingsServerProtocol @@ -2259,6 +2307,11 @@ Already installed containers were found on the server. All installed containers Servers سرورها + + + Connect to + + PageSettingsSplitTunneling @@ -2542,6 +2595,31 @@ It's okay as long as it's from someone you trust. Key as text متن شامل کلید + + + Adding a server to connect to + + + + + Key + کلید + + + + VPN:// + + + + + Add + + + + + Unsupported config file + + PageSetupWizardCredentials @@ -3506,7 +3584,7 @@ It's okay as long as it's from someone you trust. این پیکربندی قبلاً به برنامه اضافه شده است - + ErrorCode: %1. کد خطا: %1. @@ -3606,37 +3684,42 @@ It's okay as long as it's from someone you trust. - - QFile error: The file could not be opened + + Missing list of available services - QFile error: An error occurred when reading from the file + QFile error: The file could not be opened - QFile error: The file could not be accessed + QFile error: An error occurred when reading from the file - QFile error: An unspecified error occurred + QFile error: The file could not be accessed - QFile error: A fatal error occurred + QFile error: An unspecified error occurred + QFile error: A fatal error occurred + + + + QFile error: The operation was aborted - + Internal error Internal error diff --git a/client/translations/amneziavpn_hi_IN.ts b/client/translations/amneziavpn_hi_IN.ts index 1ff5f013..b9d06701 100644 --- a/client/translations/amneziavpn_hi_IN.ts +++ b/client/translations/amneziavpn_hi_IN.ts @@ -4,52 +4,52 @@ ApiServicesModel - + Classic VPN for comfortable work, downloading large files and watching videos. Works for any sites. Speed up to %1 MBit/s - + VPN to access blocked sites in regions with high levels of Internet censorship. - + <p><a style="color: #EB5757;">Not available in your region. If you have VPN enabled, disable it, return to the previous screen, and try again.</a> - + Amnezia Premium - A classic VPN for comfortable work, downloading large files, and watching videos in high resolution. It works for all websites, even in countries with the highest level of internet censorship. - + Amnezia Free is a free VPN to bypass blocking in countries with high levels of internet censorship - + %1 MBit/s - + %1 days - + VPN will open only popular sites blocked in your region, such as Instagram, Facebook, Twitter and others. Other sites will be opened from your real IP address, <a href="%1/free" style="color: #FBB26A;">more details on the website.</a> - + Free - + %1 $/month @@ -482,6 +482,21 @@ Already installed containers were found on the server. All installed containers Unable change server while there is an active connection सक्रिय कनेक्शन होने पर सर्वर बदलने में असमर्थ + + + Online + + + + + Offline + + + + + Connection to + + PageProtocolAwgClientSettings @@ -1416,6 +1431,14 @@ Already installed containers were found on the server. All installed containers गोपनीयता नीति + + PageSettingsApiLanguageList + + + Unable change server location while there is an active connection + + + PageSettingsApiServerInfo @@ -1429,64 +1452,69 @@ Already installed containers were found on the server. All installed containers - + Work period - + + Valid until + + + + Speed - + Support tag - + Copied कॉपी किया गया - + Reload API config - + Reload API config? - - + + Continue जारी रखना - - + + Cancel रद्द करना - + Cannot reload API config during active connection - + Remove from application - + Remove from application? - + Cannot remove server during active connection सक्रिय कनेक्शन के दौरान सर्वर को हटाया नहीं जा सकता @@ -2096,30 +2124,50 @@ Already installed containers were found on the server. All installed containers PageSettingsServerInfo - + + Subscription is valid until + + + + Server name सर्वर का नाम - + Save सहेजें - + Protocols प्रोटोकॉल - + Services सेवाएं - + Management प्रबंध + + + Server settings + + + + + Name + + + + + Remove server + + PageSettingsServerProtocol @@ -2212,6 +2260,11 @@ Already installed containers were found on the server. All installed containers Servers सर्वर + + + Connect to + + PageSettingsSplitTunneling @@ -2487,6 +2540,31 @@ Already installed containers were found on the server. All installed containers Key as text पाठ के रूप में कुंजी + + + Adding a server to connect to + + + + + Key + चाबी + + + + VPN:// + + + + + Add + + + + + Unsupported config file + + PageSetupWizardCredentials @@ -3450,7 +3528,12 @@ Already installed containers were found on the server. All installed containers - + + Missing list of available services + + + + ErrorCode: %1. ErrorCode: %1. @@ -3515,37 +3598,37 @@ Already installed containers were found on the server. All installed containers कॉन्फ़िगरेशन में सर्वर से कनेक्ट करने के लिए कोई कंटेनर और क्रेडेंशियल नहीं है - + QFile error: The file could not be opened Qफ़ाइल त्रुटि: फ़ाइल खोली नहीं जा सकी - + QFile error: An error occurred when reading from the file Qफ़ाइल त्रुटि: फ़ाइल से पढ़ते समय एक त्रुटि उत्पन्न हुई - + QFile error: The file could not be accessed Qफ़ाइल त्रुटि: फ़ाइल तक नहीं पहुंचा जा सका - + QFile error: An unspecified error occurred Qफ़ाइल त्रुटि: एक अनिर्दिष्ट त्रुटि उत्पन्न हुई - + QFile error: A fatal error occurred Qफ़ाइल त्रुटि: एक घातक त्रुटि उत्पन्न हुई - + QFile error: The operation was aborted Qफ़ाइल त्रुटि: ऑपरेशन निरस्त कर दिया गया था - + Internal error आंतरिक त्रुटि diff --git a/client/translations/amneziavpn_my_MM.ts b/client/translations/amneziavpn_my_MM.ts index a13e77f1..62e41897 100644 --- a/client/translations/amneziavpn_my_MM.ts +++ b/client/translations/amneziavpn_my_MM.ts @@ -4,52 +4,52 @@ ApiServicesModel - + Classic VPN for comfortable work, downloading large files and watching videos. Works for any sites. Speed up to %1 MBit/s သက်တောင့်သက်သာအလုပ်လုပ်နိုင်ဖို့အတွက်နှင့် ကြီးမားသောဖိုင်များကိုဒေါင်းလုဒ်လုပ်ခြင်းနှင့် ဗီဒီယိုများကြည့်ရှုခြင်းတို့အတွက် အသုံးပြုနိုင်သော VPN ဖြစ်ပါတယ်။ မည်သည့်ဆိုက်များအတွက်မဆိုအလုပ်လုပ်ပြီး လိုင်းအရှိန် %1 MBit/s အထိအသုံးပြုနိုင်ပါတယ်။ - + VPN to access blocked sites in regions with high levels of Internet censorship. အင်တာနက် ဆင်ဆာဖြတ်တောက်မှု မြင့်မားသော ဒေသများရှိ ပိတ်ဆို့ထားသော ဆိုက်များကို ဝင်ရောက်ရန် VPN။. - + <p><a style="color: #EB5757;">Not available in your region. If you have VPN enabled, disable it, return to the previous screen, and try again.</a> - + Amnezia Premium - A classic VPN for comfortable work, downloading large files, and watching videos in high resolution. It works for all websites, even in countries with the highest level of internet censorship. Amnezia Premium - သက်တောင့်သက်သာအလုပ်လုပ်နိုင်ဖို့အတွက်နှင့် ကြီးမားသောဖိုင်များကိုဒေါင်းလုဒ်လုပ်ခြင်းနှင့် ဗီဒီယိုများကိုကြည်လင်ပြတ်သားစွာကြည့်ရှုခြင်းတို့အတွက် အသုံးပြုနိုင်သော VPN ဖြစ်ပါတယ်။ အင်တာနက်ဆင်ဆာဖြတ်မှု အဆင့်အမြင့်ဆုံးနိုင်ငံများတွင်ပင် မည်သည့်ဆိုက်များအတွက်မဆို အလုပ်လုပ်ပါသည်။. - + Amnezia Free is a free VPN to bypass blocking in countries with high levels of internet censorship Amnezia Free သည် အင်တာနက်ဆင်ဆာဖြတ်တောက်မှု မြင့်မားသောနိုင်ငံများတွင် ပိတ်ဆို့ခြင်းကို ကျော်ဖြတ်ရန်အတွက် အခမဲ့ VPN တစ်ခုဖြစ်ပါသည်။ - + %1 MBit/s %1 MBit/s - + %1 days %1 ရက် - + VPN will open only popular sites blocked in your region, such as Instagram, Facebook, Twitter and others. Other sites will be opened from your real IP address, <a href="%1/free" style="color: #FBB26A;">more details on the website.</a> ဤ VPN သည် သင့်ဒေသရှိ Instagram၊ Facebook၊ Twitter နှင့် အခြားသော လူကြိုက်များသော ဆိုက်များကိုသာ ဖွင့်ပေးပါမည်။ အခြားဝဘ်ဆိုက်များကိုမူ သင်၏ IP လိပ်စာအစစ်အမှန်ဖြင့်သာ ဖွင့်ပေးပါမည်၊ <a href="%1/free" style="color: #FBB26A;">နောက်ထပ်အသေးစိတ်အချက်အလက်များကို ဝဘ်ဆိုဒ်ပေါ်တွင်ကြည့်ရန်</a> - + Free အခမဲ့ - + %1 $/month %1 $/တစ်လ @@ -482,6 +482,21 @@ Already installed containers were found on the server. All installed containers Unable change server while there is an active connection လက်ရှိချိတ်ဆက်မှုတစ်ခုရှိနေချိန်တွင် ဆာဗာကို ပြောင်းလဲ၍မရပါ + + + Online + + + + + Offline + + + + + Connection to + + PageProtocolAwgClientSettings @@ -1384,6 +1399,14 @@ Already installed containers were found on the server. All installed containers ကိုယ်ရေးအချက်အလက်မူဝါဒ + + PageSettingsApiLanguageList + + + Unable change server location while there is an active connection + + + PageSettingsApiServerInfo @@ -1397,64 +1420,69 @@ Already installed containers were found on the server. All installed containers စျေးနှုန်း - + Work period အလုပ်လုပ်မည့်ကာလ - + + Valid until + + + + Speed မြန်နှုန်း - + Support tag ကူညီပံ့ပိုးမှု tag - + Copied ကူးယူပြီးပါပြီ - + Reload API config API config ကို ပြန်လည်စတင်မည် - + Reload API config? API config ကို ပြန်လည်စတင်မည်လား? - - + + Continue ဆက်လက်လုပ်ဆောင်မည် - - + + Cancel ပယ်ဖျက်မည် - + Cannot reload API config during active connection ချိတ်ဆက်မှုရှိနေချိန်အတွင်း API config ကို ပြန်လည်စတင်၍မရပါ - + Remove from application အပလီကေးရှင်းမှဖယ်ရှားမည် - + Remove from application? အပလီကေးရှင်းမှဖယ်ရှားမည်လား? - + Cannot remove server during active connection ချိတ်ဆက်မှုရှိနေချိန်အတွင်း ဆာဗာကို ဖယ်ရှား၍မရပါ @@ -2057,30 +2085,50 @@ Already installed containers were found on the server. All installed containers PageSettingsServerInfo - + + Subscription is valid until + + + + Server name ဆာဗာအမည် - + Save သိမ်းဆည်းမည် - + Protocols ပရိုတိုကောများ - + Services ဝန်ဆောင်မှုများ - + Management စီမံခန့်ခွဲမှု + + + Server settings + + + + + Name + + + + + Remove server + + PageSettingsServerProtocol @@ -2173,6 +2221,11 @@ Already installed containers were found on the server. All installed containers Servers ဆာဗာများ + + + Connect to + + PageSettingsSplitTunneling @@ -2428,6 +2481,31 @@ Already installed containers were found on the server. All installed containers I have nothing ကျွန်ုပ်တွင်ဘာမှမရှိပါ + + + Adding a server to connect to + + + + + Key + Key + + + + VPN:// + + + + + Add + + + + + Unsupported config file + + PageSetupWizardCredentials @@ -3311,7 +3389,7 @@ Already installed containers were found on the server. All installed containers ဤ config ကို အပလီကေးရှင်းထဲသို့ ထည့်သွင်းပြီးဖြစ်သည် - + ErrorCode: %1. မှားယွင်းမှုကုတ်: %1. @@ -3416,37 +3494,42 @@ Already installed containers were found on the server. All installed containers - + + Missing list of available services + + + + QFile error: The file could not be opened QFile မှားယွင်းမှု: ဖိုင်ကို ဖွင့်၍မရပါ - + QFile error: An error occurred when reading from the file QFile မှားယွင်းမှု: ဖိုင်ကိုဖတ်နေစဥ်အတွင်း မှားယွင်းမှုဖြစ်သွားသည် - + QFile error: The file could not be accessed QFile မှားယွင်းမှု: ဖိုင်ကို ဝင်၍မရပါ - + QFile error: An unspecified error occurred QFile မှားယွင်းမှု: သတ်မှတ်မထားသော မှားယွင်းမှုတစ်ခု ဖြစ်ပွားခဲ့သည် - + QFile error: A fatal error occurred QFile မှားယွင်းမှု: ကြီးမားသော မှားယွင်းမှုတစ်ခု ဖြစ်ပွားခဲ့သည် - + QFile error: The operation was aborted QFile မှားယွင်းမှု: လုပ်ငန်းစဥ်ကို ဖျက်သိမ်းလိုက်ရသည် - + Internal error စက်တွင်းဖြစ်သော မှားယွင်းမှု diff --git a/client/translations/amneziavpn_ru_RU.ts b/client/translations/amneziavpn_ru_RU.ts index fc6c8d38..b2da3abc 100644 --- a/client/translations/amneziavpn_ru_RU.ts +++ b/client/translations/amneziavpn_ru_RU.ts @@ -4,52 +4,52 @@ ApiServicesModel - + Classic VPN for comfortable work, downloading large files and watching videos. Works for any sites. Speed up to %1 MBit/s Классический VPN для комфортной работы, загрузки больших файлов и просмотра видео. Работает для любых сайтов. Скорость до %1 Мбит/с - + VPN to access blocked sites in regions with high levels of Internet censorship. VPN для доступа к заблокированным сайтам в регионах с высоким уровнем интернет-цензуры. - + <p><a style="color: #EB5757;">Not available in your region. If you have VPN enabled, disable it, return to the previous screen, and try again.</a> - + Amnezia Premium - A classic VPN for comfortable work, downloading large files, and watching videos in high resolution. It works for all websites, even in countries with the highest level of internet censorship. Amnezia Premium — классический VPN для комфортной работы, загрузки больших файлов и просмотра видео в высоком разрешении. Работает на всех сайтах, даже в странах с самым высоким уровнем интернет-цензуры. - + Amnezia Free is a free VPN to bypass blocking in countries with high levels of internet censorship Amnezia Free - это бесплатный VPN для обхода блокировок в странах с высоким уровнем интернет-цензуры - + %1 MBit/s - + %1 days %1 дней - + VPN will open only popular sites blocked in your region, such as Instagram, Facebook, Twitter and others. Other sites will be opened from your real IP address, <a href="%1/free" style="color: #FBB26A;">more details on the website.</a> Через VPN будут открываться только популярные сайты, заблокированные в вашем регионе, такие как Instagram, Facebook, Twitter и другие. Остальные сайты будут открываться с вашего реального IP-адреса, <a href="%1/free" style="color: #FBB26A;">подробности на сайте.</a> - + Free Бесплатно - + %1 $/month %1 $/месяц @@ -486,6 +486,21 @@ Already installed containers were found on the server. All installed containers Unable change server while there is an active connection Невозможно изменить сервер во время активного соединения + + + Online + + + + + Offline + + + + + Connection to + + PageProtocolAwgClientSettings @@ -1479,6 +1494,14 @@ Already installed containers were found on the server. All installed containers Политика конфиденциальности + + PageSettingsApiLanguageList + + + Unable change server location while there is an active connection + + + PageSettingsApiServerInfo @@ -1492,64 +1515,69 @@ Already installed containers were found on the server. All installed containers Цена - + Work period Период работы - + + Valid until + + + + Speed Скорость - + Support tag - + Copied Скопировано - + Reload API config Перезагрузить конфигурацию API - + Reload API config? Перезагрузить конфигурацию API? - - + + Continue Продолжить - - + + Cancel Отменить - + Cannot reload API config during active connection Невозможно перзагрузить API конфигурацию при активном соединении - + Remove from application Удалить из приложения - + Remove from application? Удалить из приложения? - + Cannot remove server during active connection Невозможно удалить сервер во время активного соединения @@ -2199,27 +2227,32 @@ Already installed containers were found on the server. All installed containers PageSettingsServerInfo - + + Subscription is valid until + + + + Server name Имя сервера - + Save Сохранить - + Protocols Протоколы - + Services Сервисы - + Management Управление @@ -2227,6 +2260,21 @@ Already installed containers were found on the server. All installed containers Data Данные + + + Server settings + + + + + Name + + + + + Remove server + + PageSettingsServerProtocol @@ -2323,6 +2371,11 @@ Already installed containers were found on the server. All installed containers Servers Серверы + + + Connect to + + PageSettingsSplitTunneling @@ -2614,6 +2667,31 @@ It's okay as long as it's from someone you trust. Key as text Ключ в виде текста + + + Adding a server to connect to + + + + + Key + Ключ + + + + VPN:// + + + + + Add + + + + + Unsupported config file + + PageSetupWizardCredentials @@ -3647,7 +3725,7 @@ and will not be shared or disclosed to the Amnezia or any third parties Данная конфигурация уже была добавлена в приложение - + ErrorCode: %1. Код ошибки: %1. @@ -3741,37 +3819,42 @@ and will not be shared or disclosed to the Amnezia or any third parties - + + Missing list of available services + + + + QFile error: The file could not be opened Ошибка QFile: не удалось открыть файл - + QFile error: An error occurred when reading from the file Ошибка QFile: произошла ошибка при чтении из файла - + QFile error: The file could not be accessed Ошибка QFile: не удалось получить доступ к файлу - + QFile error: An unspecified error occurred Ошибка QFile: произошла неизвестная ошибка - + QFile error: A fatal error occurred Ошибка QFile: произошла фатальная ошибка - + QFile error: The operation was aborted Ошибка QFile: операция была прервана - + Internal error Внутренняя ошибка diff --git a/client/translations/amneziavpn_uk_UA.ts b/client/translations/amneziavpn_uk_UA.ts index e2b79498..04e25744 100644 --- a/client/translations/amneziavpn_uk_UA.ts +++ b/client/translations/amneziavpn_uk_UA.ts @@ -27,52 +27,52 @@ ApiServicesModel - + Classic VPN for comfortable work, downloading large files and watching videos. Works for any sites. Speed up to %1 MBit/s Звичайний VPN для комфортної роботи, завантаження великих файлів та перегляду відео. Працює для будь-яких сайтів. Швидкість до %1 MBit/s - + VPN to access blocked sites in regions with high levels of Internet censorship. VPN для доступу до заблокованих сайтів у регіонах з високим рівнем інтернет-цензури. - + <p><a style="color: #EB5757;">Not available in your region. If you have VPN enabled, disable it, return to the previous screen, and try again.</a> - + Amnezia Premium - A classic VPN for comfortable work, downloading large files, and watching videos in high resolution. It works for all websites, even in countries with the highest level of internet censorship. Amnezia Premium - звичайний VPN для комфортної роботи, завантаження великих файлів та перегляду відео у високій роздільній здатності. Працює для всіх вебсайтів, навіть у країнах з найвищим рівнем інтернет-цензури. - + Amnezia Free is a free VPN to bypass blocking in countries with high levels of internet censorship Amnezia Free — це безкоштовний VPN для обходу блокувань у країнах з високим рівнем інтернет-цензури - + %1 MBit/s %1 MBit/s - + %1 days %1 днів - + VPN will open only popular sites blocked in your region, such as Instagram, Facebook, Twitter and others. Other sites will be opened from your real IP address, <a href="%1/free" style="color: #FBB26A;">more details on the website.</a> Лише популярні сайти, які заблоковані у вашому регіоні, будуть відкриватись за допомогою VPN підключення (Instagram, Facebook, Twitter та ін.). Звичайні сайти будуть відкриватися без використання VPN, <a href="%1/free" style="color: #FBB26A;">більш детально на нашому сайті.</a> - + Free Безкоштовно - + %1 $/month %1 $/місяць @@ -512,6 +512,21 @@ Already installed containers were found on the server. All installed containers Unable change server while there is an active connection Не можна змінити сервер при активному підключенні + + + Online + + + + + Offline + + + + + Connection to + + PageProtocolAwgClientSettings @@ -1553,6 +1568,14 @@ Already installed containers were found on the server. All installed containers Політика конфіденційності + + PageSettingsApiLanguageList + + + Unable change server location while there is an active connection + + + PageSettingsApiServerInfo @@ -1566,64 +1589,69 @@ Already installed containers were found on the server. All installed containers Ціна - + Work period Період роботи - + + Valid until + + + + Speed Швидкість - + Support tag - + Copied Скопійовано - + Reload API config Перезавантажити конфігурацію API - + Reload API config? Перезавантажити конфігурацію API? - - + + Continue Продовжити - - + + Cancel Відмінити - + Cannot reload API config during active connection Неможливо перезавантажити конфігурацію API під час активного підключення - + Remove from application Видалити з додатку - + Remove from application? Видалити з додатку? - + Cannot remove server during active connection Неможливо видалити сервер під час активного підключення @@ -2285,27 +2313,32 @@ Already installed containers were found on the server. All installed containers PageSettingsServerInfo - + + Subscription is valid until + + + + Server name Імя сервера - + Save Зберегти - + Protocols Протоколи - + Services Сервіси - + Management Управління @@ -2313,6 +2346,21 @@ Already installed containers were found on the server. All installed containers Data Дані + + + Server settings + + + + + Name + + + + + Remove server + + PageSettingsServerProtocol @@ -2409,6 +2457,11 @@ Already installed containers were found on the server. All installed containers Servers Сервери + + + Connect to + + PageSettingsSplitTunneling @@ -2700,6 +2753,31 @@ It's okay as long as it's from someone you trust. Key as text Ключ у вигляді тексту + + + Adding a server to connect to + + + + + Key + Ключ + + + + VPN:// + + + + + Add + + + + + Unsupported config file + + PageSetupWizardCredentials @@ -3743,7 +3821,7 @@ and will not be shared or disclosed to the Amnezia or any third parties Ця конфігурація вже була додана в застосунок - + ErrorCode: %1. @@ -3837,37 +3915,42 @@ and will not be shared or disclosed to the Amnezia or any third parties - - QFile error: The file could not be opened + + Missing list of available services - QFile error: An error occurred when reading from the file + QFile error: The file could not be opened - QFile error: The file could not be accessed + QFile error: An error occurred when reading from the file - QFile error: An unspecified error occurred + QFile error: The file could not be accessed - QFile error: A fatal error occurred + QFile error: An unspecified error occurred + QFile error: A fatal error occurred + + + + QFile error: The operation was aborted - + Internal error Internal error diff --git a/client/translations/amneziavpn_ur_PK.ts b/client/translations/amneziavpn_ur_PK.ts index 76c25335..d20aae05 100644 --- a/client/translations/amneziavpn_ur_PK.ts +++ b/client/translations/amneziavpn_ur_PK.ts @@ -4,52 +4,52 @@ ApiServicesModel - + Classic VPN for comfortable work, downloading large files and watching videos. Works for any sites. Speed up to %1 MBit/s - + VPN to access blocked sites in regions with high levels of Internet censorship. - + <p><a style="color: #EB5757;">Not available in your region. If you have VPN enabled, disable it, return to the previous screen, and try again.</a> - + Amnezia Premium - A classic VPN for comfortable work, downloading large files, and watching videos in high resolution. It works for all websites, even in countries with the highest level of internet censorship. - + Amnezia Free is a free VPN to bypass blocking in countries with high levels of internet censorship - + %1 MBit/s - + %1 days - + VPN will open only popular sites blocked in your region, such as Instagram, Facebook, Twitter and others. Other sites will be opened from your real IP address, <a href="%1/free" style="color: #FBB26A;">more details on the website.</a> - + Free - + %1 $/month @@ -482,6 +482,21 @@ Already installed containers were found on the server. All installed containers Unable change server while there is an active connection فعال کنکشن موجود ہونے کی وجہ سے سرور تبدیل کرنے میں ناکام ہیں + + + Online + + + + + Offline + + + + + Connection to + + PageProtocolAwgClientSettings @@ -1420,6 +1435,14 @@ Already installed containers were found on the server. All installed containers رازداری کی پالیسی + + PageSettingsApiLanguageList + + + Unable change server location while there is an active connection + + + PageSettingsApiServerInfo @@ -1433,64 +1456,69 @@ Already installed containers were found on the server. All installed containers - + Work period - + + Valid until + + + + Speed - + Support tag - + Copied - + Reload API config - + Reload API config? - - + + Continue - - + + Cancel - + Cannot reload API config during active connection - + Remove from application - + Remove from application? - + Cannot remove server during active connection چالو کنکشن کے دوران سرور کو ہٹایا نہیں جا سکتا @@ -2100,30 +2128,50 @@ Already installed containers were found on the server. All installed containers PageSettingsServerInfo - + + Subscription is valid until + + + + Server name سرور کا نام - + Save محفوظ - + Protocols پروٹوکولات - + Services خدمات - + Management مینجمنٹ + + + Server settings + + + + + Name + + + + + Remove server + + PageSettingsServerProtocol @@ -2216,6 +2264,11 @@ Already installed containers were found on the server. All installed containers Servers سرور + + + Connect to + + PageSettingsSplitTunneling @@ -2491,6 +2544,31 @@ Already installed containers were found on the server. All installed containers Key as text متن کے طور پر کلید + + + Adding a server to connect to + + + + + Key + کلید + + + + VPN:// + + + + + Add + + + + + Unsupported config file + + PageSetupWizardCredentials @@ -3414,7 +3492,7 @@ Already installed containers were found on the server. All installed containers یہ تشکیل پہلے ہی ایپلی کیشن میں شامل کی جا چکی ہے - + ErrorCode: %1. ایرر کوڈ: %1. @@ -3519,37 +3597,42 @@ Already installed containers were found on the server. All installed containers - + + Missing list of available services + + + + QFile error: The file could not be opened QFile کی خرابی: فائل کو نہیں کھولا جا سکا - + QFile error: An error occurred when reading from the file کیو فائل کی خرابی: فائل سے پڑھتے وقت ایک خرابی پیش آگئی - + QFile error: The file could not be accessed QFile کی خرابی: فائل تک رسائی نہیں ہو سکی - + QFile error: An unspecified error occurred کیو فائل میں خرابی: ایک غیر متعینہ خرابی پیش آگئی - + QFile error: A fatal error occurred کیو فائل میں خرابی: ایک مہلک خرابی پیش آگئی - + QFile error: The operation was aborted کیو فائل کی خرابی: آپریشن روک دیا گیا تھا - + Internal error داخلی خامی diff --git a/client/translations/amneziavpn_zh_CN.ts b/client/translations/amneziavpn_zh_CN.ts index fe4c5cf8..33cd7958 100644 --- a/client/translations/amneziavpn_zh_CN.ts +++ b/client/translations/amneziavpn_zh_CN.ts @@ -4,52 +4,52 @@ ApiServicesModel - + Classic VPN for comfortable work, downloading large files and watching videos. Works for any sites. Speed up to %1 MBit/s - + VPN to access blocked sites in regions with high levels of Internet censorship. - + <p><a style="color: #EB5757;">Not available in your region. If you have VPN enabled, disable it, return to the previous screen, and try again.</a> - + Amnezia Premium - A classic VPN for comfortable work, downloading large files, and watching videos in high resolution. It works for all websites, even in countries with the highest level of internet censorship. - + Amnezia Free is a free VPN to bypass blocking in countries with high levels of internet censorship - + %1 MBit/s - + %1 days - + VPN will open only popular sites blocked in your region, such as Instagram, Facebook, Twitter and others. Other sites will be opened from your real IP address, <a href="%1/free" style="color: #FBB26A;">more details on the website.</a> - + Free - + %1 $/month @@ -500,6 +500,21 @@ Already installed containers were found on the server. All installed containers Unable change server while there is an active connection 已建立连接时无法更改服务器配置 + + + Online + + + + + Offline + + + + + Connection to + + PageProtocolAwgClientSettings @@ -1484,6 +1499,14 @@ And if you don't like the app, all the more support it - the donation will 隐私政策 + + PageSettingsApiLanguageList + + + Unable change server location while there is an active connection + + + PageSettingsApiServerInfo @@ -1497,64 +1520,69 @@ And if you don't like the app, all the more support it - the donation will - + Work period - + + Valid until + + + + Speed - + Support tag - + Copied - + Reload API config - + Reload API config? - - + + Continue 继续 - - + + Cancel 取消 - + Cannot reload API config during active connection - + Remove from application - + Remove from application? - + Cannot remove server during active connection @@ -2212,27 +2240,32 @@ And if you don't like the app, all the more support it - the donation will PageSettingsServerInfo - + + Subscription is valid until + + + + Server name 服务器名 - + Save 保存 - + Protocols 协议 - + Services 服务 - + Management 管理 @@ -2240,6 +2273,21 @@ And if you don't like the app, all the more support it - the donation will Data 数据 + + + Server settings + + + + + Name + + + + + Remove server + + PageSettingsServerProtocol @@ -2340,6 +2388,11 @@ And if you don't like the app, all the more support it - the donation will Servers 服务器 + + + Connect to + + PageSettingsSplitTunneling @@ -2646,6 +2699,31 @@ It's okay as long as it's from someone you trust. Key as text 授权码文本 + + + Adding a server to connect to + + + + + Key + 授权码 + + + + VPN:// + + + + + Add + + + + + Unsupported config file + + PageSetupWizardCredentials @@ -3748,37 +3826,42 @@ and will not be shared or disclosed to the Amnezia or any third parties - - QFile error: The file could not be opened + + Missing list of available services - QFile error: An error occurred when reading from the file + QFile error: The file could not be opened - QFile error: The file could not be accessed + QFile error: An error occurred when reading from the file - QFile error: An unspecified error occurred + QFile error: The file could not be accessed - QFile error: A fatal error occurred + QFile error: An unspecified error occurred + QFile error: A fatal error occurred + + + + QFile error: The operation was aborted - + ErrorCode: %1. 错误代码: %1. @@ -3846,7 +3929,7 @@ and will not be shared or disclosed to the Amnezia or any third parties 该配置不包含任何用于连接到服务器的容器和凭据。 - + Internal error 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/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/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/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/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/models/servers_model.cpp b/client/ui/models/servers_model.cpp index bfe2b89a..ba2a10ec 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/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..5670a7db --- /dev/null +++ b/client/ui/qml/DefaultVpn/Controls/InputType.qml @@ -0,0 +1,56 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects + +import Config 1.0 + +import "TextTypes" + +TextField { + 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 + + + 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 + + verticalAlignment: Text.AlignTop + +} 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) + } +} 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") ? 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" 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 @@ + + + + + + + +