Compare commits

...

61 Commits

Author SHA1 Message Date
aiamnezia
24f545d001 Merge branch 'dev' into feature/local-proxy-integration 2026-04-21 16:14:49 +04:00
vkamn
650c1c6ebb chore: bump version (#2502) 2026-04-20 20:32:59 +08:00
vkamn
8dbded1624 chore: remove ip from tunnel name for ios (#2489) 2026-04-17 15:02:54 +08:00
vkamn
cebfcc846e feat: add renewal for external-premium (#2485)
* feat: add renewal for external-premium

* chore: bump version

* chore: send subscription status for renewal link request
2026-04-17 15:01:24 +08:00
vkamn
4c18ceaa50 chore: minor fixes (#2477) 2026-04-14 16:27:46 +08:00
NickVs2015
ebe3a5dac6 fix: add linux reconnection (#2415)
* fix: add linux reconnection

* fix: Dbus error, fix race conditional

* fix: improve reeconnection

* fix: add dns load/unload

* feat:  catch  state changed via  check gateway

* revert: restore linuxfirewall.cpp

* fix: restore reconnect time

* fix: add   NM_STATE_DISABLED and  check getGatewayAndIface more carefully

* fix: reconnect

* fix: revert wireguardutilslinux

* fix: revert
2026-04-14 11:10:41 +08:00
yp
92deee5f67 fix: tun2socks auth settings (#2456)
* add parser auth/pass & fix port

* fix generateRandomHex

* remove hardcore port ios

* add generated random port

* fix sin6_port

* fixed inbound

* add error message

* add std::runtime_error & fixed random generator

* remove loop

---------

Co-authored-by: Yaumenau Pavel <yaumenau.pavel@planetvpn.dev>
2026-04-13 20:06:08 +08:00
aiamnezia
2b0d86c626 fix: waiting starting xray before setting running flag 2026-04-13 11:04:19 +04:00
aiamnezia
fe773a108e fix: waiting VPN disconnect before proxy turning on 2026-04-13 10:19:01 +04:00
aiamnezia
a0997156f6 fix: restoring server_uuid within config update 2026-04-13 09:40:23 +04:00
aiamnezia
9a6e975622 fix: stop xray before closing the app 2026-04-13 09:24:55 +04:00
lunardunno
a75bd0cf5e fix: set a fixed 3proxy ver 0.9.5 (#2468) 2026-04-13 12:27:45 +08:00
aiamnezia
78f09634a4 fix: prevent stopping Xray due to changing local proxy port
- Added a check in XrayController::stop() to skip the stop operation if Xray is not currently running, with a debug log for clarity.
2026-04-13 07:31:26 +04:00
aiamnezia
be692001b0 fix: fix local proxy settings and restart logic
- Added local proxy restart token management in Settings.
- Implemented logic to handle local proxy state on application quit.
- Updated ProxyServer to restart based on configuration changes.
- Enhanced API configuration updates to bump restart token when necessary.
- Improved UI components to reflect local proxy availability and state.
- Added new error handling and notifications for local proxy operations.
2026-04-13 07:14:42 +04:00
aiamnezia
850b8ea03b Merge branch 'dev' into feature/local-proxy-integration 2026-04-13 03:07:45 +04:00
vkamn
46f5b3894b chore: minor fixes (#2459)
* fix: fixed links on page with service description

* fix: fixed subscription text color

* chore: update ru translations

* chore: add save button

* fix: ru translation fixes
2026-04-10 22:24:00 +08:00
Mitternacht822
493ee22883 chore: block vless toggle while active connection (#2318)
* fix: prevent disabled SwitcherType from toggling via keyboard

* fix: disabled vless option toggle while connection is active
2026-04-08 12:45:51 +08:00
yyy-amnezia
ad14847eb5 fix: ios ovpn fix (#2360)
* feat: enhance OpenVPN support and configuration handling for iOS and macOS platforms

* Deps updated

* Deps updated

* feat: add OpenVPN configuration validation and regeneration logic to VpnConfigurationsController

* revert: restore pre-fix OpenVPN NE flow

* chore: add OpenVPN NE payload diagnostics

* Revert "revert: restore pre-fix OpenVPN NE flow"

This reverts commit ae99cc77e9.

* chore: remove openvpn config processing

---------

Co-authored-by: vkamn <vk@amnezia.org>
2026-04-08 12:37:52 +08:00
lunardunno
cd50e0b8a5 fix: full server cleanup (#2446)
* Fix: full server cleanup

* Cleaning by REPOSITORY:TAG
2026-04-08 12:27:06 +08:00
vkamn
78f504e35c feat: new services description (#2412)
* feat: iap for apple now use storekit2

* fix: fixed error 101 on connection event

* feat: enhance StoreKit2Helper to handle entitlements and improve restore service from App Store functionality

* chore: add isInAppPurchase and isTestPurchase in primary config

* refactor: use end_date from primary config for renew ui

* fix: hide renew button for free

* fix: hide renew button for appstore purchases

* feat: add new premium info page

* feat: add new free info page

* chore: minor fixes

* refactor: move plan and benefits into separate models

* fix: fixed expired status when configs without an end date

* feat: add trial api support

* chore: add api message parsing for 422 error

* feat: move privacy policy and term of use to gateway

* feat: add iap support for new premium info page

* chore: minor fixes

* chore: minor fix

* chore: minor fixes

* feat: additional parsing for storekit subscription plans

* chore: minor codestyle fixes

* chore: simplify benefits

* chore: hide extend buttons on external premium

* feat: add trial error processing

* fix: remove wrong check from tiral handler

* chore: cleanup

---------

Co-authored-by: spectrum <yyy@amnezia.org>
2026-04-08 12:21:12 +08:00
NickVs2015
bf3d11e5c4 feat: renewal new status logic (#2409)
* fix: renewal add status logic

* fix: wakeup activity resumed android
2026-03-25 19:48:32 +08:00
NickVs2015
9a0222aee3 fix: ui fixes for renewal subscription (#2406) 2026-03-25 12:34:42 +08:00
NickVs2015
f0f0f7c5be feat: add subscription renewal (#2389)
* feat: add renewal subsribe

* fix: after review
2026-03-24 22:45:02 +08:00
NickVs2015
36b1a863bf fix: black screen resume / pause (#2400) 2026-03-24 22:13:31 +08:00
yyy-amnezia
4103c5bbcf refactor: extract and simplify OpenVPN reachability and network change handling logic (#2402) 2026-03-24 22:12:59 +08:00
aiamnezia
c645d07a87 chore: fix build 2026-03-24 16:39:59 +04:00
vkamn
fa69da6d56 chore: send app version in services request (#2403) 2026-03-24 20:25:04 +08:00
aiamnezia
7f8786720e Merge branch 'dev' into feature/local-proxy-integration 2026-03-24 15:48:49 +04:00
yyy-amnezia
aaf2c9ddeb feat: add Xray split tunnel support for iOS PacketTunnelProvider (#2332) 2026-03-24 16:07:36 +08:00
Mitternacht822
dbbc7119ec feat: add warning info for ssh keys (#2252)
* fix: fixed da typo

* feat: added warning about available ssh keys info
2026-03-24 16:06:40 +08:00
vkamn
c57162c4cc feat: add base amnezia trial support (#2366)
* feat: add base amnezia trial support

* feat: add external-trial
2026-03-24 10:29:51 +08:00
NickVs2015
40e39895c9 fix openfile deadlock (#2373) 2026-03-21 11:46:46 +08:00
vkamn
ec3ab2a03c chore: update licnese file (#2376) 2026-03-20 21:04:13 +08:00
yyy-amnezia
ddecfcad26 fix: apple platform network switch fix (#2359)
* Apple platform network switch fix

* macos_ne exclusion fixed
2026-03-20 20:51:36 +08:00
NickVs2015
67bd880cdf fix: swap buffers error (#2347) 2026-03-16 13:03:20 +08:00
aiamnezia
25c70b5bf6 fix: fix android build 2026-02-13 17:21:15 +04:00
aiamnezia
7bf16f075c feat: add mutual exclusion between local proxy and vpn 2026-02-13 17:07:35 +04:00
aiamnezia
6518d4866e fix: Fix local proxy UI 2026-02-13 16:46:49 +04:00
aiamnezia
4c2010244b feat: Update local proxy settings page 2026-01-27 15:09:02 +04:00
aiamnezia
e946ee2430 feat: enhance ConfigManager with dynamic proxy port resolution and availability check
- Added functionality to resolve and validate the local proxy port within a specified range.
- Implemented a method to check if a port is available before applying it to the configuration.
- Updated ProxyService to handle the new port resolution logic and cache the parsed configuration.
2026-01-27 12:45:38 +04:00
aiamnezia
5fab8363e7 feat: made API port static and proxy port configurable 2026-01-27 11:09:27 +04:00
aiamnezia
35c2e1564b chore: add debug 2026-01-23 15:33:25 +04:00
aiamnezia
ca5bca085b feat: remove logs for local proxy 2026-01-19 16:25:59 +04:00
aiamnezia
33b2a3d2fc Merge branch 'dev' into feature/local-proxy-integration 2026-01-16 17:48:48 +04:00
aiamnezia
917f5858a8 fix: update ProxyService to use new configuration fetching method 2026-01-15 23:35:39 +04:00
aiamnezia
e98c11079a feat: enhance ConfigManager with fetch capabilities for Xray configuration 2026-01-15 23:05:36 +04:00
aiamnezia
84d908d4d8 feat: enhance local proxy error handling and configuration management
- Added signal for local proxy start failure to Settings.
- Updated CoreController to emit failure signals with descriptive messages when local proxy fails to start.
- Refactored XrayController to accept JSON configuration directly, improving configuration handling.
- Removed unused config file loading logic to streamline the XrayController's functionality.
2026-01-15 21:04:10 +04:00
aiamnezia
412e69af9b feat: update ConfigManager and ProxyServer to utilize Settings
- Modified ConfigManager to accept a Settings object for improved configuration management.
- Updated ProxyServer to initialize with Settings, enhancing dependency injection.
2025-12-31 21:16:45 +04:00
aiamnezia
4492b0af7e fix: fix local proxy page 2025-12-31 20:15:40 +04:00
aiamnezia
806b1d75af refactor: streamline HTTP API for Xray control
- Removed outdated config management routes and consolidated Xray control endpoints.
- Updated response structures to ensure consistent error handling across API calls.
2025-12-31 19:30:29 +04:00
aiamnezia
9740e7557a feat: add dynamic local proxy lifecycle in depence of settings 2025-12-31 18:48:57 +04:00
aiamnezia
5ab82e2196 chore: fix android build 2025-12-30 16:59:15 +04:00
aiamnezia
ae44de7101 chore: fix win build 2025-12-30 16:05:05 +04:00
aiamnezia
2be6079e21 refactor: enhance XrayController to use IPC for process management
- Replaced direct process management with IPC calls for starting and stopping the Xray process.
- Improved error handling for IPC communication and config file loading.
- Removed unused methods and variables related to direct process handling.
- Updated logging to reflect changes in process management.
2025-12-30 15:33:59 +04:00
aiamnezia
2a653f8876 feat: implement local proxy settings UI and functionality
- Added new QML pages for managing local proxy settings and connection types.
- Updated SettingsController to handle local proxy enablement and port configuration.
- Enhanced server model to include processed server UUID for local proxy management.
2025-12-30 14:19:32 +04:00
aiamnezia
41ab51a5ef feat: add server UUID management and local proxy settings
- Implemented UUID migration for servers to ensure each server has a unique identifier.
- Added methods for managing local proxy settings, including owner UUID, port, and HTTP enablement.
- Updated server model to include server UUID role for better data handling.
2025-12-30 11:02:59 +04:00
aiamnezia
300558c33c Merge branch 'dev' into feature/local-proxy-integration 2025-12-19 14:18:26 +04:00
aiamnezia
774deb87f5 fix build scripts 2025-08-08 09:44:50 +04:00
aiamnezia
bae7fbd222 Add qtwebsockets in deploy.yml 2025-08-08 09:14:35 +04:00
aiamnezia
97e4b95673 Add qthttpserver deploy.yml 2025-08-08 09:07:28 +04:00
aiamnezia
2ae97c5cda feat: Added local proxy server 2025-08-08 06:44:18 +04:00
114 changed files with 7378 additions and 1348 deletions

View File

@@ -31,7 +31,7 @@ jobs:
host: 'linux'
target: 'desktop'
arch: 'linux_gcc_64'
modules: 'qtremoteobjects qt5compat qtshadertools'
modules: 'qtremoteobjects qt5compat qtshadertools qthttpserver qtwebsockets'
dir: ${{ runner.temp }}
setup-python: 'true'
tools: 'tools_ifw'
@@ -129,7 +129,7 @@ jobs:
host: 'windows'
target: 'desktop'
arch: 'win64_msvc2022_64'
modules: 'qtremoteobjects qt5compat qtshadertools'
modules: 'qtremoteobjects qt5compat qtshadertools qthttpserver qtwebsockets'
dir: ${{ runner.temp }}
setup-python: 'true'
tools: 'tools_ifw'
@@ -222,7 +222,7 @@ jobs:
version: ${{ env.QT_VERSION }}
host: 'mac'
target: 'desktop'
modules: 'qtremoteobjects qt5compat qtshadertools qtmultimedia'
modules: 'qtremoteobjects qt5compat qtshadertools qtmultimedia qthttpserver qtwebsockets'
arch: 'clang_64'
dir: ${{ runner.temp }}
set-env: 'true'
@@ -234,7 +234,7 @@ jobs:
version: ${{ env.QT_VERSION }}
host: 'mac'
target: 'ios'
modules: 'qtremoteobjects qt5compat qtshadertools qtmultimedia'
modules: 'qtremoteobjects qt5compat qtshadertools qtmultimedia qthttpserver qtwebsockets'
dir: ${{ runner.temp }}
setup-python: 'true'
set-env: 'true'
@@ -337,7 +337,7 @@ jobs:
host: 'mac'
target: 'desktop'
arch: 'clang_64'
modules: 'qtremoteobjects qt5compat qtshadertools'
modules: 'qtremoteobjects qt5compat qtshadertools qthttpserver qtwebsockets'
dir: ${{ runner.temp }}
setup-python: 'true'
set-env: 'true'
@@ -414,7 +414,7 @@ jobs:
host: 'mac'
target: 'desktop'
arch: 'clang_64'
modules: 'qtremoteobjects qt5compat qtshadertools'
modules: 'qtremoteobjects qt5compat qtshadertools qthttpserver qtwebsockets'
dir: ${{ runner.temp }}
setup-python: 'true'
set-env: 'true'
@@ -542,7 +542,7 @@ jobs:
env:
ANDROID_BUILD_PLATFORM: android-36
QT_VERSION: 6.10.1
QT_MODULES: 'qtremoteobjects qt5compat qtimageformats qtshadertools'
QT_MODULES: 'qtremoteobjects qt5compat qtimageformats qtshadertools qthttpserver qtwebsockets'
PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }}
PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }}
DEV_AGW_PUBLIC_KEY: ${{ secrets.DEV_AGW_PUBLIC_KEY }}

3
.gitignore vendored
View File

@@ -10,7 +10,8 @@ deploy/build_64/*
winbuild*.bat
.cache/
.vscode/
.cursorignore
.cursor/
# Qt-es
/.qmake.cache

View File

@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.25.0 FATAL_ERROR)
set(PROJECT AmneziaVPN)
set(AMNEZIAVPN_VERSION 4.8.14.5)
set(AMNEZIAVPN_VERSION 4.8.15.2)
project(${PROJECT} VERSION ${AMNEZIAVPN_VERSION}
DESCRIPTION "AmneziaVPN"
@@ -12,7 +12,7 @@ string(TIMESTAMP CURRENT_DATE "%Y-%m-%d")
set(RELEASE_DATE "${CURRENT_DATE}")
set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH})
set(APP_ANDROID_VERSION_CODE 2117)
set(APP_ANDROID_VERSION_CODE 2119)
if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
set(MZ_PLATFORM_NAME "linux")

View File

@@ -179,7 +179,7 @@ You may face compiling issues in QT Creator after you've worked in Android Studi
## License
GPL v3.0
This project is licensed under the GNU General Public License v3.0 (see LICENSE) and also includes third-party components distributed under their own terms (see THIRD_PARTY_LICENSES.md).
## Donate

149
THIRD_PARTY_LICENSES.md Normal file
View File

@@ -0,0 +1,149 @@
# Third-Party Licenses
This project is licensed under the GNU General Public License v3.0.
This file lists third-party software components used by this repository.
Each component is distributed under its own license as linked below.
---
## QtKeychain
- Source: https://github.com/frankosterfeld/qtkeychain
- License: BSD License
- License Text: https://www.gnu.org/licenses/license-list.html#ModifiedBSD
---
## QSimpleCrypto
- Source: https://github.com/n1flh31mur/QSimpleCrypto
- License: Apache License 2.0
- License Text: https://github.com/n1flh31mur/QSimpleCrypto/blob/master/LICENSE
---
## SortFilterProxyModel
- Source: https://github.com/oKcerG/SortFilterProxyModel
- License: MIT License
- License Text: https://github.com/oKcerG/SortFilterProxyModel/blob/master/LICENSE
---
## QJsonStruct
- Source: https://github.com/Qv2ray/QJsonStruct
- License: MIT License
- License Text: https://github.com/Qv2ray/QJsonStruct/blob/master/LICENSE
---
## QR Code Generator (qrcodegen)
- Source: https://github.com/nayuki/QR-Code-generator
- License: MIT License
- License Text: https://www.nayuki.io/page/qr-code-generator-library
---
## Qt Gamepad
- Source: https://github.com/qt/qtgamepad
- License: GNU General Public License v3.0 (GPL-3.0)
- License Text: https://www.gnu.org/licenses/gpl-3.0.en.html
---
## AmneziaWG Apple (WireGuard)
- Source: https://github.com/amnezia-vpn/amneziawg-apple
- License: MIT License
- License Text: https://github.com/amnezia-vpn/amneziawg-apple/blob/master/COPYING
---
## AmneziaWG Android
- Source: https://github.com/amnezia-vpn/amneziawg-go
- License: MIT License
- License Text: https://github.com/amnezia-vpn/amneziawg-go/blob/master/LICENSE
---
## Xray Core
- Source: https://github.com/XTLS/Xray-core
- License: Mozilla Public License 2.0 (MPL-2.0)
- License Text: https://github.com/XTLS/Xray-core/blob/main/LICENSE
---
## Cloak
- Source: https://github.com/cbeuw/Cloak
- License: GNU General Public License v3.0 (GPL-3.0)
- License Text: https://github.com/cbeuw/Cloak/blob/master/LICENSE
---
## Shadowsocks
- Source: https://github.com/shadowsocks/shadowsocks-libev
- License: GPL-3.0-or-later
- License Text: http://www.gnu.org/licenses/
---
## OpenSSL
- Source: https://github.com/openssl/openssl
- License: Apache License 2.0
- License Text: https://www.openssl.org/source/license.html
---
## libssh
- Source: https://www.libssh.org/
- License: GNU Lesser General Public License (LGPL)
- License Text: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
---
## OpenVPNAdapter
- Source: https://github.com/ss-abramchuk/OpenVPNAdapter
- License: GNU Affero General Public License v3.0 (AGPL-3.0)
- License Text: https://github.com/ss-abramchuk/OpenVPNAdapter/blob/master/LICENSE
---
## Wintun
- Source: https://www.wintun.net/
- License: Prebuilt Binaries License
- License Text: https://github.com/WireGuard/wintun/blob/master/prebuilt-binaries-license.txt
---
## Mullvad Split Tunnel Driver
- Source: https://github.com/mullvad/win-split-tunnel
- License: GNU General Public License v3.0 (GPL-3.0) and Mozilla Public License Version 2.0
- License Text: https://github.com/mullvad/win-split-tunnel/blob/master/LICENSE-GPL.md https://github.com/mullvad/win-split-tunnel/blob/master/LICENSE-MPL.txt
---
## tun2socks
- Source: https://github.com/eycorsican/go-tun2socks
- License: MIT License
- License Text: https://github.com/eycorsican/go-tun2socks/blob/master/LICENSE
---
## TAP-Windows Driver
- Source: https://github.com/OpenVPN/tap-windows6
- License: tap-windows6 license
- License Text: https://github.com/OpenVPN/tap-windows6/blob/master/COPYING

View File

@@ -14,6 +14,10 @@ set(PACKAGES
Core5Compat Concurrent LinguistTools
)
if(NOT ANDROID AND NOT IOS)
list(APPEND PACKAGES HttpServer)
endif()
execute_process(
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
COMMAND git rev-parse --short HEAD
@@ -46,6 +50,10 @@ set(LIBS ${LIBS}
Qt6::Core5Compat Qt6::Concurrent
)
if(NOT ANDROID AND NOT IOS)
list(APPEND LIBS Qt6::HttpServer)
endif()
if(WIN32 OR (APPLE AND NOT IOS) OR (LINUX AND NOT ANDROID))
set(LIBS ${LIBS} Qt6::Widgets)
endif()

View File

@@ -109,6 +109,16 @@ void AmneziaApplication::init()
// install filter on main window
if (auto win = qobject_cast<QQuickWindow*>(obj)) {
win->installEventFilter(this);
#ifdef Q_OS_ANDROID
QObject::connect(win, &QQuickWindow::sceneGraphError,
[](QQuickWindow::SceneGraphError, const QString &msg) {
qWarning() << "Scene graph error (suppressed):" << msg;
});
// Keep graphics context alive across hide/show cycles to avoid
// eglSwapBuffers/makeCurrent being called on a context Android has reclaimed.
win->setPersistentSceneGraph(true);
win->setPersistentGraphics(true);
#endif
win->show();
}
},

View File

@@ -296,9 +296,25 @@ class AmneziaActivity : QtActivity() {
hasWindowFocus = hasFocus
Log.d(TAG, "Window focus changed: hasFocus=$hasFocus")
// Cancel pending operations if window loses focus
if (!hasFocus) {
// Cancel pending operations if window loses focus
resumeHandler.removeCallbacksAndMessages(null)
} else if (isActivityResumed && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
window.decorView.apply {
invalidate()
resumeHandler.postDelayed({
if (isActivityResumed && hasWindowFocus && !isFinishing && !isDestroyed) {
sendTouch(1f, 1f)
}
}, 50)
resumeHandler.postDelayed({
if (isActivityResumed && hasWindowFocus && !isFinishing && !isDestroyed) {
sendTouch(2f, 2f)
requestLayout()
invalidate()
}
}, 150)
}
}
}
@@ -337,6 +353,13 @@ class AmneziaActivity : QtActivity() {
private external fun nativeGamepadKeyEvent(deviceId: Int, keyCode: Int, pressed: Boolean)
override fun onPause() {
// Notify Qt to stop rendering BEFORE super.onPause() destroys the EGL surface.
// Using a coroutine here would be too late — the surface is gone by the time
// the coroutine runs. A direct synchronous call gives Qt's render thread the
// best chance to process visible=false before surface destruction.
if (qtInitialized.isCompleted) {
QtAndroidController.onActivityPaused()
}
super.onPause()
isActivityResumed = false
// Cancel all pending operations when activity pauses
@@ -349,6 +372,9 @@ class AmneziaActivity : QtActivity() {
super.onResume()
isActivityResumed = true
Log.d(TAG, "Resume Amnezia activity")
if (qtInitialized.isCompleted) {
QtAndroidController.onActivityResumed()
}
if (pendingOpenFileUri != null && !openFileDeliveryScheduled) {
val uri = pendingOpenFileUri!!
@@ -816,7 +842,7 @@ class AmneziaActivity : QtActivity() {
@Suppress("unused")
fun getFd(fileName: String): Int {
Log.v(TAG, "Get fd for $fileName")
return blockingCall {
return blockingCall(Dispatchers.IO) {
try {
pfd = contentResolver.openFileDescriptor(Uri.parse(fileName), "r")
pfd?.fd ?: -1

View File

@@ -31,4 +31,7 @@ object QtAndroidController {
external fun onImeInsetsChanged(heightDp: Int)
external fun onSystemBarsInsetsChanged(navBarHeightDp: Int, statusBarHeightDp: Int)
external fun onActivityPaused()
external fun onActivityResumed()
}

View File

@@ -4,6 +4,9 @@ import android.content.Context
import android.net.VpnService.Builder
import java.io.File
import java.io.IOException
import java.net.InetAddress
import java.net.ServerSocket
import java.util.UUID
import go.Seq
import org.amnezia.vpn.protocol.BadConfigException
import org.amnezia.vpn.protocol.Protocol
@@ -19,11 +22,32 @@ import org.amnezia.vpn.util.Log
import org.amnezia.vpn.util.net.InetNetwork
import org.amnezia.vpn.util.net.ip
import org.amnezia.vpn.util.net.parseInetAddress
import org.json.JSONArray
import org.json.JSONObject
private const val TAG = "Xray"
private const val LIBXRAY_TAG = "libXray"
private fun findSocksInboundIndex(inbounds: JSONArray): Int {
for (i in 0 until inbounds.length()) {
val o = inbounds.optJSONObject(i) ?: continue
if (o.optString("protocol").equals("socks", ignoreCase = true)) {
return i
}
}
return -1
}
private fun acquireFreeLocalPort(): Int {
try {
ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).use { return it.localPort }
} catch (e: Exception) {
throw VpnStartException(
"Failed to acquire free TCP port on 127.0.0.1 for SOCKS inbound: ${e.message}"
)
}
}
class Xray : Protocol() {
private var isRunning: Boolean = false
@@ -56,6 +80,10 @@ class Xray : Protocol() {
val xrayJsonConfig = config.optJSONObject("xray_config_data")
?: config.optJSONObject("ssxray_config_data")
?: throw BadConfigException("config_data not found")
// Inject SOCKS5 auth before starting xray. Re-uses existing credentials if present.
ensureInboundAuth(xrayJsonConfig)
val xrayConfig = parseConfig(config, xrayJsonConfig)
(xrayJsonConfig.optJSONObject("log") ?: JSONObject().also { xrayJsonConfig.put("log", it) })
@@ -97,9 +125,22 @@ class Xray : Protocol() {
if (it.isNotBlank()) setMtu(it.toInt())
}
val socksConfig = xrayJsonConfig.getJSONArray("inbounds")[0] as JSONObject
val inbounds = xrayJsonConfig.getJSONArray("inbounds")
val socksIdx = findSocksInboundIndex(inbounds)
if (socksIdx < 0) {
throw BadConfigException("socks inbound not found")
}
val socksConfig = inbounds.getJSONObject(socksIdx)
socksConfig.getInt("port").let { setSocksPort(it) }
val socksSettings = socksConfig.optJSONObject("settings")
val accounts = socksSettings?.optJSONArray("accounts")
if (accounts != null && accounts.length() > 0) {
val account = accounts.getJSONObject(0)
setSocksUser(account.optString("user"))
setSocksPass(account.optString("pass"))
}
configSplitTunneling(config)
configAppSplitTunneling(config)
}
@@ -162,9 +203,10 @@ class Xray : Protocol() {
}
private fun runTun2Socks(config: XrayConfig, fd: Int) {
val proxyUrl = "socks5://${config.socksUser}:${config.socksPass}@127.0.0.1:${config.socksPort}"
val tun2SocksConfig = Tun2SocksConfig().apply {
mtu = config.mtu.toLong()
proxy = "socks5://127.0.0.1:${config.socksPort}"
proxy = proxyUrl
device = "fd://$fd"
logLevel = "warn"
}
@@ -173,6 +215,37 @@ class Xray : Protocol() {
}
}
// Ensures SOCKS5 auth is present on the socks inbound settings.
// Re-uses existing credentials if already configured; otherwise generates random ones.
private fun ensureInboundAuth(xrayConfig: JSONObject) {
val inbounds = xrayConfig.optJSONArray("inbounds") ?: return
val socksIdx = findSocksInboundIndex(inbounds)
if (socksIdx < 0) return
val inbound = inbounds.getJSONObject(socksIdx)
inbound.put("port", acquireFreeLocalPort())
val settings = inbound.optJSONObject("settings") ?: JSONObject().also { inbound.put("settings", it) }
val accounts = settings.optJSONArray("accounts")
if (accounts != null && accounts.length() > 0) {
val account = accounts.getJSONObject(0)
if (account.optString("user").isNotEmpty() && account.optString("pass").isNotEmpty()) {
// Ensure auth mode is enforced even for imported configs that had accounts
// but auth: "noauth" (or no auth field).
settings.put("auth", "password")
inbound.put("settings", settings)
inbounds.put(socksIdx, inbound)
return
}
}
val user = UUID.randomUUID().toString().replace("-", "").substring(0, 16)
val pass = UUID.randomUUID().toString().replace("-", "")
settings.put("auth", "password")
settings.put("accounts", JSONArray().put(JSONObject().put("user", user).put("pass", pass)))
inbound.put("settings", settings)
inbounds.put(socksIdx, inbound)
}
companion object {
val instance: Xray by lazy { Xray() }
}

View File

@@ -9,12 +9,16 @@ private const val XRAY_DEFAULT_MAX_MEMORY: Long = 50 shl 20 // 50 MB
class XrayConfig protected constructor(
protocolConfigBuilder: ProtocolConfig.Builder,
val socksPort: Int,
val socksUser: String,
val socksPass: String,
val maxMemory: Long,
) : ProtocolConfig(protocolConfigBuilder) {
protected constructor(builder: Builder) : this(
builder,
builder.socksPort,
builder.socksUser,
builder.socksPass,
builder.maxMemory
)
@@ -22,6 +26,12 @@ class XrayConfig protected constructor(
internal var socksPort: Int = 0
private set
internal var socksUser: String = ""
private set
internal var socksPass: String = ""
private set
internal var maxMemory: Long = XRAY_DEFAULT_MAX_MEMORY
private set
@@ -29,6 +39,10 @@ class XrayConfig protected constructor(
fun setSocksPort(port: Int) = apply { socksPort = port }
fun setSocksUser(user: String) = apply { socksUser = user }
fun setSocksPass(pass: String) = apply { socksPass = pass }
fun setMaxMemory(maxMemory: Long) = apply { this.maxMemory = maxMemory }
override fun build(): XrayConfig = configBuild().run { XrayConfig(this@Builder) }

View File

@@ -121,6 +121,7 @@ target_sources(${PROJECT} PRIVATE
${CLIENT_ROOT_DIR}/platforms/ios/LogRecord.swift
${CLIENT_ROOT_DIR}/platforms/ios/ScreenProtection.swift
${CLIENT_ROOT_DIR}/platforms/ios/VPNCController.swift
${CLIENT_ROOT_DIR}/platforms/ios/StoreKit2Helper.swift
)
target_sources(${PROJECT} PRIVATE

View File

@@ -131,6 +131,7 @@ target_sources(${PROJECT} PRIVATE
${CLIENT_ROOT_DIR}/platforms/ios/LogRecord.swift
${CLIENT_ROOT_DIR}/platforms/ios/ScreenProtection.swift
${CLIENT_ROOT_DIR}/platforms/ios/VPNCController.swift
${CLIENT_ROOT_DIR}/platforms/ios/StoreKit2Helper.swift
)
target_sources(${PROJECT} PRIVATE

View File

@@ -124,6 +124,11 @@ file(GLOB_RECURSE PAGE_LOGIC_CPP CONFIGURE_DEPENDS ${CLIENT_ROOT_DIR}/ui/pages_l
file(GLOB CONFIGURATORS_H CONFIGURE_DEPENDS ${CLIENT_ROOT_DIR}/configurators/*.h)
file(GLOB CONFIGURATORS_CPP CONFIGURE_DEPENDS ${CLIENT_ROOT_DIR}/configurators/*.cpp)
if(NOT ANDROID AND NOT IOS)
file(GLOB LOCAL_PROXY_H CONFIGURE_DEPENDS ${CLIENT_ROOT_DIR}/core/local-proxy/*.h)
file(GLOB LOCAL_PROXY_CPP CONFIGURE_DEPENDS ${CLIENT_ROOT_DIR}/core/local-proxy/*.cpp)
endif()
file(GLOB UI_MODELS_H CONFIGURE_DEPENDS
${CLIENT_ROOT_DIR}/ui/models/*.h
${CLIENT_ROOT_DIR}/ui/models/protocols/*.h
@@ -161,6 +166,11 @@ set(SOURCES ${SOURCES}
${UI_CONTROLLERS_CPP}
)
if(NOT ANDROID AND NOT IOS)
list(APPEND HEADERS ${LOCAL_PROXY_H})
list(APPEND SOURCES ${LOCAL_PROXY_CPP})
endif()
if(WIN32)
set(HEADERS ${HEADERS}
${CLIENT_ROOT_DIR}/protocols/ikev2_vpn_protocol_windows.h

View File

@@ -298,14 +298,14 @@ bool ContainerProps::isSupportedByCurrentPlatform(DockerContainer c)
}
#elif defined(MACOS_NE)
// macOS build using Network Extension hide OpenVPN-based containers
// macOS build using Network Extension allow OpenVPN for parity with iOS.
switch (c) {
case DockerContainer::OpenVpn: return true;
case DockerContainer::WireGuard: return true;
case DockerContainer::Awg2: return true;
case DockerContainer::Awg: return true;
case DockerContainer::Xray: return true;
case DockerContainer::SSXray: return true;
case DockerContainer::OpenVpn:
case DockerContainer::Cloak:
case DockerContainer::ShadowSocks:
return false;

View File

@@ -11,7 +11,8 @@ namespace apiDefs
AmneziaPremiumV1,
AmneziaPremiumV2,
SelfHosted,
ExternalPremium
ExternalPremium,
ExternalTrial
};
enum ConfigSource {
@@ -32,6 +33,7 @@ namespace apiDefs
constexpr QLatin1String stackType("stack_type");
constexpr QLatin1String serviceType("service_type");
constexpr QLatin1String cliVersion("cli_version");
constexpr QLatin1String cliName("cli_name");
constexpr QLatin1String supportedProtocols("supported_protocols");
constexpr QLatin1String vpnKey("vpn_key");
@@ -53,8 +55,14 @@ namespace apiDefs
constexpr QLatin1String activeDeviceCount("active_device_count");
constexpr QLatin1String maxDeviceCount("max_device_count");
constexpr QLatin1String subscriptionEndDate("subscription_end_date");
constexpr QLatin1String subscriptionExpiredByServer("subscription_expired_by_server");
constexpr QLatin1String subscriptionStatus("subscription_status");
constexpr QLatin1String subscription("subscription");
constexpr QLatin1String endDate("end_date");
constexpr QLatin1String issuedConfigs("issued_configs");
constexpr QLatin1String subscriptionDescription("subscription_description");
constexpr QLatin1String termsOfUseUrl("terms_of_use_url");
constexpr QLatin1String privacyPolicyUrl("privacy_policy_url");
constexpr QLatin1String supportInfo("support_info");
constexpr QLatin1String email("email");
@@ -69,11 +77,13 @@ namespace apiDefs
constexpr QLatin1String transactionId("transaction_id");
constexpr QLatin1String isTestPurchase("is_test_purchase");
constexpr QLatin1String isInAppPurchase("is_in_app_purchase");
constexpr QLatin1String userCountryCode("user_country_code");
constexpr QLatin1String serviceInfo("service_info");
constexpr QLatin1String isAdVisible("is_ad_visible");
constexpr QLatin1String isRenewalAvailable("is_renewal_available");
constexpr QLatin1String adHeader("ad_header");
constexpr QLatin1String adDescription("ad_description");
constexpr QLatin1String adEndpoint("ad_endpoint");

View File

@@ -3,11 +3,33 @@
#include <QDateTime>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
namespace
{
const QByteArray AMNEZIA_CONFIG_SIGNATURE = QByteArray::fromHex("000000ff");
constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?");
constexpr QLatin1String trialAlreadyUsedMessage("trial subscription already used");
QDateTime subscriptionEndUtcFromString(const QString &subscriptionEndDate)
{
if (subscriptionEndDate.isEmpty()) {
return {};
}
QDateTime endDate = QDateTime::fromString(subscriptionEndDate, Qt::ISODateWithMs).toUTC();
if (!endDate.isValid()) {
endDate = QDateTime::fromString(subscriptionEndDate, Qt::ISODate).toUTC();
}
return endDate;
}
QString apiErrorMessageFromJson(const QJsonObject &jsonObj)
{
const QJsonValue value = jsonObj.value(QStringLiteral("message"));
return value.isString() ? value.toString().trimmed() : QString();
}
QString escapeUnicode(const QString &input)
{
QString output;
@@ -24,9 +46,30 @@ namespace
bool apiUtils::isSubscriptionExpired(const QString &subscriptionEndDate)
{
QDateTime now = QDateTime::currentDateTimeUtc();
QDateTime endDate = QDateTime::fromString(subscriptionEndDate, Qt::ISODateWithMs);
return endDate < now;
if (subscriptionEndDate.isEmpty()) {
return false;
}
const QDateTime endDate = subscriptionEndUtcFromString(subscriptionEndDate);
if (!endDate.isValid()) {
return false;
}
return endDate <= QDateTime::currentDateTimeUtc();
}
bool apiUtils::isSubscriptionExpiringSoon(const QString &subscriptionEndDate, int withinDays)
{
if (subscriptionEndDate.isEmpty()) {
return false;
}
const QDateTime endDate = subscriptionEndUtcFromString(subscriptionEndDate);
if (!endDate.isValid()) {
return false;
}
const QDateTime nowUtc = QDateTime::currentDateTimeUtc();
if (endDate <= nowUtc) {
return false;
}
return endDate <= nowUtc.addDays(withinDays);
}
bool apiUtils::isServerFromApi(const QJsonObject &serverConfigObject)
@@ -60,6 +103,7 @@ apiDefs::ConfigType apiUtils::getConfigType(const QJsonObject &serverConfigObjec
constexpr QLatin1String servicePremium("amnezia-premium");
constexpr QLatin1String serviceFree("amnezia-free");
constexpr QLatin1String serviceExternalPremium("external-premium");
constexpr QLatin1String serviceExternalTrial("external-trial");
auto apiConfigObject = serverConfigObject.value(apiDefs::key::apiConfig).toObject();
auto serviceType = apiConfigObject.value(apiDefs::key::serviceType).toString();
@@ -70,6 +114,8 @@ apiDefs::ConfigType apiUtils::getConfigType(const QJsonObject &serverConfigObjec
return apiDefs::ConfigType::AmneziaFreeV3;
} else if (serviceType == serviceExternalPremium) {
return apiDefs::ConfigType::ExternalPremium;
} else if (serviceType == serviceExternalTrial) {
return apiDefs::ConfigType::ExternalTrial;
}
}
default: {
@@ -90,50 +136,66 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList<QSslError> &ssl
const int httpStatusCodeConflict = 409;
const int httpStatusCodeNotFound = 404;
const int httpStatusCodeNotImplemented = 501;
const int httpStatusCodePaymentRequired = 402;
const int httpStatusCodeUnprocessableEntity = 422;
if (!sslErrors.empty()) {
qDebug().noquote() << sslErrors;
return amnezia::ErrorCode::ApiConfigSslError;
} else if (replyError == QNetworkReply::NoError) {
}
if (replyError == QNetworkReply::NoError) {
return amnezia::ErrorCode::NoError;
} else if (replyError == QNetworkReply::NetworkError::OperationCanceledError
|| replyError == QNetworkReply::NetworkError::TimeoutError) {
}
if (replyError == QNetworkReply::NetworkError::OperationCanceledError
|| replyError == QNetworkReply::NetworkError::TimeoutError) {
qDebug() << replyError;
return amnezia::ErrorCode::ApiConfigTimeoutError;
} else if (replyError == QNetworkReply::NetworkError::OperationNotImplementedError) {
}
if (replyError == QNetworkReply::NetworkError::OperationNotImplementedError) {
qDebug() << replyError;
return amnezia::ErrorCode::ApiUpdateRequestError;
} else {
qDebug() << QString::fromUtf8(responseBody);
qDebug() << replyError;
qDebug() << replyErrorString;
qDebug() << httpStatusCode;
}
int httpStatusFromBody = -1;
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody);
if (jsonDoc.isObject()) {
QJsonObject jsonObj = jsonDoc.object();
httpStatusFromBody = jsonObj.value("http_status").toInt(-1);
}
qDebug() << QString::fromUtf8(responseBody);
qDebug() << replyError;
qDebug() << httpStatusCode;
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody);
if (jsonDoc.isObject()) {
QJsonObject jsonObj = jsonDoc.object();
const int httpStatusFromBody = jsonObj.value(QStringLiteral("http_status")).toInt(-1);
if (httpStatusFromBody == httpStatusCodeConflict) {
if (apiErrorMessageFromJson(jsonObj).contains(trialAlreadyUsedMessage, Qt::CaseInsensitive)) {
return amnezia::ErrorCode::ApiTrialAlreadyUsedError;
}
return amnezia::ErrorCode::ApiConfigLimitError;
} else if (httpStatusFromBody == httpStatusCodeNotFound) {
}
if (httpStatusFromBody == httpStatusCodeNotFound) {
return amnezia::ErrorCode::ApiNotFoundError;
} else if (httpStatusFromBody == httpStatusCodeNotImplemented) {
}
if (httpStatusFromBody == httpStatusCodeNotImplemented) {
return amnezia::ErrorCode::ApiUpdateRequestError;
}
if (httpStatusFromBody == httpStatusCodeUnprocessableEntity) {
if (apiErrorMessageFromJson(jsonObj) == unprocessableSubscriptionMessage) {
return amnezia::ErrorCode::ApiSubscriptionExpiredError;
}
return amnezia::ErrorCode::ApiConfigDownloadError;
}
if (httpStatusFromBody == httpStatusCodePaymentRequired) {
return amnezia::ErrorCode::ApiSubscriptionNotActiveError;
}
return amnezia::ErrorCode::ApiConfigDownloadError;
}
qDebug() << "something went wrong";
return amnezia::ErrorCode::InternalError;
return amnezia::ErrorCode::ApiConfigDownloadError;
}
bool apiUtils::isPremiumServer(const QJsonObject &serverConfigObject)
{
static const QSet<apiDefs::ConfigType> premiumTypes = { apiDefs::ConfigType::AmneziaPremiumV1, apiDefs::ConfigType::AmneziaPremiumV2,
apiDefs::ConfigType::ExternalPremium };
apiDefs::ConfigType::ExternalPremium, apiDefs::ConfigType::ExternalTrial };
return premiumTypes.contains(getConfigType(serverConfigObject));
}
@@ -177,7 +239,9 @@ QString apiUtils::getPremiumV1VpnKey(const QJsonObject &serverConfigObject)
QString apiUtils::getPremiumV2VpnKey(const QJsonObject &serverConfigObject)
{
if (apiUtils::getConfigType(serverConfigObject) != apiDefs::ConfigType::AmneziaPremiumV2) {
auto configType = apiUtils::getConfigType(serverConfigObject);
if (configType != apiDefs::ConfigType::AmneziaPremiumV2 && configType != apiDefs::ConfigType::ExternalPremium
&& configType != apiDefs::ConfigType::ExternalTrial) {
return {};
}

View File

@@ -13,6 +13,8 @@ namespace apiUtils
bool isSubscriptionExpired(const QString &subscriptionEndDate);
bool isSubscriptionExpiringSoon(const QString &subscriptionEndDate, int withinDays = 30);
bool isPremiumServer(const QJsonObject &serverConfigObject);
apiDefs::ConfigType getConfigType(const QJsonObject &serverConfigObject);

View File

@@ -1,7 +1,10 @@
#include "coreController.h"
#include <QCoreApplication>
#include <QDirIterator>
#include <QDebug>
#include <QTranslator>
#include <QStandardPaths>
#if defined(Q_OS_ANDROID)
#include "core/installedAppsImageProvider.h"
@@ -26,10 +29,61 @@ CoreController::CoreController(const QSharedPointer<VpnConnection> &vpnConnectio
initNotificationHandler();
initLocalProxy();
m_translator.reset(new QTranslator());
updateTranslator(m_settings->getAppLanguage());
}
void CoreController::initLocalProxy()
{
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS)
constexpr quint16 kLocalProxyApiPort = 49490;
m_proxyServer.reset(new ProxyServer(m_settings, this));
QObject::connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, [this]() {
if (m_settings && m_settings->isLocalProxyHttpEnabled()) {
m_settings->setLocalProxyHttpEnabled(false);
}
});
auto syncLocalProxy = [this]() {
if (!m_proxyServer) {
return;
}
const bool httpEnabled = m_settings->isLocalProxyHttpEnabled();
if (!httpEnabled) {
qInfo() << "Local proxy: HTTP API disabled";
m_proxyServer->stop();
return;
}
if (!m_proxyServer->start(kLocalProxyApiPort)) {
qWarning() << "Local proxy: failed to start on port" << kLocalProxyApiPort;
m_settings->setLocalProxyHttpEnabled(false);
emit m_settings->localProxyStartFailed(tr("Local proxy failed to start. Check if the port is available."));
return;
}
if (!m_proxyServer->syncSettings()) {
qWarning() << "Local proxy: failed to start proxy core (Xray)";
m_settings->setLocalProxyHttpEnabled(false);
emit m_settings->localProxyStartFailed(tr("Couldnt start the proxy due to an internal error. Try restarting the app."));
return;
}
qInfo() << "Local proxy: running on 127.0.0.1:" << kLocalProxyApiPort;
};
syncLocalProxy();
connect(m_settings.get(), &Settings::localProxySettingsChanged, this, syncLocalProxy);
#endif
}
void CoreController::initModels()
{
m_containersModel.reset(new ContainersModel(this));
@@ -91,6 +145,12 @@ void CoreController::initModels()
m_apiServicesModel.reset(new ApiServicesModel(this));
m_engine->rootContext()->setContextProperty("ApiServicesModel", m_apiServicesModel.get());
m_apiSubscriptionPlansModel.reset(new ApiSubscriptionPlansModel(this));
m_engine->rootContext()->setContextProperty("ApiSubscriptionPlansModel", m_apiSubscriptionPlansModel.get());
m_apiBenefitsModel.reset(new ApiBenefitsModel(this));
m_engine->rootContext()->setContextProperty("ApiBenefitsModel", m_apiBenefitsModel.get());
m_apiCountryModel.reset(new ApiCountryModel(this));
m_engine->rootContext()->setContextProperty("ApiCountryModel", m_apiCountryModel.get());
@@ -112,6 +172,8 @@ void CoreController::initControllers()
m_pageController.reset(new PageController(m_serversModel, m_settings));
m_engine->rootContext()->setContextProperty("PageController", m_pageController.get());
connect(m_connectionController.get(), &ConnectionController::localProxyStoppedBecauseVpnTurnedOn, m_pageController.get(),
&PageController::showNotificationMessage);
m_focusController.reset(new FocusController(m_engine, this));
m_engine->rootContext()->setContextProperty("FocusController", m_focusController.get());
@@ -151,8 +213,11 @@ void CoreController::initControllers()
new ApiSettingsController(m_serversModel, m_apiAccountInfoModel, m_apiCountryModel, m_apiDevicesModel, m_settings));
m_engine->rootContext()->setContextProperty("ApiSettingsController", m_apiSettingsController.get());
m_apiConfigsController.reset(new ApiConfigsController(m_serversModel, m_apiServicesModel, m_settings));
m_apiConfigsController.reset(
new ApiConfigsController(m_serversModel, m_apiServicesModel, m_apiSubscriptionPlansModel, m_apiBenefitsModel, m_settings));
m_engine->rootContext()->setContextProperty("ApiConfigsController", m_apiConfigsController.get());
connect(m_apiConfigsController.get(), &ApiConfigsController::subscriptionRefreshNeeded,
this, [this]() { m_apiSettingsController->getAccountInfo(false); });
m_apiNewsController.reset(new ApiNewsController(m_newsModel, m_settings, m_serversModel, this));
m_engine->rootContext()->setContextProperty("ApiNewsController", m_apiNewsController.get());

View File

@@ -32,9 +32,11 @@
#include "ui/models/protocols/ikev2ConfigModel.h"
#endif
#include "ui/models/api/apiAccountInfoModel.h"
#include "ui/models/api/apiBenefitsModel.h"
#include "ui/models/api/apiCountryModel.h"
#include "ui/models/api/apiDevicesModel.h"
#include "ui/models/api/apiServicesModel.h"
#include "ui/models/api/apiSubscriptionPlansModel.h"
#include "ui/models/appSplitTunnelingModel.h"
#include "ui/models/clientManagementModel.h"
#include "ui/models/protocols/awgConfigModel.h"
@@ -50,7 +52,8 @@
#include "ui/models/newsModel.h"
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS)
#include "ui/notificationhandler.h"
#include "core/local-proxy/proxyserver.h"
#include "ui/notificationhandler.h"
#endif
class CoreController : public QObject
@@ -77,6 +80,7 @@ private:
void initAndroidController();
void initAppleController();
void initSignalHandlers();
void initLocalProxy();
void initNotificationHandler();
@@ -133,6 +137,8 @@ private:
QSharedPointer<ClientManagementModel> m_clientManagementModel;
QSharedPointer<ApiServicesModel> m_apiServicesModel;
QSharedPointer<ApiSubscriptionPlansModel> m_apiSubscriptionPlansModel;
QSharedPointer<ApiBenefitsModel> m_apiBenefitsModel;
QSharedPointer<ApiCountryModel> m_apiCountryModel;
QSharedPointer<ApiAccountInfoModel> m_apiAccountInfoModel;
QSharedPointer<ApiDevicesModel> m_apiDevicesModel;
@@ -148,6 +154,10 @@ private:
#endif
QScopedPointer<SftpConfigModel> m_sftpConfigModel;
QScopedPointer<Socks5ProxyConfigModel> m_socks5ConfigModel;
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS)
QScopedPointer<ProxyServer> m_proxyServer;
#endif
};
#endif // CORECONTROLLER_H

View File

@@ -44,8 +44,11 @@ namespace
constexpr int httpStatusCodeNotFound = 404;
constexpr int httpStatusCodeConflict = 409;
constexpr int httpStatusCodeNotImplemented = 501;
constexpr int httpStatusCodePaymentRequired = 402;
constexpr int httpStatusCodeUnprocessableEntity = 422;
constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?");
}
GatewayController::GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs,
@@ -333,10 +336,16 @@ QStringList GatewayController::getProxyUrls(const QString &serviceType, const QS
QStringList baseUrls;
if (m_isDevEnvironment) {
baseUrls = QString(DEV_S3_ENDPOINT).split(", ");
baseUrls = QString(DEV_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts);
} else {
baseUrls = QString(PROD_S3_ENDPOINT).split(", ");
baseUrls = QString(PROD_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts);
}
if (baseUrls.empty()) {
qDebug() << "empty storage endpoint list";
return {};
}
std::random_device randomDevice;
std::mt19937 generator(randomDevice());
std::shuffle(baseUrls.begin(), baseUrls.end(), generator);
@@ -415,12 +424,14 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep
{
const QByteArray &responseBody = decryptedResponseBody;
int httpStatus = -1;
int apiHttpStatus = -1;
QString apiErrorMessage;
if (isDecryptionSuccessful) {
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody);
if (jsonDoc.isObject()) {
QJsonObject jsonObj = jsonDoc.object();
httpStatus = jsonObj.value("http_status").toInt(-1);
apiHttpStatus = jsonObj.value("http_status").toInt(-1);
apiErrorMessage = jsonObj.value(QStringLiteral("message")).toString().trimmed();
}
} else {
qDebug() << "failed to decrypt the data";
@@ -431,10 +442,12 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep
qDebug() << "timeout occurred";
qDebug() << replyError;
return true;
} else if (responseBody.contains("html")) {
}
if (responseBody.contains("html")) {
qDebug() << "the response contains an html tag";
return true;
} else if (httpStatus == httpStatusCodeNotFound) {
}
if (apiHttpStatus == httpStatusCodeNotFound) {
if (responseBody.contains(errorResponsePattern1) || responseBody.contains(errorResponsePattern2)
|| responseBody.contains(errorResponsePattern3)) {
return false;
@@ -442,16 +455,25 @@ bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &rep
qDebug() << replyError;
return true;
}
} else if (httpStatus == httpStatusCodeNotImplemented) {
}
if (apiHttpStatus == httpStatusCodeNotImplemented) {
if (responseBody.contains(updateRequestResponsePattern)) {
return false;
} else {
qDebug() << replyError;
return true;
}
} else if (httpStatus == httpStatusCodeConflict) {
}
if (apiHttpStatus == httpStatusCodeConflict) {
return false;
} else if (replyError != QNetworkReply::NetworkError::NoError) {
}
if (apiHttpStatus == httpStatusCodePaymentRequired) {
return false;
}
if (apiHttpStatus == httpStatusCodeUnprocessableEntity) {
return apiErrorMessage != unprocessableSubscriptionMessage;
}
if (replyError != QNetworkReply::NetworkError::NoError) {
qDebug() << replyError;
return true;
}

View File

@@ -123,6 +123,9 @@ namespace amnezia
ApiUpdateRequestError = 1111,
ApiSubscriptionExpiredError = 1112,
ApiPurchaseError = 1113,
ApiSubscriptionNotActiveError = 1114,
ApiNoPurchasedSubscriptionsError = 1115,
ApiTrialAlreadyUsedError = 1116,
// QFile errors
OpenError = 1200,

View File

@@ -80,6 +80,9 @@ QString errorString(ErrorCode code) {
case (ErrorCode::ApiUpdateRequestError): errorMessage = QObject::tr("Please update the application to use this feature"); break;
case (ErrorCode::ApiSubscriptionExpiredError): errorMessage = QObject::tr("Your Amnezia Premium subscription has expired.\n Please check your email for renewal instructions.\n If you haven't received an email, please contact our support."); break;
case (ErrorCode::ApiPurchaseError): errorMessage = QObject::tr("Unable to process purchase"); break;
case (ErrorCode::ApiSubscriptionNotActiveError): errorMessage = QObject::tr("No active subscription found"); break;
case (ErrorCode::ApiNoPurchasedSubscriptionsError): errorMessage = QObject::tr("No purchased subscriptions found. Please purchase a subscription first"); break;
case (ErrorCode::ApiTrialAlreadyUsedError): errorMessage = QObject::tr("This email address has already been used to activate a trial"); break;
// QFile errors
case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break;

View File

@@ -0,0 +1,466 @@
#include "configmanager.h"
#include "containers/containers_defs.h"
#include "core/api/apiDefs.h"
#include "core/api/apiUtils.h"
#include "core/controllers/gatewayController.h"
#include "core/defs.h"
#include "portavailabilityhelper.h"
#include "proxylogger.h"
#include "settings.h"
#include "version.h"
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonParseError>
#include <QSaveFile>
#include <QSysInfo>
#include <QStandardPaths>
#include <QUuid>
ConfigManager::ConfigManager(const std::shared_ptr<Settings> &settings)
: m_settings(settings)
{
ProxyLogger::getInstance().debug("ConfigManager initialized (Settings-backed)");
}
namespace {
namespace gateway_key {
constexpr char apiConfig[] = "api_config";
constexpr char authData[] = "auth_data";
constexpr char userCountryCode[] = "user_country_code";
constexpr char serviceType[] = "service_type";
constexpr char serviceProtocol[] = "service_protocol";
constexpr char uuid[] = "installation_uuid";
constexpr char osVersion[] = "os_version";
constexpr char appVersion[] = "app_version";
constexpr char publicKey[] = "public_key";
constexpr char vless[] = "vless";
} // namespace gateway_key
constexpr quint16 kDefaultProxyPort = 10808;
constexpr int kProxyPortMin = 1024;
constexpr int kProxyPortMax = 65535;
int resolveProxyPort(const std::shared_ptr<Settings> &settings)
{
if (!settings) {
return kDefaultProxyPort;
}
const quint16 port = settings->localProxyPort();
if (port < kProxyPortMin || port > kProxyPortMax) {
return kDefaultProxyPort;
}
return static_cast<int>(port);
}
} // namespace
bool ConfigManager::applyProxyPortToConfig(QJsonObject &config, int port) const
{
if (!config.contains("inbounds") || !config.value("inbounds").isArray()) {
return false;
}
QJsonArray inbounds = config.value("inbounds").toArray();
if (inbounds.isEmpty() || !inbounds.at(0).isObject()) {
return false;
}
QJsonObject firstInbound = inbounds.at(0).toObject();
firstInbound.insert("port", port);
inbounds[0] = firstInbound;
config.insert("inbounds", inbounds);
return true;
}
QString ConfigManager::serializeConfig(const QJsonObject &config) const
{
return QString::fromUtf8(QJsonDocument(config).toJson(QJsonDocument::Compact));
}
std::optional<ConfigManager::ConfigData> ConfigManager::buildConfig(QString &errorDescription) const
{
errorDescription.clear();
if (!m_settings) {
const QString message = QStringLiteral("Settings backend is not available");
ProxyLogger::getInstance().error(message);
errorDescription = message;
return std::nullopt;
}
const QString ownerUuid = m_settings->localProxyOwnerUuid();
if (ownerUuid.isEmpty()) {
const QString message = QStringLiteral("Local proxy owner UUID is not configured");
ProxyLogger::getInstance().warning(message);
errorDescription = message;
return std::nullopt;
}
const auto ownerServer = findServerByUuid(ownerUuid);
if (!ownerServer) {
const QString message = QStringLiteral("Owner server with UUID %1 not found in Settings").arg(ownerUuid);
ProxyLogger::getInstance().error(message);
errorDescription = message;
return std::nullopt;
}
if (!apiUtils::isPremiumServer(*ownerServer)) {
const QString message = QStringLiteral("Server %1 is not premium, local proxy is unavailable")
.arg(ownerServer->value(amnezia::config_key::name).toString());
ProxyLogger::getInstance().warning(message);
errorDescription = message;
return std::nullopt;
}
const auto serializedConfig = extractSerializedXrayConfig(*ownerServer);
if (!serializedConfig || serializedConfig->isEmpty()) {
const QString message = QStringLiteral("Server %1 lacks Xray last_config payload")
.arg(ownerServer->value(amnezia::config_key::name).toString());
ProxyLogger::getInstance().error(message);
errorDescription = message;
return std::nullopt;
}
QJsonParseError parseError;
const QJsonDocument doc = QJsonDocument::fromJson(serializedConfig->toUtf8(), &parseError);
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
const QString message = QStringLiteral("Failed to parse Xray config JSON: %1").arg(parseError.errorString());
ProxyLogger::getInstance().error(message);
errorDescription = message;
return std::nullopt;
}
ConfigData data;
data.ownerUuid = ownerUuid;
data.serverName = ownerServer->value(amnezia::config_key::name).toString();
data.parsedConfig = doc.object();
const int proxyPort = resolveProxyPort(m_settings);
if (applyProxyPortToConfig(data.parsedConfig, proxyPort)) {
data.serializedConfig = serializeConfig(data.parsedConfig);
} else {
ProxyLogger::getInstance().warning(QStringLiteral("Failed to override local proxy inbound port; using original config"));
data.serializedConfig = *serializedConfig;
}
return data;
}
std::optional<ConfigManager::ConfigData> ConfigManager::buildConfigWithFetch(QString &errorDescription) const
{
errorDescription.clear();
if (!m_settings) {
const QString message = QStringLiteral("Settings backend is not available");
ProxyLogger::getInstance().error(message);
errorDescription = message;
return std::nullopt;
}
const QString ownerUuid = m_settings->localProxyOwnerUuid();
if (ownerUuid.isEmpty()) {
const QString message = QStringLiteral("Local proxy owner UUID is not configured");
ProxyLogger::getInstance().warning(message);
errorDescription = message;
return std::nullopt;
}
const auto ownerServer = findServerByUuid(ownerUuid);
if (!ownerServer) {
const QString message = QStringLiteral("Owner server with UUID %1 not found in Settings").arg(ownerUuid);
ProxyLogger::getInstance().error(message);
errorDescription = message;
return std::nullopt;
}
if (!apiUtils::isPremiumServer(*ownerServer)) {
const QString message = QStringLiteral("Server %1 is not premium, local proxy is unavailable")
.arg(ownerServer->value(amnezia::config_key::name).toString());
ProxyLogger::getInstance().warning(message);
errorDescription = message;
return std::nullopt;
}
auto serializedConfig = extractSerializedXrayConfig(*ownerServer);
if (!serializedConfig || serializedConfig->isEmpty()) {
auto fetchedConfig = fetchSerializedXrayConfigFromGateway(*ownerServer, errorDescription);
if (!fetchedConfig || fetchedConfig->isEmpty()) {
return std::nullopt;
}
serializedConfig = fetchedConfig;
}
QJsonParseError parseError;
const QJsonDocument doc = QJsonDocument::fromJson(serializedConfig->toUtf8(), &parseError);
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
const QString message = QStringLiteral("Failed to parse Xray config JSON: %1").arg(parseError.errorString());
ProxyLogger::getInstance().error(message);
errorDescription = message;
return std::nullopt;
}
ConfigData data;
data.ownerUuid = ownerUuid;
data.serverName = ownerServer->value(amnezia::config_key::name).toString();
data.parsedConfig = doc.object();
int selectedPort = resolveProxyPort(m_settings);
const bool isUserDefinedPort = m_settings->isLocalProxyPortUserDefined();
if (!PortAvailabilityHelper::isPortAvailable(selectedPort)) {
const bool canAutoSelect = !isUserDefinedPort && selectedPort == kDefaultProxyPort;
if (canAutoSelect) {
const auto freePort = PortAvailabilityHelper::findFirstAvailablePort(kDefaultProxyPort + 1, kProxyPortMax);
if (!freePort) {
errorDescription = QStringLiteral("No available local proxy port in range %1-%2")
.arg(kDefaultProxyPort + 1)
.arg(kProxyPortMax);
ProxyLogger::getInstance().error(errorDescription);
return std::nullopt;
}
selectedPort = *freePort;
} else {
errorDescription = QStringLiteral("Local proxy port %1 is already in use")
.arg(selectedPort);
ProxyLogger::getInstance().error(errorDescription);
return std::nullopt;
}
}
if (applyProxyPortToConfig(data.parsedConfig, selectedPort)) {
data.serializedConfig = serializeConfig(data.parsedConfig);
} else {
ProxyLogger::getInstance().warning(QStringLiteral("Failed to override local proxy inbound port; using original config"));
data.serializedConfig = *serializedConfig;
}
return data;
}
bool ConfigManager::writeTempConfig(const QString &serializedConfig, QString &configPath, QString &errorDescription) const
{
errorDescription.clear();
configPath.clear();
const QString directory = tempDirectory();
if (!QDir().mkpath(directory)) {
const QString message = QStringLiteral("Failed to create temp config directory: %1").arg(directory);
ProxyLogger::getInstance().error(message);
errorDescription = message;
return false;
}
const QString path = tempConfigPath();
QSaveFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
const QString message = QStringLiteral("Failed to open temp config file %1: %2").arg(path, file.errorString());
ProxyLogger::getInstance().error(message);
errorDescription = message;
return false;
}
if (file.write(serializedConfig.toUtf8()) == -1) {
const QString message = QStringLiteral("Failed to write temp config file %1: %2").arg(path, file.errorString());
ProxyLogger::getInstance().error(message);
errorDescription = message;
return false;
}
if (!file.commit()) {
const QString message = QStringLiteral("Failed to commit temp config file %1").arg(path);
ProxyLogger::getInstance().error(message);
errorDescription = message;
return false;
}
ProxyLogger::getInstance().info(QStringLiteral("Xray config saved to %1").arg(path));
configPath = path;
return true;
}
bool ConfigManager::removeTempConfig() const
{
const QString path = tempConfigPath();
QFile file(path);
if (!file.exists()) {
return true;
}
if (!file.remove()) {
ProxyLogger::getInstance().warning(QStringLiteral("Failed to remove temp config file %1: %2").arg(path, file.errorString()));
return false;
}
ProxyLogger::getInstance().debug(QStringLiteral("Removed temp config file %1").arg(path));
return true;
}
QString ConfigManager::tempConfigPath() const
{
return QDir(tempDirectory()).filePath(QStringLiteral("xray_active.json"));
}
std::optional<QJsonObject> ConfigManager::findServerByUuid(const QString &uuid) const
{
if (!m_settings) {
return std::nullopt;
}
const QJsonArray servers = m_settings->serversArray();
for (const QJsonValue &value : servers) {
const QJsonObject server = value.toObject();
if (server.value(amnezia::config_key::server_uuid).toString() == uuid) {
return server;
}
}
return std::nullopt;
}
std::optional<QString> ConfigManager::extractSerializedXrayConfig(const QJsonObject &server) const
{
const QJsonArray containers = server.value(amnezia::config_key::containers).toArray();
const QString targetContainer = ContainerProps::containerToString(amnezia::DockerContainer::Xray);
const QString protoKey = ProtocolProps::protoToString(amnezia::Proto::Xray);
for (const QJsonValue &value : containers) {
const QJsonObject container = value.toObject();
if (container.value(amnezia::config_key::container).toString() != targetContainer) {
continue;
}
const QJsonObject proto = container.value(protoKey).toObject();
const QString serialized = proto.value(amnezia::config_key::last_config).toString();
if (!serialized.isEmpty()) {
return serialized;
}
}
return std::nullopt;
}
std::optional<QString> ConfigManager::fetchSerializedXrayConfigFromGateway(const QJsonObject &server, QString &errorDescription) const
{
errorDescription.clear();
if (!m_settings) {
const QString message = QStringLiteral("Settings backend is not available");
ProxyLogger::getInstance().error(message);
errorDescription = message;
return std::nullopt;
}
const QJsonObject apiConfig = server.value(gateway_key::apiConfig).toObject();
if (apiConfig.isEmpty()) {
const QString message = QStringLiteral("Server API config is missing");
ProxyLogger::getInstance().warning(message);
errorDescription = message;
return std::nullopt;
}
const QString userCountryCode = apiConfig.value(gateway_key::userCountryCode).toString();
const QString serviceType = apiConfig.value(gateway_key::serviceType).toString();
if (userCountryCode.isEmpty() || serviceType.isEmpty()) {
const QString message = QStringLiteral("Server API config lacks service identifiers");
ProxyLogger::getInstance().warning(message);
errorDescription = message;
return std::nullopt;
}
QJsonObject apiPayload;
apiPayload[gateway_key::osVersion] = QSysInfo::productType();
apiPayload[gateway_key::appVersion] = QString(APP_VERSION);
const QString appLanguage = m_settings->getAppLanguage().name().split("_").first();
if (!appLanguage.isEmpty()) {
apiPayload[apiDefs::key::appLanguage] = appLanguage;
}
apiPayload[gateway_key::uuid] = m_settings->getInstallationUuid(true);
apiPayload[gateway_key::userCountryCode] = userCountryCode;
apiPayload[gateway_key::serviceType] = serviceType;
apiPayload[gateway_key::serviceProtocol] = gateway_key::vless;
apiPayload[gateway_key::publicKey] = QUuid::createUuid().toString(QUuid::WithoutBraces);
const QJsonObject authData = server.value(gateway_key::authData).toObject();
if (!authData.isEmpty()) {
apiPayload[gateway_key::authData] = authData;
}
GatewayController gatewayController(m_settings->getGatewayEndpoint(), m_settings->isDevGatewayEnv(), apiDefs::requestTimeoutMsecs,
m_settings->isStrictKillSwitchEnabled());
QByteArray responseBody;
const amnezia::ErrorCode errorCode = gatewayController.post(QString("%1v1/config"), apiPayload, responseBody);
if (errorCode != amnezia::ErrorCode::NoError) {
const QString message = QStringLiteral("Gateway request failed with error code %1").arg(static_cast<int>(errorCode));
ProxyLogger::getInstance().error(message);
errorDescription = message;
return std::nullopt;
}
QJsonParseError responseError;
const QJsonDocument responseDoc = QJsonDocument::fromJson(responseBody, &responseError);
if (responseError.error != QJsonParseError::NoError || !responseDoc.isObject()) {
const QString message = QStringLiteral("Failed to parse gateway response: %1").arg(responseError.errorString());
ProxyLogger::getInstance().error(message);
errorDescription = message;
return std::nullopt;
}
QString data = responseDoc.object().value(amnezia::config_key::config).toString();
if (data.isEmpty()) {
const QString message = QStringLiteral("Gateway response lacks config payload");
ProxyLogger::getInstance().error(message);
errorDescription = message;
return std::nullopt;
}
data.replace("vpn://", "");
QByteArray decoded = QByteArray::fromBase64(data.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
if (decoded.isEmpty()) {
const QString message = QStringLiteral("Gateway config payload is empty");
ProxyLogger::getInstance().error(message);
errorDescription = message;
return std::nullopt;
}
const QByteArray uncompressed = qUncompress(decoded);
if (!uncompressed.isEmpty()) {
decoded = uncompressed;
}
QJsonParseError configError;
const QJsonDocument configDoc = QJsonDocument::fromJson(decoded, &configError);
if (configError.error != QJsonParseError::NoError || !configDoc.isObject()) {
const QString message = QStringLiteral("Failed to parse gateway config JSON: %1").arg(configError.errorString());
ProxyLogger::getInstance().error(message);
errorDescription = message;
return std::nullopt;
}
const auto serializedConfig = extractSerializedXrayConfig(configDoc.object());
if (!serializedConfig || serializedConfig->isEmpty()) {
const QString message = QStringLiteral("Gateway response lacks Xray last_config payload");
ProxyLogger::getInstance().error(message);
errorDescription = message;
return std::nullopt;
}
ProxyLogger::getInstance().info("Fetched Xray config from gateway");
return serializedConfig;
}
QString ConfigManager::tempDirectory() const
{
const QString baseDir = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
if (baseDir.isEmpty()) {
return QDir::temp().filePath(QStringLiteral("amnezia_local_proxy"));
}
return QDir(baseDir).filePath(QStringLiteral("local_proxy"));
}

View File

@@ -0,0 +1,37 @@
#pragma once
#include <memory>
#include <optional>
#include <QJsonObject>
#include <QString>
class Settings;
class ConfigManager {
public:
struct ConfigData {
QString ownerUuid;
QString serverName;
QString serializedConfig;
QJsonObject parsedConfig;
};
explicit ConfigManager(const std::shared_ptr<Settings> &settings);
std::optional<ConfigData> buildConfig(QString &errorDescription) const;
std::optional<ConfigData> buildConfigWithFetch(QString &errorDescription) const;
bool writeTempConfig(const QString &serializedConfig, QString &configPath, QString &errorDescription) const;
bool removeTempConfig() const;
QString tempConfigPath() const;
private:
std::optional<QJsonObject> findServerByUuid(const QString &uuid) const;
std::optional<QString> extractSerializedXrayConfig(const QJsonObject &server) const;
std::optional<QString> fetchSerializedXrayConfigFromGateway(const QJsonObject &server, QString &errorDescription) const;
QString tempDirectory() const;
bool applyProxyPortToConfig(QJsonObject &config, int port) const;
QString serializeConfig(const QJsonObject &config) const;
std::shared_ptr<Settings> m_settings;
};

View File

@@ -0,0 +1,190 @@
#include "httpapi.h"
#include "proxylogger.h"
#include <QJsonObject>
#include <QJsonArray>
#include <QHostAddress>
#include <optional>
namespace {
std::optional<int> extractInboundPort(const QJsonObject &config)
{
if (!config.contains("inbounds") || !config["inbounds"].isArray()) {
return std::nullopt;
}
const QJsonArray inbounds = config["inbounds"].toArray();
if (inbounds.isEmpty() || !inbounds.at(0).isObject()) {
return std::nullopt;
}
const QJsonObject firstInbound = inbounds.at(0).toObject();
if (!firstInbound.contains("port")) {
return std::nullopt;
}
return firstInbound.value("port").toInt();
}
QJsonValue proxyPortValue(const std::optional<int> &port)
{
if (port.has_value()) {
return QJsonValue(*port);
}
return QJsonValue::Null;
}
QHttpServerResponse makeServiceUnavailablePingResponse()
{
QJsonObject payload{
{"status", "error"},
{"proxyPort", QJsonValue::Null}
};
return QHttpServerResponse(payload, QHttpServerResponse::StatusCode::ServiceUnavailable);
}
QHttpServerResponse makeServiceUnavailableStatusResponse()
{
QJsonObject payload{
{"status", "error"}
};
return QHttpServerResponse(payload, QHttpServerResponse::StatusCode::ServiceUnavailable);
}
} // namespace
HttpApi::HttpApi(QWeakPointer<IProxyService> service, QObject* parent)
: QObject(parent)
, m_tcpServer(new QTcpServer(this))
, m_service(service)
{
ProxyLogger::getInstance().debug("HttpApi initialized");
}
HttpApi::~HttpApi()
{
stop();
}
bool HttpApi::start(quint16 port)
{
ProxyLogger::getInstance().info(QString("Starting HTTP API server on port %1").arg(port));
if (!m_tcpServer->listen(QHostAddress::LocalHost, port)) {
ProxyLogger::getInstance().error(QString("Failed to start HTTP API server on port %1").arg(port));
return false;
}
setupRoutes();
m_server.bind(m_tcpServer.data());
ProxyLogger::getInstance().info(QString("HTTP API server is running on localhost:%1").arg(m_tcpServer->serverPort()));
ProxyLogger::getInstance().debug("Available endpoints:\n"
" POST /api/v1/up\n"
" POST /api/v1/down\n"
" GET /api/v1/ping");
return true;
}
void HttpApi::stop()
{
ProxyLogger::getInstance().info("Stopping HTTP API server");
if (m_tcpServer) {
m_tcpServer->close();
}
}
void HttpApi::setupRoutes()
{
ProxyLogger::getInstance().debug("Setting up HTTP API routes");
m_server.route("/api/v1/up", QHttpServerRequest::Method::Post,
[this] {
ProxyLogger::getInstance().debug("Handling POST /api/v1/up request");
return handlePostUp();
});
m_server.route("/api/v1/down", QHttpServerRequest::Method::Post,
[this] {
ProxyLogger::getInstance().debug("Handling POST /api/v1/down request");
return handlePostDown();
});
m_server.route("/api/v1/ping", QHttpServerRequest::Method::Get,
[this] {
ProxyLogger::getInstance().debug("Handling GET /api/v1/ping request");
return handleGetPing();
});
}
QHttpServerResponse HttpApi::handlePostUp()
{
if (auto service = m_service.lock()) {
const bool started = service->startXray();
QJsonObject response;
response["status"] = started ? "ok" : "error";
const auto port = started ? extractInboundPort(service->getConfig()) : std::optional<int>{};
response["proxyPort"] = proxyPortValue(port);
if (started) {
if (port.has_value()) {
ProxyLogger::getInstance().info(QString("Xray process started on port %1").arg(*port));
} else {
ProxyLogger::getInstance().warning("Xray started but inbound port is unknown (local proxy owner may be missing)");
}
} else {
ProxyLogger::getInstance().warning("Failed to start Xray process via HTTP API");
}
return QHttpServerResponse(response);
}
ProxyLogger::getInstance().error("Service unavailable: proxy backend is not initialized");
return makeServiceUnavailablePingResponse();
}
QHttpServerResponse HttpApi::handlePostDown()
{
if (auto service = m_service.lock()) {
const bool stopped = service->stopXray();
QJsonObject response;
response["status"] = stopped ? "ok" : "error";
if (!stopped) {
ProxyLogger::getInstance().warning("Failed to stop Xray process via HTTP API");
} else {
ProxyLogger::getInstance().info("Xray process stopped via HTTP API");
}
return QHttpServerResponse(response);
}
ProxyLogger::getInstance().error("Service unavailable: proxy backend is not initialized");
return makeServiceUnavailableStatusResponse();
}
QHttpServerResponse HttpApi::handleGetPing() const
{
if (auto service = m_service.lock()) {
QJsonObject response;
response["status"] = "ok";
const bool isRunning = service->isXrayRunning();
if (isRunning) {
const auto port = extractInboundPort(service->getConfig());
response["proxyPort"] = proxyPortValue(port);
if (port.has_value()) {
ProxyLogger::getInstance().debug(QString("Xray port: %1").arg(*port));
} else {
ProxyLogger::getInstance().warning("Unable to detect inbound port while Xray is running");
}
} else {
response["proxyPort"] = QJsonValue::Null;
ProxyLogger::getInstance().debug("Xray is not running");
}
return QHttpServerResponse(response);
}
ProxyLogger::getInstance().error("Service unavailable: proxy backend is not initialized");
return makeServiceUnavailablePingResponse();
}

View File

@@ -0,0 +1,32 @@
#pragma once
#include <QObject>
#include <QScopedPointer>
#include <QHttpServer>
#include <QHttpServerRequest>
#include <QHttpServerResponse>
#include <QTcpServer>
#include <QWeakPointer>
#include "iproxyservice.h"
class HttpApi : public QObject {
Q_OBJECT
public:
explicit HttpApi(QWeakPointer<IProxyService> service, QObject* parent = nullptr);
~HttpApi();
bool start(quint16 port);
void stop();
private:
void setupRoutes();
QHttpServerResponse handlePostUp();
QHttpServerResponse handlePostDown();
QHttpServerResponse handleGetPing() const;
QHttpServer m_server;
QScopedPointer<QTcpServer> m_tcpServer;
QWeakPointer<IProxyService> m_service;
};

View File

@@ -0,0 +1,17 @@
#pragma once
#include <QJsonObject>
#include <QString>
class IProxyService {
public:
virtual ~IProxyService() = default;
virtual QJsonObject getConfig() = 0;
virtual bool startXray() = 0;
virtual bool stopXray() = 0;
virtual bool isXrayRunning() const = 0;
virtual qint64 getXrayProcessId() const = 0;
virtual QString getXrayError() const = 0;
};

View File

@@ -0,0 +1,43 @@
#include "portavailabilityhelper.h"
#include <QHostAddress>
#include <QTcpServer>
namespace {
constexpr int kProxyPortMin = 1024;
constexpr int kProxyPortMax = 65535;
}
bool PortAvailabilityHelper::isPortAvailable(int port)
{
if (port < kProxyPortMin || port > kProxyPortMax) {
return false;
}
QTcpServer server;
const bool success = server.listen(QHostAddress::LocalHost, static_cast<quint16>(port));
server.close();
return success;
}
std::optional<int> PortAvailabilityHelper::findFirstAvailablePort(int startPort, int endPort)
{
if (startPort < kProxyPortMin) {
startPort = kProxyPortMin;
}
if (endPort > kProxyPortMax) {
endPort = kProxyPortMax;
}
if (startPort > endPort) {
return std::nullopt;
}
for (int port = startPort; port <= endPort; ++port) {
if (isPortAvailable(port)) {
return port;
}
}
return std::nullopt;
}

View File

@@ -0,0 +1,11 @@
#pragma once
#include <optional>
class PortAvailabilityHelper
{
public:
static bool isPortAvailable(int port);
static std::optional<int> findFirstAvailablePort(int startPort, int endPort);
};

View File

@@ -0,0 +1,133 @@
#include "proxylogger.h"
#include <QDir>
#include <QTextStream>
ProxyLogger::ProxyLogger() : m_maxFileSize(0), m_currentLevel(LogLevel::Info)
{
}
ProxyLogger::~ProxyLogger()
{
}
ProxyLogger& ProxyLogger::getInstance()
{
static ProxyLogger instance;
return instance;
}
void ProxyLogger::init(const QString& logPath, qint64 maxFileSize)
{
QMutexLocker locker(&m_mutex);
m_logPath = logPath;
m_maxFileSize = maxFileSize;
// Create logs directory if it doesn't exist
QDir dir = QFileInfo(m_logPath).dir();
if (!dir.exists()) {
dir.mkpath(".");
}
}
void ProxyLogger::setLogLevel(LogLevel level)
{
m_currentLevel = level;
}
void ProxyLogger::log(LogLevel level, const QString& message)
{
logInternal(level, message);
}
void ProxyLogger::debug(const QString& message)
{
logInternal(LogLevel::Debug, message);
}
void ProxyLogger::info(const QString& message)
{
logInternal(LogLevel::Info, message);
}
void ProxyLogger::warning(const QString& message)
{
logInternal(LogLevel::Warning, message);
}
void ProxyLogger::error(const QString& message)
{
logInternal(LogLevel::Error, message);
}
void ProxyLogger::logInternal(LogLevel level, const QString& message)
{
if (m_logPath.isEmpty()) {
return;
}
if (level < m_currentLevel) {
return;
}
QMutexLocker locker(&m_mutex);
checkRotation();
QFile file(m_logPath);
if (!openLogFile(file)) {
return;
}
QTextStream stream(&file);
QString timestamp = QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss.zzz");
stream << QString("[%1] [%2] %3\n").arg(timestamp, levelToString(level), message);
stream.flush();
file.close();
}
QString ProxyLogger::levelToString(LogLevel level)
{
switch (level) {
case LogLevel::Debug: return "DEBUG";
case LogLevel::Info: return "INFO";
case LogLevel::Warning: return "WARNING";
case LogLevel::Error: return "ERROR";
default: return "UNKNOWN";
}
}
qint64 ProxyLogger::getCurrentFileSize() const
{
QFile file(m_logPath);
if (file.exists()) {
return file.size();
}
return -1;
}
void ProxyLogger::checkRotation()
{
if (m_maxFileSize > 0 && getCurrentFileSize() >= m_maxFileSize) {
// Delete the oldest file
QFile::remove(QString("%1.%2").arg(m_logPath).arg(MAX_BACKUP_FILES));
// Shift existing files
for (int i = MAX_BACKUP_FILES - 1; i >= 1; --i) {
QString oldName = QString("%1.%2").arg(m_logPath).arg(i);
QString newName = QString("%1.%2").arg(m_logPath).arg(i + 1);
QFile::rename(oldName, newName);
}
// Rename current file
QFile::rename(m_logPath, m_logPath + ".1");
}
}
bool ProxyLogger::openLogFile(QFile& file)
{
if (!file.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) {
qDebug() << "Failed to open log file:" << m_logPath;
return false;
}
return true;
}

View File

@@ -0,0 +1,54 @@
#ifndef LOCAL_PROXY_LOGGER_H
#define LOCAL_PROXY_LOGGER_H
#include <QObject>
#include <QFile>
#include <QDateTime>
#include <QMutex>
#include <QString>
class ProxyLogger
{
public:
enum class LogLevel {
Debug,
Info,
Warning,
Error
};
static ProxyLogger& getInstance();
void init(const QString& logPath, qint64 maxFileSize = 1024 * 1024 * 10); // 10MB by default
void setLogLevel(LogLevel level);
// Main logging method
void log(LogLevel level, const QString& message);
// Helper methods for convenience
void debug(const QString& message);
void info(const QString& message);
void warning(const QString& message);
void error(const QString& message);
private:
ProxyLogger();
~ProxyLogger();
ProxyLogger(const ProxyLogger&) = delete;
ProxyLogger& operator=(const ProxyLogger&) = delete;
void logInternal(LogLevel level, const QString& message);
void checkRotation();
QString levelToString(LogLevel level);
bool openLogFile(QFile& file);
qint64 getCurrentFileSize() const;
QString m_logPath;
qint64 m_maxFileSize;
LogLevel m_currentLevel;
QMutex m_mutex;
static const int MAX_BACKUP_FILES = 3;
};
#endif // LOCAL_PROXY_LOGGER_H

View File

@@ -0,0 +1,111 @@
#include "proxyserver.h"
#include "settings.h"
#include <QDebug>
ProxyServer::ProxyServer(const std::shared_ptr<Settings> &settings, QObject *parent)
: QObject(parent)
, m_settings(settings)
, m_service(new ProxyService(settings, this))
{
m_lastRestartToken = m_settings ? m_settings->localProxyRestartToken() : 0;
}
ProxyServer::~ProxyServer()
{
stop();
}
bool ProxyServer::start(quint16 port)
{
if (m_isRunning) {
if (m_currentApiPort == port) {
qInfo() << "Local proxy: already running on port" << port;
return true;
}
qInfo() << "Local proxy: restarting on new port" << port;
stop();
}
m_api.reset(new HttpApi(m_service.toWeakRef()));
const bool apiStarted = m_api->start(port);
if (!apiStarted) {
qWarning() << "Local proxy: port is busy:" << port;
m_api.reset();
m_isRunning = false;
m_currentApiPort = 0;
return false;
}
m_isRunning = true;
m_currentApiPort = port;
return true;
}
void ProxyServer::stop()
{
stopXrayProcess();
if (m_api) {
m_api->stop();
m_api.reset();
}
m_isRunning = false;
m_currentApiPort = 0;
m_currentProxyPort = 0;
}
bool ProxyServer::startXrayProcess()
{
return m_service->startXray();
}
void ProxyServer::stopXrayProcess()
{
m_service->stopXray();
}
bool ProxyServer::syncSettings()
{
if (!m_isRunning) {
qDebug() << "Local proxy: syncSettings called but server is not running";
return false;
}
const quint16 newProxyPort = m_settings ? m_settings->localProxyPort() : 0;
const int restartToken = m_settings ? m_settings->localProxyRestartToken() : 0;
const bool xrayRunning = m_service->isXrayRunning();
if (!xrayRunning) {
qInfo() << "Local proxy: starting Xray on port" << newProxyPort;
const bool started = startXrayProcess();
if (started) {
m_currentProxyPort = newProxyPort;
m_lastRestartToken = restartToken;
}
return started;
}
if (m_lastRestartToken != restartToken) {
qInfo() << "Local proxy: restarting Xray due to config change token";
const bool restarted = m_service->restartXray();
if (restarted) {
m_currentProxyPort = newProxyPort;
m_lastRestartToken = restartToken;
}
return restarted;
}
if (m_currentProxyPort != newProxyPort) {
qInfo() << "Local proxy: proxy port changed from" << m_currentProxyPort << "to" << newProxyPort;
const bool restarted = m_service->restartXray();
if (restarted) {
m_currentProxyPort = newProxyPort;
m_lastRestartToken = restartToken;
}
return restarted;
}
return true;
}

View File

@@ -0,0 +1,36 @@
#pragma once
#include <QObject>
#include <QScopedPointer>
#include <QSharedPointer>
#include <memory>
#include "httpapi.h"
#include "proxyservice.h"
class Settings;
class ProxyServer : public QObject
{
Q_OBJECT
public:
explicit ProxyServer(const std::shared_ptr<Settings> &settings, QObject *parent = nullptr);
~ProxyServer();
bool start(quint16 port = 49490);
void stop();
bool syncSettings();
private:
bool startXrayProcess();
void stopXrayProcess();
std::shared_ptr<Settings> m_settings;
QScopedPointer<HttpApi> m_api;
QSharedPointer<ProxyService> m_service;
bool m_isRunning {false};
quint16 m_currentApiPort {0};
quint16 m_currentProxyPort {0};
int m_lastRestartToken {0};
};

View File

@@ -0,0 +1,117 @@
#include "proxyservice.h"
#include "proxylogger.h"
namespace {
void logConfigError(const QString &errorMessage)
{
if (!errorMessage.isEmpty()) {
ProxyLogger::getInstance().error(errorMessage);
}
}
} // namespace
ProxyService::ProxyService(const std::shared_ptr<Settings> &settings, QObject* parent)
: QObject(parent)
, m_configManager(new ConfigManager(settings))
, m_xrayController(new XrayController())
{
ProxyLogger::getInstance().debug("ProxyService initialized");
}
QJsonObject ProxyService::getConfig()
{
if (!m_cachedConfig.isEmpty()) {
return m_cachedConfig;
}
QString error;
const auto configData = m_configManager->buildConfigWithFetch(error);
if (!configData) {
logConfigError(error);
return {};
}
m_cachedConfig = configData->parsedConfig;
return m_cachedConfig;
}
bool ProxyService::startXray()
{
ProxyLogger::getInstance().info("Starting Xray");
if (m_xrayController->isXrayRunning()) {
ProxyLogger::getInstance().info("Xray is already running");
return true;
}
QString error;
const auto configData = m_configManager->buildConfigWithFetch(error);
if (!configData) {
logConfigError(error);
return false;
}
const bool success = m_xrayController->start(configData->serializedConfig);
if (success) {
m_cachedConfig = configData->parsedConfig;
ProxyLogger::getInstance().info("Xray started successfully");
emit xrayStatusChanged(true);
return true;
}
ProxyLogger::getInstance().error(QStringLiteral("Failed to start Xray: %1").arg(m_xrayController->getError()));
return false;
}
bool ProxyService::stopXray()
{
ProxyLogger::getInstance().info("Stopping Xray");
const bool stopped = m_xrayController->stop();
if (stopped) {
ProxyLogger::getInstance().info("Xray stopped");
emit xrayStatusChanged(false);
return true;
}
ProxyLogger::getInstance().warning(QStringLiteral("Failed to stop Xray: %1").arg(m_xrayController->getError()));
return false;
}
bool ProxyService::isXrayRunning() const
{
return m_xrayController->isXrayRunning();
}
qint64 ProxyService::getXrayProcessId() const
{
return m_xrayController->getProcessId();
}
QString ProxyService::getXrayError() const
{
return m_xrayController->getError();
}
void ProxyService::clearCache()
{
m_cachedConfig = QJsonObject();
ProxyLogger::getInstance().debug("ProxyService cache cleared");
}
bool ProxyService::restartXray()
{
ProxyLogger::getInstance().info("Restarting Xray with updated config");
clearCache();
if (m_xrayController->isXrayRunning()) {
if (!stopXray()) {
ProxyLogger::getInstance().error("Failed to stop Xray during restart, aborting");
return false;
}
}
return startXray();
}

View File

@@ -0,0 +1,38 @@
#pragma once
#include "configmanager.h"
#include "iproxyservice.h"
#include "xraycontroller.h"
#include <QObject>
#include <QScopedPointer>
#include <QJsonObject>
#include <memory>
class Settings;
class ProxyService : public QObject, public IProxyService {
Q_OBJECT
public:
explicit ProxyService(const std::shared_ptr<Settings> &settings, QObject* parent = nullptr);
~ProxyService() = default;
QJsonObject getConfig() override;
bool startXray() override;
bool stopXray() override;
bool isXrayRunning() const override;
qint64 getXrayProcessId() const override;
QString getXrayError() const override;
void clearCache();
bool restartXray();
signals:
void xrayStatusChanged(bool running);
private:
QScopedPointer<ConfigManager> m_configManager;
QScopedPointer<XrayController> m_xrayController;
QJsonObject m_cachedConfig;
};

View File

@@ -0,0 +1,105 @@
#include "xraycontroller.h"
#include "proxylogger.h"
#include "core/ipcclient.h"
namespace {
const QString kIpcUnavailableError = QStringLiteral("Failed to communicate with IPC service");
}
XrayController::XrayController(QObject *parent)
: QObject(parent)
{
ProxyLogger::getInstance().debug("XrayController initialized");
}
XrayController::~XrayController()
{
stop();
}
bool XrayController::start(const QString &configJson)
{
if (m_isRunning) {
ProxyLogger::getInstance().info("Xray is already running");
return true;
}
ProxyLogger::getInstance().info("Request to start Xray via IPC");
m_lastError.clear();
if (configJson.trimmed().isEmpty()) {
m_lastError = QStringLiteral("Config content is empty");
ProxyLogger::getInstance().error(m_lastError);
return false;
}
const bool ipcResult = IpcClient::withInterface([&](QSharedPointer<IpcInterfaceReplica> iface) {
auto xrayStart = iface->xrayStart(configJson);
if (!xrayStart.waitForFinished() || !xrayStart.returnValue()) {
ProxyLogger::getInstance().warning("Failed to start Xray via IPC");
return false;
}
return true;
}, []() {
return false;
});
if (!ipcResult) {
m_lastError = kIpcUnavailableError;
ProxyLogger::getInstance().error(m_lastError);
return false;
}
ProxyLogger::getInstance().info("Xray start command sent to IPC service");
m_isRunning = true;
return true;
}
bool XrayController::stop()
{
if (!m_isRunning) {
ProxyLogger::getInstance().debug("Skipping Xray stop via IPC: local proxy Xray is not running");
return true;
}
ProxyLogger::getInstance().info("Stopping Xray via IPC");
const bool ipcResult = IpcClient::withInterface([](QSharedPointer<IpcInterfaceReplica> iface) {
auto xrayStop = iface->xrayStop();
if (!xrayStop.waitForFinished() || !xrayStop.returnValue()) {
ProxyLogger::getInstance().warning("Failed to stop Xray via IPC");
return false;
}
return true;
}, []() {
return false;
});
if (!ipcResult) {
m_lastError = kIpcUnavailableError;
ProxyLogger::getInstance().warning(m_lastError);
return false;
}
m_isRunning = false;
return true;
}
bool XrayController::isXrayRunning() const
{
return m_isRunning;
}
qint64 XrayController::getProcessId() const
{
return -1;
}
QString XrayController::getError() const
{
return m_lastError;
}

View File

@@ -0,0 +1,22 @@
#pragma once
#include <QObject>
#include <QString>
class XrayController : public QObject {
Q_OBJECT
public:
explicit XrayController(QObject* parent = nullptr);
~XrayController();
bool start(const QString& configJson);
bool stop();
bool isXrayRunning() const;
qint64 getProcessId() const;
QString getError() const;
private:
bool m_isRunning {false};
QString m_lastError;
};

View File

@@ -1,6 +1,11 @@
#include <QString>
#include <QJsonArray>
#include <QJsonObject>
#include <QList>
#include <QHostAddress>
#include <QRandomGenerator>
#include <QTcpServer>
#include <stdexcept>
#include "3rd/QJsonStruct/QJsonIO.hpp"
#include "transfer.h"
#include "serialization.h"
@@ -14,25 +19,125 @@ namespace amnezia::serialization::inbounds
// "port": 10808,
// "protocol": "socks",
// "settings": {
// "auth": "password",
// "accounts": [{"user": "...", "pass": "..."}],
// "udp": true
// }
// }
//],
const static QString listen = "127.0.0.1";
const static int port = 10808;
const static int defaultPort = 10808;
const static QString protocol = "socks";
static int indexOfSocksInbound(const QJsonArray &inbounds)
{
for (int i = 0; i < inbounds.size(); ++i) {
const QString p = inbounds.at(i).toObject().value(QLatin1String("protocol")).toString();
if (p.compare(QLatin1String("socks"), Qt::CaseInsensitive) == 0)
return i;
}
return -1;
}
// Ask the OS for a free TCP port on loopback (same stack as inbound "listen": "127.0.0.1").
static int acquireFreeLocalPort()
{
QTcpServer probe;
if (!probe.listen(QHostAddress(QStringLiteral("127.0.0.1")), 0)) {
throw std::runtime_error(
"Failed to bind a local TCP port on 127.0.0.1 for SOCKS inbound "
"(QTcpServer::listen failed; possible permission or OS network error).");
}
return static_cast<int>(probe.serverPort());
}
// Generates a hex string of `byteCount` random bytes (URL-safe, no special chars).
static QString generateRandomHex(int byteCount)
{
if (byteCount <= 0)
return {};
// fillRange writes full quint32 words; size the buffer to a multiple of 4 bytes to avoid
// overrunning a short buffer when byteCount is not divisible by 4.
const int numUint32 = (byteCount + int(sizeof(quint32)) - 1) / int(sizeof(quint32));
QByteArray buf(numUint32 * int(sizeof(quint32)), '\0');
QRandomGenerator::system()->fillRange(reinterpret_cast<quint32 *>(buf.data()), numUint32);
return QString::fromLatin1(buf.left(byteCount).toHex());
}
QJsonObject GenerateInboundEntry()
{
QJsonObject root;
QJsonIO::SetValue(root, listen, "listen");
QJsonIO::SetValue(root, port, "port");
QJsonIO::SetValue(root, defaultPort, "port");
QJsonIO::SetValue(root, protocol, "protocol");
QJsonIO::SetValue(root, true, "settings", "udp");
return root;
}
InboundCredentials GetInboundCredentials(const QJsonObject &xrayConfig)
{
InboundCredentials creds;
creds.port = defaultPort;
const QJsonArray inbounds = xrayConfig.value("inbounds").toArray();
const int socksIdx = indexOfSocksInbound(inbounds);
if (socksIdx < 0)
return creds;
const QJsonObject inbound = inbounds.at(socksIdx).toObject();
creds.port = inbound.value("port").toInt(defaultPort);
const QJsonObject settings = inbound.value("settings").toObject();
const QJsonArray accounts = settings.value("accounts").toArray();
if (accounts.isEmpty())
return creds;
const QJsonObject account = accounts.first().toObject();
creds.username = account.value("user").toString();
creds.password = account.value("pass").toString();
return creds;
}
InboundCredentials EnsureInboundAuth(QJsonObject &xrayConfig)
{
QJsonArray inbounds = xrayConfig.value("inbounds").toArray();
const int socksIdx = indexOfSocksInbound(inbounds);
if (socksIdx < 0)
return GetInboundCredentials(xrayConfig); // no SOCKS inbound to patch
QJsonObject inbound = inbounds.at(socksIdx).toObject();
InboundCredentials creds;
creds.port = acquireFreeLocalPort();
inbound["port"] = creds.port;
QJsonObject settings = inbound.value("settings").toObject();
const QJsonArray accounts = settings.value("accounts").toArray();
if (!accounts.isEmpty()) {
const QJsonObject account = accounts.first().toObject();
creds.username = account.value("user").toString();
creds.password = account.value("pass").toString();
}
if (creds.username.isEmpty() || creds.password.isEmpty()) {
// Generate fresh credentials for this session (never persisted)
creds.username = generateRandomHex(8); // 16 hex chars
creds.password = generateRandomHex(16); // 32 hex chars
QJsonObject account;
account["user"] = creds.username;
account["pass"] = creds.password;
settings["accounts"] = QJsonArray{ account };
}
// Always ensure auth mode is enforced, even for imported configs that had
// accounts but auth: "noauth" (or no auth field at all).
settings["auth"] = QStringLiteral("password");
inbound["settings"] = settings;
inbounds[socksIdx] = inbound;
xrayConfig["inbounds"] = inbounds;
return creds;
}
} // namespace amnezia::serialization::inbounds

View File

@@ -60,7 +60,24 @@ namespace amnezia::serialization
namespace inbounds
{
struct InboundCredentials {
QString username;
QString password;
int port;
};
QJsonObject GenerateInboundEntry();
// Reads existing SOCKS5 auth from the first inbound with protocol "socks"
// (.settings.accounts[0]). Returns empty username/password if none.
InboundCredentials GetInboundCredentials(const QJsonObject &xrayConfig);
// Ensures SOCKS5 auth is present on the inbound whose protocol is "socks".
// Re-uses existing credentials if already set; otherwise generates random ones
// and writes them into the config. Assigns a free loopback TCP port each session
// (OS-assigned). Throws std::runtime_error if a SOCKS inbound exists but binding
// a local port on 127.0.0.1 fails (e.g. permissions or OS error).
InboundCredentials EnsureInboundAuth(QJsonObject &xrayConfig);
}
}

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 21V17C15 16.4696 15.2107 15.9609 15.5858 15.5858C15.9609 15.2107 16.4696 15 17 15H21" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 4V6C7.21572 6.61347 7.62494 7.14024 8.16602 7.50096C8.7071 7.86168 9.35075 8.03682 10 8V8C10.5304 8 11.0391 8.21071 11.4142 8.58579C11.7893 8.96086 12 9.46957 12 10C12 10.5304 12.2107 11.0391 12.5858 11.4142C12.9609 11.7893 13.4696 12 14 12C14.5304 12 15.0391 11.7893 15.4142 11.4142C15.7893 11.0391 16 10.5304 16 10C16 9.46957 16.2107 8.96086 16.5858 8.58579C16.9609 8.21071 17.4696 8 18 8H21" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 11H5C5.53043 11 6.03914 11.2107 6.41421 11.5858C6.78929 11.9609 7 12.4696 7 13V14C7 14.5304 7.21071 15.0391 7.58579 15.4142C7.96086 15.7893 8.46957 16 9 16C9.53043 16 10.0391 16.2107 10.4142 16.5858C10.7893 16.9609 11 17.4696 11 18V22" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.1777 8C23.2737 8 23.2737 16 18.1777 16C13.0827 16 11.0447 8 5.43875 8C0.85375 8 0.85375 16 5.43875 16C11.0447 16 13.0828 8 18.1788 8H18.1777Z" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 342 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 2H7C5.89543 2 5 2.89543 5 4V20C5 21.1046 5.89543 22 7 22H17C18.1046 22 19 21.1046 19 20V4C19 2.89543 18.1046 2 17 2Z" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 18H12.01" stroke="#D7D8DB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 423 B

View File

@@ -16,7 +16,7 @@ set_target_properties(AmneziaVPNNetworkExtension PROPERTIES
XCODE_ATTRIBUTE_PRODUCT_BUNDLE_NAME "${BUILD_IOS_APP_IDENTIFIER}.network-extension"
XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS ${CMAKE_CURRENT_SOURCE_DIR}/AmneziaVPNNetworkExtension.entitlements
XCODE_ATTRIBUTE_MARKETING_VERSION "${APP_MAJOR_VERSION}"
XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION "${BUILD_ID}"
XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION "${CMAKE_PROJECT_VERSION_TWEAK}"
XCODE_ATTRIBUTE_PRODUCT_NAME "AmneziaVPNNetworkExtension"
XCODE_ATTRIBUTE_APPLICATION_EXTENSION_API_ONLY "YES"

View File

@@ -8,7 +8,7 @@
<string>AmneziaVPNNetworkExtension</string>
<key>CFBundleIdentifier</key>
<string>org.amnezia.AmneziaVPN.network-extension</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
@@ -16,9 +16,9 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>${APPLE_PROJECT_VERSION}</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>${CMAKE_PROJECT_VERSION_TWEAK}</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
@@ -41,6 +41,6 @@
<string>group.org.amnezia.AmneziaVPN</string>
<key>com.wireguard.macos.app_group_id</key>
<string>${BUILD_VPN_DEVELOPMENT_TEAM}.group.org.amnezia.AmneziaVPN</string>
<string>$(DEVELOPMENT_TEAM).group.org.amnezia.AmneziaVPN</string>
</dict>
</plist>

View File

@@ -101,7 +101,9 @@ bool AndroidController::initialize()
{"onAuthResult", "(Z)V", reinterpret_cast<void *>(onAuthResult)},
{"decodeQrCode", "(Ljava/lang/String;)Z", reinterpret_cast<bool *>(decodeQrCode)},
{"onImeInsetsChanged", "(I)V", reinterpret_cast<void *>(onImeInsetsChanged)},
{"onSystemBarsInsetsChanged", "(II)V", reinterpret_cast<void *>(onSystemBarsInsetsChanged)}
{"onSystemBarsInsetsChanged", "(II)V", reinterpret_cast<void *>(onSystemBarsInsetsChanged)},
{"onActivityPaused", "()V", reinterpret_cast<void *>(onActivityPaused)},
{"onActivityResumed", "()V", reinterpret_cast<void *>(onActivityResumed)}
};
QJniEnvironment env;
@@ -558,3 +560,22 @@ void AndroidController::onSystemBarsInsetsChanged(JNIEnv *env, jobject thiz, jin
emit AndroidController::instance()->systemBarsInsetsChanged(navBarHeightDp, statusBarHeightDp);
}
// static
void AndroidController::onActivityPaused(JNIEnv *env, jobject thiz)
{
Q_UNUSED(env);
Q_UNUSED(thiz);
emit AndroidController::instance()->activityPaused();
}
// static
void AndroidController::onActivityResumed(JNIEnv *env, jobject thiz)
{
Q_UNUSED(env);
Q_UNUSED(thiz);
emit AndroidController::instance()->activityResumed();
}

View File

@@ -75,6 +75,8 @@ signals:
void authenticationResult(bool result);
void imeInsetsChanged(int heightDp);
void systemBarsInsetsChanged(int navBarHeightDp, int statusBarHeightDp);
void activityPaused();
void activityResumed();
private:
bool isWaitingStatus = true;
@@ -105,6 +107,8 @@ private:
static bool decodeQrCode(JNIEnv *env, jobject thiz, jstring data);
static void onImeInsetsChanged(JNIEnv *env, jobject thiz, jint heightDp);
static void onSystemBarsInsetsChanged(JNIEnv *env, jobject thiz, jint navBarHeightDp, jint statusBarHeightDp);
static void onActivityPaused(JNIEnv *env, jobject thiz);
static void onActivityResumed(JNIEnv *env, jobject thiz);
template <typename Ret, typename ...Args>
static auto callActivityMethod(const char *methodName, const char *signature, Args &&...args);

View File

@@ -15,6 +15,12 @@ struct OpenVPNConfig: Decodable {
extension PacketTunnelProvider {
func startOpenVPN(completionHandler: @escaping (Error?) -> Void) {
// Reset session-derived state so reconnects never reuse stale gateway/address data.
openVpnGatewayAddress = nil
openVpnLocalAddress = nil
openVpnLocalMask = nil
lastOpenVPNSettings = nil
guard let protocolConfiguration = self.protocolConfiguration as? NETunnelProviderProtocol,
let providerConfiguration = protocolConfiguration.providerConfiguration,
let openVPNConfigData = providerConfiguration[Constants.ovpnConfigKey] as? Data else {
@@ -25,7 +31,25 @@ extension PacketTunnelProvider {
do {
let openVPNConfig = try JSONDecoder().decode(OpenVPNConfig.self, from: openVPNConfigData)
ovpnLog(.info, title: "config: ", message: openVPNConfig.str)
let wrapperPreview = String(decoding: openVPNConfigData.prefix(512), as: UTF8.self)
let ovpnPreview = String(openVPNConfig.config.prefix(512))
ovpnLog(.info, title: "config wrapper", message: "bytes=\(openVPNConfigData.count) preview=\(wrapperPreview)")
ovpnLog(.info, title: "config raw", message: "chars=\(openVPNConfig.config.count) preview=\(ovpnPreview)")
let ovpnConfiguration = Data(openVPNConfig.config.utf8)
splitTunnelType = openVPNConfig.splitTunnelType
splitTunnelSites = openVPNConfig.splitTunnelSites
openVpnDnsServers = Self.extractDnsServers(from: openVPNConfig.config)
openVpnRemoteAddress = Self.extractRemoteHost(from: openVPNConfig.config)
openVpnRedirectGatewayDef1 = Self.hasRedirectGatewayDef1(in: openVPNConfig.config)
if let openVpnRemoteAddress {
ovpnLog(.info, title: "Remote", message: "host=\(openVpnRemoteAddress)")
}
if !openVpnDnsServers.isEmpty {
ovpnLog(.info, title: "DNS", message: "servers=\(openVpnDnsServers)")
}
if openVpnRedirectGatewayDef1 {
ovpnLog(.info, title: "IPv4Routes", message: "redirect-gateway def1 detected")
}
setupAndlaunchOpenVPN(withConfig: ovpnConfiguration, completionHandler: completionHandler)
} catch {
ovpnLog(.error, message: "Can't parse OpenVPN config: \(error.localizedDescription)")
@@ -73,6 +97,11 @@ extension PacketTunnelProvider {
let digestString = digest.map { String(format: "%02x", $0) }.joined()
ovpnLog(.info, title: "ConfigDigest", message: digestString)
let hasCertTag = configString.contains("<cert>") && configString.contains("</cert>")
let hasKeyTag = configString.contains("<key>") && configString.contains("</key>")
let hasAuthUserPass = configString.contains("auth-user-pass")
ovpnLog(.info, title: "ConfigCreds", message: "inlineCert=\(hasCertTag) inlineKey=\(hasKeyTag) authUserPass=\(hasAuthUserPass)")
let hasTlsAuthOpen = configString.contains("<tls-auth>")
let hasTlsAuthClose = configString.contains("</tls-auth>")
ovpnLog(.info, title: "ConfigFlags", message: "tls-auth open=\(hasTlsAuthOpen) close=\(hasTlsAuthClose)")
@@ -83,27 +112,98 @@ extension PacketTunnelProvider {
ovpnLog(.debug, title: "ConfigHead", message: head)
ovpnLog(.debug, title: "ConfigTail", message: tail)
if let start = configString.range(of: "<tls-auth>"),
let end = configString.range(of: "</tls-auth>", range: start.upperBound..<configString.endIndex) {
let keyBody = String(configString[start.upperBound..<end.lowerBound])
ovpnLog(.debug, title: "TLSAuthInline", message: keyBody)
let sanitizedLines = keyBody
.split(whereSeparator: { $0.isNewline })
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.filter { !$0.hasPrefix("#") }
let sanitizedKey = sanitizedLines.joined(separator: "\n")
ovpnLog(.debug, title: "TLSAuthSanitized", message: sanitizedKey)
let sanitizedBlock = "<tls-auth>\n\(sanitizedKey)\n</tls-auth>"
configString.replaceSubrange(start.lowerBound..<end.upperBound, with: sanitizedBlock)
if hasTlsAuthOpen && hasTlsAuthClose {
ovpnLog(.info, title: "TLSAuthSanitized", message: "preserve original tls-auth block")
}
let normalizedConfig = configString.replacingOccurrences(of: "\r\n", with: "\n")
var normalizedConfig = configString.replacingOccurrences(of: "\r\n", with: "\n")
normalizedConfig = Self.normalizeInlineBlock(
in: normalizedConfig,
tag: "ca",
beginMarkers: ["-----BEGIN CERTIFICATE-----"],
endMarkers: ["-----END CERTIFICATE-----"]
)
normalizedConfig = Self.normalizeInlineBlock(
in: normalizedConfig,
tag: "cert",
beginMarkers: ["-----BEGIN CERTIFICATE-----"],
endMarkers: ["-----END CERTIFICATE-----"]
)
normalizedConfig = Self.normalizeInlineBlock(
in: normalizedConfig,
tag: "key",
beginMarkers: [
"-----BEGIN PRIVATE KEY-----",
"-----BEGIN RSA PRIVATE KEY-----",
"-----BEGIN EC PRIVATE KEY-----",
"-----BEGIN ENCRYPTED PRIVATE KEY-----"
],
endMarkers: [
"-----END PRIVATE KEY-----",
"-----END RSA PRIVATE KEY-----",
"-----END EC PRIVATE KEY-----",
"-----END ENCRYPTED PRIVATE KEY-----"
]
)
normalizedConfig = Self.normalizeInlineBlock(
in: normalizedConfig,
tag: "tls-auth",
beginMarkers: ["-----BEGIN OpenVPN Static key V1-----"],
endMarkers: ["-----END OpenVPN Static key V1-----"]
)
normalizedConfig = Self.stripUnsupportedOptions(forOpenVPNAdapter: normalizedConfig)
if !normalizedConfig.hasSuffix("\n") {
normalizedConfig.append("\n")
}
let normalizedLines = normalizedConfig.split(whereSeparator: \.isNewline)
let normalizedTail = normalizedLines.suffix(10).joined(separator: "\n")
ovpnLog(.debug, title: "ConfigTailSanitized", message: normalizedTail)
let redirectLines = normalizedLines
.map(String.init)
.filter { $0.lowercased().contains("redirect-gateway") }
if !redirectLines.isEmpty {
ovpnLog(.info, title: "ConfigRedirect", message: redirectLines.joined(separator: " | "))
}
let controlScalars = normalizedConfig.unicodeScalars.filter {
($0.value < 0x20 && $0 != "\n" && $0 != "\r" && $0 != "\t")
}
if !controlScalars.isEmpty {
ovpnLog(.error, title: "ConfigChars", message: "nonPrintableControlCount=\(controlScalars.count)")
}
#if os(macOS)
let dumpBaseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let dumpURL = dumpBaseURL.appendingPathComponent("amnezia_ovpn_adapter_config.conf")
do {
try normalizedConfig.write(to: dumpURL, atomically: true, encoding: .utf8)
ovpnLog(.info, title: "ConfigDump", message: "path=\(dumpURL.path) bytes=\(normalizedConfig.utf8.count)")
} catch {
ovpnLog(.error, title: "ConfigDump", message: "write failed: \(error.localizedDescription)")
}
#endif
let sanitizedData = Data(normalizedConfig.utf8)
let configuration = OpenVPNConfiguration()
configuration.fileContent = sanitizedData
// Be explicit: enum default is 0 (enabled), we need stubs-only behavior.
configuration.compressionMode = .disabled
// A-012: emulate OpenVPN2 CLI capability advertisement as closely as possible.
configuration.peerInfo = [
"IV_VER": "2.6.10",
"IV_PLAT": "mac",
"IV_TCPNL": "1",
"IV_MTU": "1600",
"IV_NCP": "2",
"IV_CIPHERS": "AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305",
"IV_PROTO": "990",
"IV_LZO_STUB": "1",
"IV_COMP_STUB": "1",
"IV_COMP_STUBv2": "1"
]
if let peerInfo = configuration.peerInfo {
let peerInfoSummary = peerInfo.keys.sorted().map { "\($0)=\(peerInfo[$0] ?? "")" }.joined(separator: " ")
ovpnLog(.info, title: "PeerInfoOverride", message: peerInfoSummary)
}
if configString.contains("cloak") {
configuration.setPTCloak()
}
@@ -124,12 +224,16 @@ extension PacketTunnelProvider {
if evaluation?.autologin == false {
ovpnLog(.info, message: "Implement login with user credentials")
}
vpnReachability.startTracking { [weak self] status in
guard status == .reachableViaWiFi else { return }
self?.ovpnAdapter?.reconnect(afterTimeInterval: 5)
if let evaluation {
ovpnLog(.info, title: "ConfigEval", message: "autologin=\(evaluation.autologin) externalPki=\(evaluation.externalPki)")
}
#if !os(macOS)
vpnReachability.startTracking { [weak self] status in
self?.handleOpenVPNReachabilityChange(status)
}
#endif
startHandler = completionHandler
ovpnAdapter?.connect(using: openVPNPacketFlow())
}
@@ -144,6 +248,8 @@ extension PacketTunnelProvider {
return
}
ovpnLog(.info, title: "Transport", message: "bytesIn=\(bytesin) bytesOut=\(bytesout)")
let response: [String: Any] = [
"rx_bytes": bytesin,
"tx_bytes": bytesout
@@ -156,6 +262,10 @@ extension PacketTunnelProvider {
ovpnLog(.info, message: "Stopping tunnel: reason: \(reason.amneziaDescription)")
stopHandler = completionHandler
openVpnGatewayAddress = nil
openVpnLocalAddress = nil
openVpnLocalMask = nil
lastOpenVPNSettings = nil
if vpnReachability.isTracking {
vpnReachability.stopTracking()
}
@@ -175,11 +285,99 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate {
configureTunnelWithNetworkSettings networkSettings: NEPacketTunnelNetworkSettings?,
completionHandler: @escaping (Error?) -> Void
) {
guard var effectiveSettings = networkSettings else {
ovpnLog(.info, title: "SetTunnelNetworkSettings", message: "nil settings; skipping update")
completionHandler(nil)
return
}
let splitType = splitTunnelType ?? 0
if let ipv4Settings = effectiveSettings.ipv4Settings {
openVpnLocalAddress = ipv4Settings.addresses.first
openVpnLocalMask = ipv4Settings.subnetMasks.first
}
let serverIP = openVPNAdapter.connectionInformation?.serverIP
let configRemote = openVpnRemoteAddress
let serverEndpoint: String? = {
if let ip = serverIP, Self.isIPv4Address(ip) { return ip }
if let ip = configRemote, Self.isIPv4Address(ip) { return ip }
return effectiveSettings.tunnelRemoteAddress
}()
if let serverEndpoint,
Self.isIPv4Address(serverEndpoint),
effectiveSettings.tunnelRemoteAddress != serverEndpoint {
let updatedSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: serverEndpoint)
updatedSettings.ipv4Settings = effectiveSettings.ipv4Settings
updatedSettings.ipv6Settings = effectiveSettings.ipv6Settings
updatedSettings.dnsSettings = effectiveSettings.dnsSettings
updatedSettings.proxySettings = effectiveSettings.proxySettings
updatedSettings.mtu = effectiveSettings.mtu
effectiveSettings = updatedSettings
ovpnLog(.info, title: "Remote", message: "tunnelRemoteAddress set to server=\(serverEndpoint)")
} else if let serverEndpoint, !Self.isIPv4Address(serverEndpoint) {
ovpnLog(.info, title: "Remote", message: "skip tunnelRemoteAddress override; non-ip serverEndpoint=\(serverEndpoint)")
}
// In order to direct all DNS queries first to the VPN DNS servers before the primary DNS servers
// send empty string to NEDNSSettings.matchDomains
networkSettings?.dnsSettings?.matchDomains = [""]
if let dnsSettings = effectiveSettings.dnsSettings {
if dnsSettings.servers.isEmpty, !openVpnDnsServers.isEmpty {
let newSettings = NEDNSSettings(servers: openVpnDnsServers)
newSettings.matchDomains = dnsSettings.matchDomains
effectiveSettings.dnsSettings = newSettings
}
} else if !openVpnDnsServers.isEmpty {
let newSettings = NEDNSSettings(servers: openVpnDnsServers)
effectiveSettings.dnsSettings = newSettings
}
if splitTunnelType == 1 {
effectiveSettings.dnsSettings?.matchDomains = [""]
if let dnsSettings = effectiveSettings.dnsSettings {
let servers = dnsSettings.servers.joined(separator: ",")
let domains = dnsSettings.matchDomains?.joined(separator: ",") ?? ""
ovpnLog(.info, title: "DNS", message: "servers=[\(servers)] matchDomains=[\(domains)]")
} else {
ovpnLog(.error, title: "DNS", message: "dnsSettings is nil")
}
let tunnelRemote = effectiveSettings.tunnelRemoteAddress
if !tunnelRemote.isEmpty {
ovpnLog(.info, title: "Remote", message: "tunnelRemoteAddress=\(tunnelRemote)")
} else if let remoteAddress = openVpnRemoteAddress {
ovpnLog(.info, title: "Remote", message: "tunnelRemoteAddress is empty, configRemote=\(remoteAddress)")
}
if let ipv4Settings = effectiveSettings.ipv4Settings {
let included = (ipv4Settings.includedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationSubnetMask)" }
let excluded = (ipv4Settings.excludedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationSubnetMask)" }
let addresses = ipv4Settings.addresses.joined(separator: ",")
let masks = ipv4Settings.subnetMasks.joined(separator: ",")
let router: String
#if os(macOS)
if #available(macOS 13.0, *) {
router = ipv4Settings.router ?? ""
} else {
router = ""
}
#else
router = ""
#endif
ovpnLog(.info, title: "IPv4RoutesPre", message: "addresses=[\(addresses)] masks=[\(masks)] router=\(router) included=\(included) excluded=\(excluded)")
} else {
ovpnLog(.error, title: "IPv4RoutesPre", message: "ipv4Settings is nil")
}
if let ipv6Settings = effectiveSettings.ipv6Settings {
let included = (ipv6Settings.includedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationNetworkPrefixLength)" }
let excluded = (ipv6Settings.excludedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationNetworkPrefixLength)" }
let addresses = ipv6Settings.addresses.joined(separator: ",")
let prefixes = ipv6Settings.networkPrefixLengths.map { "\($0)" }.joined(separator: ",")
ovpnLog(.info, title: "IPv6RoutesPre", message: "addresses=[\(addresses)] prefixes=[\(prefixes)] included=\(included) excluded=\(excluded)")
}
if splitType == 1 {
var ipv4IncludedRoutes = [NEIPv4Route]()
guard let splitTunnelSites else {
@@ -195,9 +393,8 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate {
}
}
networkSettings?.ipv4Settings?.includedRoutes = ipv4IncludedRoutes
} else {
if splitTunnelType == 2 {
effectiveSettings.ipv4Settings?.includedRoutes = ipv4IncludedRoutes
} else if splitType == 2 {
var ipv4ExcludedRoutes = [NEIPv4Route]()
var ipv4IncludedRoutes = [NEIPv4Route]()
var ipv6IncludedRoutes = [NEIPv6Route]()
@@ -225,14 +422,418 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate {
destinationAddress: "\(allIPv6.address)",
networkPrefixLength: NSNumber(value: allIPv6.networkPrefixLength)))
}
networkSettings?.ipv4Settings?.includedRoutes = ipv4IncludedRoutes
networkSettings?.ipv6Settings?.includedRoutes = ipv6IncludedRoutes
networkSettings?.ipv4Settings?.excludedRoutes = ipv4ExcludedRoutes
effectiveSettings.ipv4Settings?.includedRoutes = ipv4IncludedRoutes
effectiveSettings.ipv6Settings?.includedRoutes = ipv6IncludedRoutes
effectiveSettings.ipv4Settings?.excludedRoutes = ipv4ExcludedRoutes
} else {
// Full tunnel: rely on adapter-provided routes.
}
if let serverEndpoint,
Self.isIPv4Address(serverEndpoint),
let ipv4Settings = effectiveSettings.ipv4Settings {
let hostMask = "255.255.255.255"
var excluded = ipv4Settings.excludedRoutes ?? []
let alreadyExcluded = excluded.contains {
$0.destinationAddress == serverEndpoint && $0.destinationSubnetMask == hostMask
}
if !alreadyExcluded {
excluded.append(NEIPv4Route(destinationAddress: serverEndpoint, subnetMask: hostMask))
ipv4Settings.excludedRoutes = excluded
ovpnLog(.info, title: "IPv4Routes", message: "excluded remoteAddress=\(serverEndpoint)")
}
} else if let serverEndpoint {
ovpnLog(.info, title: "IPv4Routes", message: "skip explicit remote exclude; non-ip server=\(serverEndpoint)")
}
let localAddr = openVpnLocalAddress
var net30Gateway: String?
if let localAddr, let mask = openVpnLocalMask {
net30Gateway = Self.net30Peer(for: localAddr, mask: mask)
}
var gateway = net30Gateway
if let adapterGateway = openVPNAdapter.connectionInformation?.gatewayIPv4, !adapterGateway.isEmpty {
if let localAddr, adapterGateway == localAddr {
ovpnLog(.info, title: "IPv4Gateway", message: "ignore adapter gateway equal to local address=\(adapterGateway)")
} else if let net30Gateway, net30Gateway != adapterGateway {
ovpnLog(.info, title: "IPv4Gateway", message: "ignore mismatched adapter gateway=\(adapterGateway), using net30 peer=\(net30Gateway)")
} else {
gateway = adapterGateway
}
}
openVpnGatewayAddress = gateway
if let gateway, !gateway.isEmpty {
ovpnLog(.info, title: "IPv4Gateway", message: "gateway=\(gateway)")
}
#if os(macOS)
if splitType == 0, let gateway, !gateway.isEmpty, effectiveSettings.tunnelRemoteAddress != gateway {
let updatedSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: gateway)
updatedSettings.ipv4Settings = effectiveSettings.ipv4Settings
updatedSettings.ipv6Settings = effectiveSettings.ipv6Settings
updatedSettings.dnsSettings = effectiveSettings.dnsSettings
updatedSettings.proxySettings = effectiveSettings.proxySettings
updatedSettings.mtu = effectiveSettings.mtu
effectiveSettings = updatedSettings
ovpnLog(.info, title: "Remote", message: "tunnelRemoteAddress set to gateway=\(gateway) on macOS full-tunnel")
}
#endif
#if os(macOS)
if var ipv4Settings = effectiveSettings.ipv4Settings {
if splitType == 0 {
let hasNet30Mask = ipv4Settings.subnetMasks.contains("255.255.255.252")
if hasNet30Mask {
let normalizedMasks = Array(repeating: "255.255.255.255",
count: ipv4Settings.subnetMasks.count)
let normalized = NEIPv4Settings(addresses: ipv4Settings.addresses,
subnetMasks: normalizedMasks)
normalized.includedRoutes = ipv4Settings.includedRoutes
normalized.excludedRoutes = ipv4Settings.excludedRoutes
if #available(macOS 13.0, *) {
normalized.router = ipv4Settings.router
}
ipv4Settings = normalized
ovpnLog(.info, title: "IPv4Routes", message: "normalized net30 /30 masks to /32 on macOS full-tunnel")
}
if let gateway, !gateway.isEmpty {
if #available(macOS 13.0, *) {
ipv4Settings.router = gateway
ovpnLog(.info, title: "IPv4Routes", message: "set ipv4 router=\(gateway) on macOS full-tunnel")
}
}
var included = ipv4Settings.includedRoutes ?? []
let hasDefault = included.contains {
$0.destinationAddress == "0.0.0.0" && $0.destinationSubnetMask == "0.0.0.0"
}
if hasDefault {
included.removeAll {
$0.destinationAddress == "0.0.0.0" && $0.destinationSubnetMask == "0.0.0.0"
}
}
let hasDef1Low = included.contains {
$0.destinationAddress == "0.0.0.0" && $0.destinationSubnetMask == "128.0.0.0"
}
let hasDef1High = included.contains {
$0.destinationAddress == "128.0.0.0" && $0.destinationSubnetMask == "128.0.0.0"
}
if (hasDefault || openVpnRedirectGatewayDef1) && !(hasDef1Low && hasDef1High) {
if !hasDef1Low {
let route = NEIPv4Route(destinationAddress: "0.0.0.0", subnetMask: "128.0.0.0")
if let gateway, !gateway.isEmpty {
route.gatewayAddress = gateway
}
included.append(route)
}
if !hasDef1High {
let route = NEIPv4Route(destinationAddress: "128.0.0.0", subnetMask: "128.0.0.0")
if let gateway, !gateway.isEmpty {
route.gatewayAddress = gateway
}
included.append(route)
}
ovpnLog(.info, title: "IPv4Routes", message: "ensured def1 routes (/1 + /1) on macOS full-tunnel")
}
if let gateway, !gateway.isEmpty {
included = included.map { route in
let isDef1 =
(route.destinationAddress == "0.0.0.0" && route.destinationSubnetMask == "128.0.0.0") ||
(route.destinationAddress == "128.0.0.0" && route.destinationSubnetMask == "128.0.0.0")
guard isDef1 else { return route }
if route.gatewayAddress == gateway {
return route
}
let updatedRoute = NEIPv4Route(destinationAddress: route.destinationAddress,
subnetMask: route.destinationSubnetMask)
updatedRoute.gatewayAddress = gateway
return updatedRoute
}
ovpnLog(.info, title: "IPv4Routes", message: "set gateway=\(gateway) on macOS def1 routes")
}
ipv4Settings.includedRoutes = included
effectiveSettings.ipv4Settings = ipv4Settings
}
}
#endif
if let ipv4Settings = effectiveSettings.ipv4Settings {
let included = (ipv4Settings.includedRoutes ?? []).map {
let gw = $0.gatewayAddress ?? ""
return "\($0.destinationAddress)/\($0.destinationSubnetMask) gw=\(gw)"
}
let excluded = (ipv4Settings.excludedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationSubnetMask)" }
let addresses = ipv4Settings.addresses.joined(separator: ",")
let masks = ipv4Settings.subnetMasks.joined(separator: ",")
let router: String
#if os(macOS)
if #available(macOS 13.0, *) {
router = ipv4Settings.router ?? ""
} else {
router = ""
}
#else
router = ""
#endif
ovpnLog(.info, title: "IPv4Routes", message: "addresses=[\(addresses)] masks=[\(masks)] router=\(router) included=\(included) excluded=\(excluded)")
} else {
ovpnLog(.error, title: "IPv4Routes", message: "ipv4Settings is nil")
}
if let ipv6Settings = effectiveSettings.ipv6Settings {
let included = (ipv6Settings.includedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationNetworkPrefixLength)" }
let excluded = (ipv6Settings.excludedRoutes ?? []).map { "\($0.destinationAddress)/\($0.destinationNetworkPrefixLength)" }
let addresses = ipv6Settings.addresses.joined(separator: ",")
let prefixes = ipv6Settings.networkPrefixLengths.map { "\($0)" }.joined(separator: ",")
ovpnLog(.info, title: "IPv6Routes", message: "addresses=[\(addresses)] prefixes=[\(prefixes)] included=\(included) excluded=\(excluded)")
}
#if os(macOS)
if effectiveSettings.ipv6Settings != nil {
effectiveSettings.ipv6Settings = nil
ovpnLog(.info, title: "IPv6", message: "cleared ipv6Settings on macOS")
}
#endif
lastOpenVPNSettings = effectiveSettings
// Set the network settings for the current tunneling session.
setTunnelNetworkSettings(networkSettings, completionHandler: completionHandler)
setTunnelNetworkSettings(effectiveSettings) { error in
if let error {
ovpnLog(.error, title: "SetTunnelNetworkSettings", message: error.localizedDescription)
} else {
ovpnLog(.info, title: "SetTunnelNetworkSettings", message: "ok")
}
completionHandler(error)
}
}
private static func extractDnsServers(from config: String) -> [String] {
let lines = config.split(whereSeparator: \.isNewline)
var servers: [String] = []
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("dhcp-option DNS ") {
let parts = trimmed.split(separator: " ")
if let last = parts.last {
servers.append(String(last))
}
}
}
return servers
}
private static func extractRemoteHost(from config: String) -> String? {
let lines = config.split(whereSeparator: \.isNewline)
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("remote ") {
let parts = trimmed.split(separator: " ")
if parts.count >= 2 {
return String(parts[1])
}
}
}
return nil
}
private static func hasRedirectGatewayDef1(in config: String) -> Bool {
let lines = config.split(whereSeparator: \.isNewline)
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("redirect-gateway") {
return trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).contains("def1")
}
}
return false
}
private static func net30Peer(for address: String, mask: String) -> String? {
guard mask == "255.255.255.252" else { return nil }
let parts = address.split(separator: ".")
guard parts.count == 4 else { return nil }
var octets: [Int] = []
for part in parts {
guard let num = Int(part), num >= 0 && num <= 255 else { return nil }
octets.append(num)
}
let ip = (octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]
let network = ip & ~3
let host = ip - network
let peerHost: Int
switch host {
case 1: peerHost = 2
case 2: peerHost = 1
default: return nil
}
let peerIP = network + peerHost
return "\((peerIP >> 24) & 0xff).\((peerIP >> 16) & 0xff).\((peerIP >> 8) & 0xff).\(peerIP & 0xff)"
}
private func logOpenVPNConnectionInfo() {
guard let info = ovpnAdapter?.connectionInformation else { return }
let message = "vpnIPv4=\(info.vpnIPv4 ?? "") gatewayIPv4=\(info.gatewayIPv4 ?? "") serverIP=\(info.serverIP ?? "") tun=\(info.tunName ?? "")"
ovpnLog(.info, title: "ConnInfo", message: message)
}
private static func normalizeInlineBlock(
in config: String,
tag: String,
beginMarkers: [String],
endMarkers: [String]
) -> String {
guard !beginMarkers.isEmpty, !endMarkers.isEmpty else { return config }
var normalizedConfig = config
let openTag = "<\(tag)>"
let closeTag = "</\(tag)>"
var searchStart = normalizedConfig.startIndex
while let openRange = normalizedConfig.range(of: openTag, range: searchStart..<normalizedConfig.endIndex),
let closeRange = normalizedConfig.range(of: closeTag, range: openRange.upperBound..<normalizedConfig.endIndex) {
let rawBody = String(normalizedConfig[openRange.upperBound..<closeRange.lowerBound])
let lines = rawBody
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
var beginIndex: Int?
var endIndex: Int?
for (idx, line) in lines.enumerated() {
if beginIndex == nil,
beginMarkers.contains(where: { line.contains($0) }) {
beginIndex = idx
}
if beginIndex != nil,
endMarkers.contains(where: { line.contains($0) }) {
endIndex = idx
}
}
if let beginIndex,
let endIndex,
endIndex >= beginIndex {
let extracted = lines[beginIndex...endIndex].joined(separator: "\n")
let replacement = "<\(tag)>\n\(extracted)\n</\(tag)>"
normalizedConfig.replaceSubrange(openRange.lowerBound..<closeRange.upperBound, with: replacement)
ovpnLog(.info, title: "ConfigInline", message: "tag=<\(tag)> linesIn=\(lines.count) linesOut=\(endIndex - beginIndex + 1)")
searchStart = normalizedConfig.index(openRange.lowerBound, offsetBy: replacement.count)
} else {
ovpnLog(.error, title: "ConfigInline", message: "tag=<\(tag)> missing markers, keeping original body")
searchStart = closeRange.upperBound
}
}
return normalizedConfig
}
private static func stripUnsupportedOptions(forOpenVPNAdapter config: String) -> String {
let unsupportedTokens: Set<String> = [
"block-ipv6",
"script-security",
"up",
"down",
"resolv-retry",
"persist-key",
"persist-tun",
"compat-mode",
"disable-dco"
]
let inlineBlockTags: Set<String> = [
"ca",
"cert",
"key",
"pkcs12",
"tls-auth",
"tls-crypt",
"tls-crypt-v2",
"secret",
"crl-verify",
"extra-certs"
]
var removed: [String: Int] = [:]
var normalized: [String: Int] = [:]
var output: [String] = []
var activeInlineTag: String?
for rawLine in config.split(whereSeparator: \.isNewline) {
let line = String(rawLine)
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
output.append(line)
continue
}
let trimmedLowercased = trimmed.lowercased()
if let currentInlineTag = activeInlineTag {
output.append(line)
if trimmedLowercased == "</\(currentInlineTag)>" {
activeInlineTag = nil
}
continue
}
if trimmedLowercased.hasPrefix("<"),
trimmedLowercased.hasSuffix(">"),
!trimmedLowercased.hasPrefix("</") {
let tagContent = String(trimmedLowercased.dropFirst().dropLast())
let tagName = tagContent
.split(whereSeparator: { $0 == " " || $0 == "\t" })
.first
.map(String.init) ?? ""
if inlineBlockTags.contains(tagName) {
activeInlineTag = tagName
output.append(line)
continue
}
}
if trimmed.hasPrefix("#") || trimmed.hasPrefix(";") {
output.append(line)
continue
}
let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" })
let token = parts.first.map(String.init)?.lowercased() ?? ""
if trimmedLowercased.hasPrefix("redirect-gateway") || token.hasPrefix("redirect-gateway") {
let hasDef1 = parts.dropFirst().contains { String($0).lowercased().hasPrefix("def1") }
if hasDef1 {
output.append("redirect-gateway def1")
normalized["redirect-gateway", default: 0] += 1
} else {
removed["redirect-gateway", default: 0] += 1
}
continue
}
if let matchedUnsupported = unsupportedTokens.first(where: { token.hasPrefix($0) }) {
removed[matchedUnsupported, default: 0] += 1
continue
}
output.append(line)
}
if !removed.isEmpty {
let summary = removed.keys.sorted().map { "\($0)=\(removed[$0] ?? 0)" }.joined(separator: " ")
ovpnLog(.info, title: "ConfigStrip", message: summary)
}
if !normalized.isEmpty {
let summary = normalized.keys.sorted().map { "\($0)=\(normalized[$0] ?? 0)" }.joined(separator: " ")
ovpnLog(.info, title: "ConfigNormalize", message: summary)
}
return output.joined(separator: "\n")
}
private static func isIPv4Address(_ value: String) -> Bool {
let parts = value.split(separator: ".")
if parts.count != 4 { return false }
for part in parts {
guard let num = Int(part), num >= 0 && num <= 255 else { return false }
}
return true
}
// Process events returned by the OpenVPN library
@@ -250,6 +851,9 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate {
startHandler(nil)
self.startHandler = nil
logOpenVPNConnectionInfo()
refreshOpenVPNSettingsAfterConnect()
case .disconnected:
guard let stopHandler = stopHandler else { return }
@@ -292,4 +896,41 @@ extension PacketTunnelProvider: OpenVPNAdapterDelegate {
// Handle log messages
ovpnLog(.info, message: logMessage)
}
func openVPNAdapterDidReceiveClockTick(_ openVPNAdapter: OpenVPNAdapter) {
let now = Date()
if now.timeIntervalSince(lastOpenVPNStatsLogTime) < 5 {
return
}
lastOpenVPNStatsLogTime = now
let transport = openVPNAdapter.transportStatistics
let iface = openVPNAdapter.interfaceStatistics
let transportLine = "transport bytesIn=\(transport.bytesIn) bytesOut=\(transport.bytesOut) packetsIn=\(transport.packetsIn) packetsOut=\(transport.packetsOut)"
let ifaceLine = "iface bytesIn=\(iface.bytesIn) bytesOut=\(iface.bytesOut) packetsIn=\(iface.packetsIn) packetsOut=\(iface.packetsOut) errorsIn=\(iface.errorsIn) errorsOut=\(iface.errorsOut)"
ovpnLog(.info, title: "Stats", message: "\(transportLine) | \(ifaceLine)")
}
private func refreshOpenVPNSettingsAfterConnect() {
let localAddr = openVpnLocalAddress
var net30Gateway: String?
if let localAddr, let mask = openVpnLocalMask {
net30Gateway = Self.net30Peer(for: localAddr, mask: mask)
}
var gateway = net30Gateway
if let adapterGateway = ovpnAdapter?.connectionInformation?.gatewayIPv4, !adapterGateway.isEmpty {
if let localAddr, adapterGateway == localAddr {
ovpnLog(.info, title: "IPv4Gateway", message: "post-connect ignoring adapter gateway equal to local address=\(adapterGateway)")
} else if let net30Gateway, net30Gateway != adapterGateway {
ovpnLog(.info, title: "IPv4Gateway", message: "post-connect keeping net30 peer=\(net30Gateway), adapter gateway=\(adapterGateway)")
} else {
gateway = adapterGateway
}
}
guard let gateway, !gateway.isEmpty else { return }
openVpnGatewayAddress = gateway
ovpnLog(.info, title: "IPv4Gateway", message: "post-connect gateway=\(gateway)")
}
}

View File

@@ -1,3 +1,4 @@
import Darwin
import Foundation
import NetworkExtension
@@ -6,6 +7,7 @@ enum XrayErrors: Error {
case xrayConfigIsWrong
case cantSaveXrayConfig
case cantParseListenAndPort
case cantAcquireLocalPort
case cantSaveHevSocksConfig
}
@@ -21,6 +23,80 @@ extension Constants {
}
extension PacketTunnelProvider {
/// TCP port chosen by the OS on IPv6 loopback (::1), matching inbound listen address.
private func acquireFreeLocalPort() throws -> Int {
let fd = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP)
guard fd != -1 else {
throw XrayErrors.cantAcquireLocalPort
}
defer { close(fd) }
var reuse: Int32 = 1
_ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout<Int32>.size))
var addr = sockaddr_in6()
addr.sin6_len = UInt8(MemoryLayout<sockaddr_in6>.size)
addr.sin6_family = sa_family_t(AF_INET6)
addr.sin6_port = in_port_t(0).bigEndian
addr.sin6_addr = in6addr_loopback
addr.sin6_scope_id = 0
let bindResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { p in
bind(fd, p, socklen_t(MemoryLayout<sockaddr_in6>.size))
}
}
guard bindResult == 0 else {
throw XrayErrors.cantAcquireLocalPort
}
var bound = sockaddr_in6()
var len = socklen_t(MemoryLayout<sockaddr_in6>.size)
let gr = withUnsafeMutablePointer(to: &bound) { p in
p.withMemoryRebound(to: sockaddr.self, capacity: 1) { bp in
getsockname(fd, bp, &len)
}
}
guard gr == 0 else {
throw XrayErrors.cantAcquireLocalPort
}
return Int(bound.sin6_port.byteSwapped)
}
private func applyXraySplitTunnel(_ xrayConfig: XrayConfig,
settings: NEPacketTunnelNetworkSettings) {
guard let splitTunnelType = xrayConfig.splitTunnelType else {
return
}
guard let splitTunnelSites = xrayConfig.splitTunnelSites else {
xrayLog(.error, message: "Split tunnel sites are not set")
return
}
if splitTunnelType == 1 {
var ipv4IncludedRoutes = [NEIPv4Route]()
for allowedIPString in splitTunnelSites {
if let allowedIP = IPAddressRange(from: allowedIPString) {
ipv4IncludedRoutes.append(NEIPv4Route(
destinationAddress: "\(allowedIP.address)",
subnetMask: "\(allowedIP.subnetMask())"))
}
}
settings.ipv4Settings?.includedRoutes = ipv4IncludedRoutes
} else if splitTunnelType == 2 {
var ipv4ExcludedRoutes = [NEIPv4Route]()
for excludedIPString in splitTunnelSites {
if let excludedIP = IPAddressRange(from: excludedIPString) {
ipv4ExcludedRoutes.append(NEIPv4Route(
destinationAddress: "\(excludedIP.address)",
subnetMask: "\(excludedIP.subnetMask())"))
}
}
settings.ipv4Settings?.excludedRoutes = ipv4ExcludedRoutes
}
}
func startXray(completionHandler: @escaping (Error?) -> Void) {
// Xray configuration
@@ -72,6 +148,7 @@ extension PacketTunnelProvider {
settings.dnsSettings = !dnsArray.isEmpty
? NEDNSSettings(servers: dnsArray)
: NEDNSSettings(servers: ["1.1.1.1"])
applyXraySplitTunnel(xrayConfig, settings: settings)
let xrayConfigData = xrayConfig.config.data(using: .utf8)
@@ -90,14 +167,11 @@ extension PacketTunnelProvider {
return
}
let port = 10808
let port = try acquireFreeLocalPort()
let address = "::1"
if var inboundsArray = jsonDict["inbounds"] as? [[String: Any]], !inboundsArray.isEmpty {
inboundsArray[0]["port"] = port
inboundsArray[0]["listen"] = address
jsonDict["inbounds"] = inboundsArray
}
// Extract existing SOCKS5 credentials or generate new ones per session.
let socksCredentials = ensureInboundAuth(jsonDict: &jsonDict, port: port, address: address)
let updatedData = try JSONSerialization.data(withJSONObject: jsonDict, options: [])
@@ -120,6 +194,8 @@ extension PacketTunnelProvider {
self?.setupAndRunTun2socks(configData: updatedData,
address: address,
port: port,
username: socksCredentials.username,
password: socksCredentials.password,
completionHandler: completionHandler)
}
}
@@ -144,6 +220,62 @@ extension PacketTunnelProvider {
}
}
private struct SocksCredentials {
let username: String
let password: String
}
private func indexOfSocksInbound(in inboundsArray: [[String: Any]]) -> Int? {
for (i, inbound) in inboundsArray.enumerated() {
guard let proto = inbound["protocol"] as? String else { continue }
if proto.caseInsensitiveCompare("socks") == .orderedSame {
return i
}
}
return nil
}
// Returns existing SOCKS5 credentials from the inbound config, or generates and injects
// new random ones. Also sets port and address on the socks inbound entry.
private func ensureInboundAuth(jsonDict: inout [String: Any], port: Int, address: String) -> SocksCredentials {
var inboundsArray = jsonDict["inbounds"] as? [[String: Any]] ?? []
if let socksIdx = indexOfSocksInbound(in: inboundsArray) {
var inbound = inboundsArray[socksIdx]
inbound["port"] = port
inbound["listen"] = address
var settings = inbound["settings"] as? [String: Any] ?? [:]
if let accounts = settings["accounts"] as? [[String: Any]],
let first = accounts.first,
let user = first["user"] as? String, !user.isEmpty,
let pass = first["pass"] as? String, !pass.isEmpty {
// Re-use existing credentials, but always enforce auth mode in case the
// imported config had accounts but auth: "noauth" (or no auth field).
settings["auth"] = "password"
inbound["settings"] = settings
inboundsArray[socksIdx] = inbound
jsonDict["inbounds"] = inboundsArray
return SocksCredentials(username: user, password: pass)
}
// Generate new random credentials for this session
let user = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased().prefix(16)
let pass = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
settings["auth"] = "password"
settings["accounts"] = [["user": String(user), "pass": pass]]
inbound["settings"] = settings
inboundsArray[socksIdx] = inbound
jsonDict["inbounds"] = inboundsArray
return SocksCredentials(username: String(user), password: pass)
}
// Fallback: no socks inbound generate credentials but can't inject
let user = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased().prefix(16)
let pass = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
return SocksCredentials(username: String(user), password: pass)
}
private func setupAndStartXray(configData: Data,
completionHandler: @escaping (Error?) -> Void) {
let path = Constants.cachesDirectory.appendingPathComponent("config.json", isDirectory: false).path
@@ -175,6 +307,8 @@ extension PacketTunnelProvider {
private func setupAndRunTun2socks(configData: Data,
address: String,
port: Int,
username: String,
password: String,
completionHandler: @escaping (Error?) -> Void) {
let config = """
tunnel:
@@ -182,6 +316,8 @@ extension PacketTunnelProvider {
socks5:
port: \(port)
address: \(address)
username: \(username)
password: \(password)
udp: 'udp'
misc:
task-stack-size: 20480

View File

@@ -41,13 +41,26 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
var ovpnAdapter: OpenVPNAdapter?
private lazy var openVPNPacketFlowAdapter = PacketTunnelFlowAdapter(flow: packetFlow)
private let pathMonitorQueue = DispatchQueue(label: Constants.processQueueName + ".path-monitor")
private let networkChangeQueue = DispatchQueue(label: Constants.processQueueName + ".network-change")
private let pathMonitor = NWPathMonitor()
private var didReceiveInitialPathUpdate = false
private var currentPath: Network.NWPath?
private var currentPathSignature: String?
private var pendingOpenVPNReconnectWorkItem: DispatchWorkItem?
private var pendingNetworkChangeWorkItem: DispatchWorkItem?
private var isApplyingNetworkChange = false
private var lastOpenVPNReachabilityStatus: OpenVPNReachabilityStatus?
var splitTunnelType: Int?
var splitTunnelSites: [String]?
var openVpnDnsServers: [String] = []
var openVpnRemoteAddress: String?
var openVpnRedirectGatewayDef1 = false
var openVpnLocalAddress: String?
var openVpnLocalMask: String?
var openVpnGatewayAddress: String?
var lastOpenVPNSettings: NEPacketTunnelNetworkSettings?
var lastOpenVPNStatsLogTime = Date.distantPast
let vpnReachability = OpenVPNReachability()
@@ -78,14 +91,22 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
guard hasMeaningfulChange, let proto = self.protoType else { return }
// WireGuard/AWG manages network changes internally; avoid restarting the tunnel here.
if proto == .wireguard {
// WireGuard/AWG and OpenVPN manages network changes internally in its own adapter.
if proto == .wireguard || proto == .openvpn {
return
}
DispatchQueue.main.async {
self.handle(networkChange: path) { _ in }
if proto == .openvpn {
self.scheduleOpenVPNReconnect(reason: "NWPath changed")
return
}
if self.isApplyingNetworkChange || self.reasserting {
xrayLog(.debug, message: "Ignoring path change while xray restart is in progress")
return
}
self.scheduleNetworkChangeHandling(for: proto, path: path)
}
pathMonitor.start(queue: pathMonitorQueue)
@@ -179,9 +200,26 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
let errorNotifier = ErrorNotifier(activationAttemptId: activationAttemptId)
neLog(.info, message: "Start tunnel")
if let vpnProto = protocolConfiguration as? NEVPNProtocol {
if #available(iOS 14.0, macOS 11.0, *) {
var details = "includeAllNetworks=\(vpnProto.includeAllNetworks)"
if #available(iOS 14.2, macOS 11.0, *) {
details += " excludeLocalNetworks=\(vpnProto.excludeLocalNetworks)"
}
neLog(.info, title: "Protocol", message: details)
}
}
if let protocolConfiguration = protocolConfiguration as? NETunnelProviderProtocol {
let providerConfiguration = protocolConfiguration.providerConfiguration
let providerKeys = providerConfiguration?.keys.sorted().joined(separator: ",") ?? ""
var protocolDetails = "bundleId=\(protocolConfiguration.providerBundleIdentifier ?? "") keys=[\(providerKeys)]"
if let ovpnData = providerConfiguration?[Constants.ovpnConfigKey] as? Data {
let preview = String(decoding: ovpnData.prefix(512), as: UTF8.self)
protocolDetails += " ovpnBytes=\(ovpnData.count) ovpnPreview=\(preview)"
}
neLog(.info, title: "Protocol", message: protocolDetails)
if (providerConfiguration?[Constants.ovpnConfigKey] as? Data) != nil {
protoType = .openvpn
} else if (providerConfiguration?[Constants.wireGuardConfigKey] as? Data) != nil {
@@ -197,6 +235,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
return
}
cancelPendingOpenVPNReconnect()
cancelPendingNetworkChangeHandling()
didReceiveInitialPathUpdate = false
updateActiveInterfaceIndexForCurrentPath()
@@ -215,6 +255,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
cancelPendingOpenVPNReconnect()
cancelPendingNetworkChangeHandling()
guard let protoType else {
completionHandler()
return
@@ -259,9 +302,111 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
}
private func handle(networkChange changePath: Network.NWPath, completion: @escaping (Error?) -> Void) {
guard protoType == .xray else {
updateActiveInterfaceIndex(for: changePath)
completion(nil)
return
}
updateActiveInterfaceIndex(for: changePath)
wg_log(.info, message: "Tunnel restarted.")
startTunnel(options: nil, completionHandler: completion)
reasserting = true
xrayLog(.info, message: "Applying network change to xray tunnel")
stopXray { }
startXray { [weak self] error in
self?.reasserting = false
completion(error)
}
}
private func scheduleNetworkChangeHandling(for proto: TunnelProtoType, path: Network.NWPath) {
guard proto == .xray else { return }
pendingNetworkChangeWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.pendingNetworkChangeWorkItem = nil
if self.isApplyingNetworkChange || self.reasserting {
xrayLog(.debug, message: "Skipping network change while restart is already in progress")
return
}
self.isApplyingNetworkChange = true
DispatchQueue.main.async {
self.handle(networkChange: path) { [weak self] _ in
self?.networkChangeQueue.async {
self?.isApplyingNetworkChange = false
}
}
}
}
pendingNetworkChangeWorkItem = workItem
networkChangeQueue.asyncAfter(deadline: .now() + 1.0, execute: workItem)
}
private func scheduleOpenVPNReconnect(reason: String) {
guard protoType == .openvpn else { return }
pendingOpenVPNReconnectWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.pendingOpenVPNReconnectWorkItem = nil
guard self.protoType == .openvpn else { return }
if self.reasserting {
ovpnLog(.debug, message: "Skipping OpenVPN reconnect while session is already reasserting")
return
}
DispatchQueue.main.async { [weak self] in
guard let self else { return }
guard !self.reasserting else {
ovpnLog(.debug, message: "Skipping OpenVPN reconnect while session is already reasserting")
return
}
ovpnLog(.info, message: "\(reason), reconnecting OpenVPN session")
self.ovpnAdapter?.reconnect(afterTimeInterval: 1)
}
}
pendingOpenVPNReconnectWorkItem = workItem
networkChangeQueue.asyncAfter(deadline: .now() + 1.0, execute: workItem)
}
func handleOpenVPNReachabilityChange(_ status: OpenVPNReachabilityStatus) {
defer { lastOpenVPNReachabilityStatus = status }
guard let previousStatus = lastOpenVPNReachabilityStatus else {
return
}
guard previousStatus != status else {
return
}
switch status {
case .reachableViaWiFi, .reachableViaWWAN:
scheduleOpenVPNReconnect(reason: "Reachability changed")
default:
break
}
}
private func cancelPendingOpenVPNReconnect() {
pendingOpenVPNReconnectWorkItem?.cancel()
pendingOpenVPNReconnectWorkItem = nil
lastOpenVPNReachabilityStatus = nil
}
private func cancelPendingNetworkChangeHandling() {
pendingNetworkChangeWorkItem?.cancel()
pendingNetworkChangeWorkItem = nil
isApplyingNetworkChange = false
}
}
@@ -271,8 +416,14 @@ private extension PacketTunnelProvider {
signatureComponents.append(path.isExpensive ? "exp" : "noexp")
signatureComponents.append(path.isConstrained ? "con" : "nocon")
let preferredTypes: [NWInterface.InterfaceType] = [.wiredEthernet, .wifi, .cellular, .loopback, .other]
let sortedInterfaces = path.availableInterfaces.sorted { lhs, rhs in
// Ignore loopback and tunnel-style `.other` interfaces so Xray does not
// react to its own utun lifecycle as if the physical uplink changed.
let preferredTypes: [NWInterface.InterfaceType] = [.wiredEthernet, .wifi, .cellular]
let externalInterfaces = path.availableInterfaces.filter { interface in
interface.type == .wiredEthernet || interface.type == .wifi || interface.type == .cellular
}
let sortedInterfaces = externalInterfaces.sorted { lhs, rhs in
if lhs.type == rhs.type {
return lhs.index < rhs.index
}
@@ -293,8 +444,8 @@ private extension PacketTunnelProvider {
case .wiredEthernet: typeName = "ethernet"
case .wifi: typeName = "wifi"
case .cellular: typeName = "cellular"
case .loopback: typeName = "loopback"
case .other: typeName = "other"
case .loopback, .other:
continue
@unknown default: typeName = "unknown"
}
signatureComponents.append("\(typeName):\(interface.index)")
@@ -323,6 +474,8 @@ extension WireGuardLogLevel {
final class PacketTunnelFlowAdapter: NSObject, OpenVPNAdapterPacketFlow {
private let flow: NEPacketTunnelFlow
private var readLogCounter = 0
private var writeLogCounter = 0
init(flow: NEPacketTunnelFlow) {
self.flow = flow
@@ -331,15 +484,98 @@ final class PacketTunnelFlowAdapter: NSObject, OpenVPNAdapterPacketFlow {
@objc(readPacketsWithCompletionHandler:)
func readPackets(completionHandler: @escaping ([Data], [NSNumber]) -> Void) {
flow.readPackets(completionHandler: completionHandler)
flow.readPackets { packets, protocols in
#if os(macOS)
if self.readLogCounter < 20, let firstPacket = packets.first, let firstProtocol = protocols.first {
let prefix = firstPacket.prefix(12).map { String(format: "%02x", $0) }.joined()
let header = Self.describePacketHeader(firstPacket)
ovpnLog(.info, title: "FlowRead", message: "count=\(packets.count) proto0=\(firstProtocol) len0=\(firstPacket.count) prefix=\(prefix) \(header)")
self.readLogCounter += 1
}
#endif
completionHandler(packets, protocols)
}
}
@objc(writePackets:withProtocols:)
func writePackets(_ packets: [Data], withProtocols protocols: [NSNumber]) -> Bool {
flow.writePackets(packets, withProtocols: protocols)
#if os(macOS)
if writeLogCounter < 20, let firstPacket = packets.first, let firstProtocol = protocols.first {
let prefix = firstPacket.prefix(12).map { String(format: "%02x", $0) }.joined()
let header = Self.describePacketHeader(firstPacket)
ovpnLog(.info, title: "FlowWrite", message: "count=\(packets.count) proto0=\(firstProtocol) len0=\(firstPacket.count) prefix=\(prefix) \(header)")
writeLogCounter += 1
}
#endif
return flow.writePackets(packets, withProtocols: protocols)
}
private static func describePacketHeader(_ packet: Data) -> String {
guard let versionNibble = packet.first.map({ Int($0 >> 4) }) else {
return "ip=unknown"
}
if versionNibble == 4, packet.count >= 20 {
let ihl = Int(packet[0] & 0x0f) * 4
guard ihl >= 20, packet.count >= ihl else {
return "ip=ipv4 malformed"
}
let proto = packet[9]
let src = "\(packet[12]).\(packet[13]).\(packet[14]).\(packet[15])"
let dst = "\(packet[16]).\(packet[17]).\(packet[18]).\(packet[19])"
let l4Offset = ihl
let ports: String
if (proto == 6 || proto == 17) && packet.count >= l4Offset + 4 {
let srcPort = (UInt16(packet[l4Offset]) << 8) | UInt16(packet[l4Offset + 1])
let dstPort = (UInt16(packet[l4Offset + 2]) << 8) | UInt16(packet[l4Offset + 3])
ports = "sport=\(srcPort) dport=\(dstPort)"
} else {
ports = "sport=- dport=-"
}
let protoName: String
switch proto {
case 1: protoName = "ICMP"
case 6: protoName = "TCP"
case 17: protoName = "UDP"
default: protoName = "P\(proto)"
}
return "ip=ipv4 src=\(src) dst=\(dst) proto=\(protoName) \(ports)"
}
if versionNibble == 6, packet.count >= 40 {
let proto = packet[6]
func hex16(_ start: Int) -> String {
let value = (UInt16(packet[start]) << 8) | UInt16(packet[start + 1])
return String(format: "%x", value)
}
let src = stride(from: 8, to: 24, by: 2).map(hex16).joined(separator: ":")
let dst = stride(from: 24, to: 40, by: 2).map(hex16).joined(separator: ":")
let l4Offset = 40
let ports: String
if (proto == 6 || proto == 17) && packet.count >= l4Offset + 4 {
let srcPort = (UInt16(packet[l4Offset]) << 8) | UInt16(packet[l4Offset + 1])
let dstPort = (UInt16(packet[l4Offset + 2]) << 8) | UInt16(packet[l4Offset + 3])
ports = "sport=\(srcPort) dport=\(dstPort)"
} else {
ports = "sport=- dport=-"
}
let protoName: String
switch proto {
case 58: protoName = "ICMPv6"
case 6: protoName = "TCP"
case 17: protoName = "UDP"
default: protoName = "P\(proto)"
}
return "ip=ipv6 src=\(src) dst=\(dst) proto=\(protoName) \(ports)"
}
return "ip=v\(versionNibble) len=\(packet.count)"
}
}
extension NEPacketTunnelFlow: OpenVPNAdapterPacketFlow {}
extension NEProviderStopReason {
var amneziaDescription: String {
switch self {

View File

@@ -0,0 +1,178 @@
import Foundation
import StoreKit
@available(iOS 15.0, macOS 12.0, *)
@objcMembers
public class StoreKit2Helper: NSObject {
public static let shared = StoreKit2Helper()
private static let errorDomain = "StoreKit2Helper"
private struct EntitlementInfo {
let transactionId: UInt64
let originalTransactionId: UInt64
let productId: String
let purchaseDate: Date
var dictionary: NSDictionary {
[
"transactionId": String(transactionId),
"originalTransactionId": String(originalTransactionId),
"productId": productId
]
}
}
public func fetchCurrentEntitlements(completion: @escaping (Bool, [NSDictionary]?, NSError?) -> Void) {
Task { @MainActor in
do {
try await AppStore.sync()
var entitlements: [EntitlementInfo] = []
for await result in Transaction.currentEntitlements {
switch result {
case .verified(let transaction):
entitlements.append(EntitlementInfo(transactionId: transaction.id,
originalTransactionId: transaction.originalID,
productId: transaction.productID,
purchaseDate: transaction.purchaseDate))
case .unverified(_, let error):
print("[IAP][StoreKit2] Unverified transaction skipped: \(error.localizedDescription)")
}
}
let sortedEntitlements = entitlements.sorted { lhs, rhs in
if lhs.purchaseDate != rhs.purchaseDate {
return lhs.purchaseDate > rhs.purchaseDate
}
return lhs.transactionId > rhs.transactionId
}.map { $0.dictionary }
completion(true, sortedEntitlements, nil)
} catch {
completion(false, nil, error as NSError)
}
}
}
public func purchaseProduct(productIdentifier: String, completion: @escaping (Bool, String?, String?, String?, NSError?) -> Void) {
Task {
do {
let products = try await Product.products(for: [productIdentifier])
guard let product = products.first else {
completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil,
error: makeError(code: 0, description: "Product not found"))
return
}
let result = try await product.purchase()
switch result {
case .success(let verification):
switch verification {
case .verified(let transaction):
await transaction.finish()
completePurchase(completion: completion, success: true, transactionId: String(transaction.id),
productId: transaction.productID, originalTransactionId: String(transaction.originalID), error: nil)
case .unverified(_, let error):
completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil,
error: error as NSError)
}
case .userCancelled:
completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil,
error: makeError(code: 1, description: "Purchase cancelled"))
case .pending:
completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil,
error: makeError(code: 2, description: "Purchase pending"))
@unknown default:
completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil,
error: makeError(code: 3, description: "Unknown purchase result"))
}
} catch {
completePurchase(completion: completion, success: false, transactionId: nil, productId: nil, originalTransactionId: nil,
error: error as NSError)
}
}
}
private func storefrontCurrencyCode(for product: Product) -> String {
product.priceFormatStyle.locale.currencyCode ?? ""
}
private func subscriptionBillingMonths(_ period: Product.SubscriptionPeriod) -> Double {
let periodValue = Double(period.value)
switch period.unit {
case .day:
return periodValue / 30.0
case .week:
return periodValue * 7.0 / 30.0
case .month:
return periodValue
case .year:
return periodValue * 12.0
@unknown default:
return periodValue
}
}
public func fetchProducts(identifiers: Set<String>, completion: @escaping ([NSDictionary], [String], NSError?) -> Void) {
Task {
do {
let products = try await Product.products(for: identifiers)
let productDicts = products.map { product in productDictionary(for: product) }
let fetchedIds = Set(products.map { $0.id })
let invalidIdentifiers = identifiers.filter { !fetchedIds.contains($0) }
DispatchQueue.main.async { completion(productDicts, Array(invalidIdentifiers), nil) }
} catch {
DispatchQueue.main.async { completion([], Array(identifiers), error as NSError) }
}
}
}
private func makeError(code: Int, description: String) -> NSError {
NSError(domain: Self.errorDomain, code: code, userInfo: [NSLocalizedDescriptionKey: description])
}
private func completePurchase(completion: @escaping (Bool, String?, String?, String?, NSError?) -> Void,
success: Bool,
transactionId: String?,
productId: String?,
originalTransactionId: String?,
error: NSError?) {
DispatchQueue.main.async {
completion(success, transactionId, productId, originalTransactionId, error)
}
}
private func productDictionary(for product: Product) -> NSDictionary {
let currencyCode = storefrontCurrencyCode(for: product)
var productData: [String: Any] = [
"productId": product.id,
"title": product.displayName,
"description": product.description,
"price": "\(product.price)",
"displayPrice": product.displayPrice,
"currencyCode": currencyCode,
"priceAmount": NSDecimalNumber(decimal: product.price).doubleValue
]
if let subscription = product.subscription {
let billingMonths = subscriptionBillingMonths(subscription.subscriptionPeriod)
productData["subscriptionBillingMonths"] = billingMonths
if let perMonthPrice = displayPricePerMonth(for: product, billingMonths: billingMonths, currencyCode: currencyCode) {
productData["displayPricePerMonth"] = perMonthPrice
}
}
return productData as NSDictionary
}
private func displayPricePerMonth(for product: Product, billingMonths: Double, currencyCode: String) -> String? {
if billingMonths <= 1e-6 {
return nil
}
let perMonthPrice = product.price / Decimal(billingMonths)
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = product.priceFormatStyle.locale
if !currencyCode.isEmpty {
formatter.currencyCode = currencyCode
}
return formatter.string(from: NSDecimalNumber(decimal: perMonthPrice))
}
}

View File

@@ -4,27 +4,20 @@
#import "StoreKitController.h"
#import <StoreKit/StoreKit.h>
#import <AmneziaVPN-Swift.h>
#include <QtCore/QDebug>
#include <QtCore/QString>
API_AVAILABLE(ios(15.0), macos(12.0))
@interface StoreKitController () <SKProductsRequestDelegate, SKPaymentTransactionObserver>
@property (nonatomic, copy) void (^purchaseCompletion)(BOOL success,
NSString *_Nullable transactionId,
NSString *_Nullable productId,
NSString *_Nullable originalTransactionId,
NSError *_Nullable error);
@property (nonatomic, copy) void (^restoreCompletion)(BOOL success,
NSArray<NSDictionary *> *_Nullable restoredTransactions,
NSError *_Nullable error);
@property (nonatomic, copy) void (^productsFetchCompletion)(NSArray<NSDictionary *> *products,
NSArray<NSString *> *invalidIdentifiers,
NSError *_Nullable error);
@property (nonatomic, strong) SKProductsRequest *productsRequest;
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *restoredTransactions;
@end
namespace
{
QString toQString(NSString *value)
{
return QString::fromUtf8((value ?: @"").UTF8String);
}
}
API_AVAILABLE(ios(15.0), macos(12.0))
@implementation StoreKitController
+ (instancetype)sharedInstance
@@ -42,17 +35,9 @@ API_AVAILABLE(ios(15.0), macos(12.0))
- (instancetype)init API_AVAILABLE(ios(15.0), macos(12.0))
{
self = [super init];
if (self) {
[[SKPaymentQueue defaultQueue] addTransactionObserver:self];
}
return self;
}
- (void)dealloc
{
[[SKPaymentQueue defaultQueue] removeTransactionObserver:self];
}
- (void)purchaseProduct:(NSString *)productIdentifier
completion:(void (^)(BOOL success,
NSString *_Nullable transactionId,
@@ -60,41 +45,48 @@ API_AVAILABLE(ios(15.0), macos(12.0))
NSString *_Nullable originalTransactionId,
NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0))
{
self.purchaseCompletion = completion;
qInfo().noquote() << "[IAP][StoreKit] Starting purchase for" << QString::fromUtf8(productIdentifier.UTF8String);
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[self performPurchaseAsync:productIdentifier];
});
}
- (void)performPurchaseAsync:(NSString *)productIdentifier API_AVAILABLE(ios(15.0), macos(12.0))
{
dispatch_async(dispatch_get_main_queue(), ^{
@try {
SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:[NSSet setWithObject:productIdentifier]];
request.delegate = self;
[request start];
} @catch (NSException *exception) {
NSError *error = [NSError errorWithDomain:@"StoreKitController"
code:1
userInfo:@{ NSLocalizedDescriptionKey : exception.reason ?: @"Purchase failed" }];
if (self.purchaseCompletion) {
self.purchaseCompletion(NO, nil, nil, nil, error);
self.purchaseCompletion = nil;
}
qInfo().noquote() << "[IAP][StoreKit2] Starting purchase for" << QString::fromUtf8(productIdentifier.UTF8String);
[[StoreKit2Helper shared] purchaseProductWithProductIdentifier:productIdentifier
completion:^(BOOL success,
NSString *transactionId,
NSString *productId,
NSString *originalTransactionId,
NSError *error) {
if (success) {
qInfo().noquote() << "[IAP][StoreKit2] Purchase success. transactionId =" << toQString(transactionId)
<< "originalTransactionId =" << toQString(originalTransactionId) << "productId =" << toQString(productId);
} else if (error) {
qWarning().noquote() << "[IAP][StoreKit2] Purchase failed:" << toQString(error.localizedDescription);
}
});
if (completion) {
completion(success, transactionId, productId, originalTransactionId, error);
}
}];
}
- (void)restorePurchasesWithCompletion:(void (^)(BOOL success,
NSArray<NSDictionary *> *_Nullable restoredTransactions,
NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0))
{
self.restoreCompletion = completion;
self.restoredTransactions = [NSMutableArray array];
[[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
[[StoreKit2Helper shared] fetchCurrentEntitlementsWithCompletion:^(BOOL success,
NSArray<NSDictionary *> *entitlements,
NSError *error) {
if (success) {
qInfo().noquote() << "[IAP][StoreKit2] currentEntitlements returned"
<< (int)(entitlements ? entitlements.count : 0) << "active entitlements";
for (NSDictionary *entitlement in entitlements) {
qInfo().noquote() << "[IAP][StoreKit2] Active entitlement:"
<< "transactionId=" << toQString(entitlement[@"transactionId"])
<< "originalTransactionId=" << toQString(entitlement[@"originalTransactionId"])
<< "productId=" << toQString(entitlement[@"productId"]);
}
} else {
qWarning().noquote() << "[IAP][StoreKit2] fetchCurrentEntitlements failed:" << toQString(error.localizedDescription);
}
if (completion) {
completion(success, entitlements, error);
}
}];
}
- (void)fetchProductsWithIdentifiers:(NSSet<NSString *> *)productIdentifiers
@@ -102,163 +94,21 @@ API_AVAILABLE(ios(15.0), macos(12.0))
NSArray<NSString *> *invalidIdentifiers,
NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0))
{
self.productsFetchCompletion = completion;
self.productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:productIdentifiers];
self.productsRequest.delegate = self;
[self.productsRequest start];
}
#pragma mark - SKProductsRequestDelegate / SKRequestDelegate
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response
{
if (self.purchaseCompletion) {
SKProduct *product = response.products.firstObject;
if (!product) {
NSError *error = [NSError errorWithDomain:@"StoreKitController"
code:0
userInfo:@{ NSLocalizedDescriptionKey : @"Product not found" }];
self.purchaseCompletion(NO, nil, nil, nil, error);
self.purchaseCompletion = nil;
self.productsRequest = nil;
return;
}
NSString *currencyCode = [product.priceLocale objectForKey:NSLocaleCurrencyCode] ?: @"";
NSString *priceString = [product.price stringValue] ?: @"";
qInfo().noquote() << "[IAP][StoreKit] Received product" << QString::fromUtf8(product.productIdentifier.UTF8String)
<< "price=" << QString::fromUtf8(priceString.UTF8String)
<< "currency=" << QString::fromUtf8(currencyCode.UTF8String);
SKPayment *payment = [SKPayment paymentWithProduct:product];
[[SKPaymentQueue defaultQueue] addPayment:payment];
self.productsRequest = nil;
return;
}
if (self.productsFetchCompletion) {
NSMutableArray<NSDictionary *> *productDicts = [NSMutableArray array];
for (SKProduct *p in response.products) {
NSDictionary *productDict = @{
@"productId": p.productIdentifier,
@"title": p.localizedTitle,
@"description": p.localizedDescription,
@"price": p.price.stringValue,
@"currencyCode": [p.priceLocale objectForKey:NSLocaleCurrencyCode] ?: @""
};
[productDicts addObject:productDict];
NSString *productCurrency = [p.priceLocale objectForKey:NSLocaleCurrencyCode] ?: @"";
NSString *productPrice = [p.price stringValue] ?: @"";
qInfo().noquote() << "[IAP][StoreKit] Fetched product info" << QString::fromUtf8(p.productIdentifier.UTF8String)
<< "price=" << QString::fromUtf8(productPrice.UTF8String)
<< "currency=" << QString::fromUtf8(productCurrency.UTF8String);
}
self.productsFetchCompletion(productDicts, response.invalidProductIdentifiers, nil);
self.productsFetchCompletion = nil;
self.productsRequest = nil;
return;
}
}
- (void)request:(SKRequest *)request didFailWithError:(NSError *)error
{
if (self.purchaseCompletion) {
self.purchaseCompletion(NO, nil, nil, nil, error);
self.purchaseCompletion = nil;
}
if (self.productsFetchCompletion) {
self.productsFetchCompletion(@[], @[], error);
self.productsFetchCompletion = nil;
}
self.productsRequest = nil;
}
#pragma mark - SKPaymentTransactionObserver
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions
{
for (SKPaymentTransaction *transaction in transactions) {
switch (transaction.transactionState) {
case SKPaymentTransactionStatePurchased: {
NSString *originalTransactionId = transaction.originalTransaction.transactionIdentifier ?: transaction.transactionIdentifier;
qInfo().noquote() << "[IAP][StoreKit] Transaction purchased" << QString::fromUtf8(transaction.transactionIdentifier.UTF8String)
<< "original=" << QString::fromUtf8((originalTransactionId ?: @"").UTF8String)
<< "product=" << QString::fromUtf8(transaction.payment.productIdentifier.UTF8String);
if (self.purchaseCompletion) {
self.purchaseCompletion(YES,
transaction.transactionIdentifier,
transaction.payment.productIdentifier,
originalTransactionId,
nil);
self.purchaseCompletion = nil;
[[StoreKit2Helper shared] fetchProductsWithIdentifiers:productIdentifiers
completion:^(NSArray<NSDictionary *> *products,
NSArray<NSString *> *invalidIdentifiers,
NSError *error) {
if (!error) {
for (NSDictionary *productInfo in products) {
qInfo().noquote() << "[IAP][StoreKit2] Fetched product info" << toQString(productInfo[@"productId"])
<< "price=" << toQString(productInfo[@"price"])
<< "currency=" << toQString(productInfo[@"currencyCode"]);
}
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
break;
}
case SKPaymentTransactionStateFailed:
qInfo().noquote() << "[IAP][StoreKit] Transaction failed" << QString::fromUtf8(transaction.transactionIdentifier.UTF8String)
<< "product=" << QString::fromUtf8(transaction.payment.productIdentifier.UTF8String)
<< "error=" << QString::fromUtf8(transaction.error.localizedDescription.UTF8String);
if (self.purchaseCompletion) {
self.purchaseCompletion(NO,
transaction.transactionIdentifier,
transaction.payment.productIdentifier,
nil,
transaction.error);
self.purchaseCompletion = nil;
}
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
break;
case SKPaymentTransactionStateRestored: {
if (self.restoreCompletion) {
NSString *transactionId = transaction.transactionIdentifier ?: @"";
NSString *originalTransactionId = transaction.originalTransaction.transactionIdentifier ?: transactionId;
NSString *productId = transaction.payment.productIdentifier ?: @"";
qInfo().noquote() << "[IAP][StoreKit] Transaction restored"
<< QString::fromUtf8(transactionId.UTF8String)
<< "original="
<< QString::fromUtf8((originalTransactionId ?: @"").UTF8String)
<< "product="
<< QString::fromUtf8((productId ?: @"").UTF8String);
NSDictionary *info = @{
@"transactionId": transactionId,
@"originalTransactionId": originalTransactionId ?: @"",
@"productId": productId ?: @""
};
if (!self.restoredTransactions) {
self.restoredTransactions = [NSMutableArray array];
}
[self.restoredTransactions addObject:info];
}
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
break;
if (completion) {
completion(products ?: @[], invalidIdentifiers ?: @[], error);
}
case SKPaymentTransactionStatePurchasing:
case SKPaymentTransactionStateDeferred:
break;
}
}
}
- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue
{
if (self.restoreCompletion) {
NSArray<NSDictionary *> *transactions = [self.restoredTransactions copy];
self.restoreCompletion(YES, transactions, nil);
self.restoreCompletion = nil;
self.restoredTransactions = nil;
}
}
- (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error
{
if (self.restoreCompletion) {
self.restoreCompletion(NO, nil, error);
self.restoreCompletion = nil;
self.restoredTransactions = nil;
}
}];
}
@end

View File

@@ -3,5 +3,7 @@ import Foundation
struct XrayConfig: Decodable {
let dns1: String?
let dns2: String?
let splitTunnelType: Int?
let splitTunnelSites: [String]?
let config: String
}

View File

@@ -179,8 +179,9 @@ bool IosController::initialize()
[NETunnelProviderManager loadAllFromPreferencesWithCompletionHandler:^(NSArray<NETunnelProviderManager *> * _Nullable managers, NSError * _Nullable error) {
@try {
if (error) {
qDebug() << "IosController::initialize : Error:" << [error.localizedDescription UTF8String];
emit connectionStateChanged(Vpn::ConnectionState::Error);
qWarning() << "IosController::initialize : loadAllFromPreferences failed:"
<< [error.localizedDescription UTF8String]
<< "domain:" << [error.domain UTF8String] << "code:" << error.code;
ok = false;
return;
}
@@ -217,16 +218,13 @@ bool IosController::connectVpn(amnezia::Proto proto, const QJsonObject& configur
m_rawConfig = configuration;
m_serverAddress = configuration.value(config_key::hostName).toString().toNSString();
const QString serverDescription = configuration.value(config_key::description).toString().trimmed();
QString tunnelName;
if (configuration.value(config_key::description).toString().isEmpty()) {
if (serverDescription.isEmpty()) {
tunnelName = ProtocolProps::protoToString(proto);
} else {
tunnelName = QString("%1 %2")
.arg(configuration.value(config_key::hostName).toString())
.arg(ProtocolProps::protoToString(proto));
}
else {
tunnelName = QString("%1 (%2) %3")
.arg(configuration.value(config_key::description).toString())
.arg(configuration.value(config_key::hostName).toString())
.arg(serverDescription)
.arg(ProtocolProps::protoToString(proto));
}
@@ -397,8 +395,14 @@ void IosController::vpnStatusDidChange(void *pNotification)
{
NETunnelProviderSession *session = (NETunnelProviderSession *)pNotification;
if (session /* && session == TunnelManager.session */ ) {
qDebug() << "IosController::vpnStatusDidChange" << iosStatusToState(session.status) << session;
if (!session) {
return;
}
if (!m_currentTunnel || (NETunnelProviderSession *)m_currentTunnel.connection != session) {
return;
}
qDebug() << "IosController::vpnStatusDidChange" << iosStatusToState(session.status) << session;
if (session.status == NEVPNStatusDisconnected) {
if (@available(iOS 16.0, *)) {
@@ -512,7 +516,6 @@ void IosController::vpnStatusDidChange(void *pNotification)
m_statusRequestInFlight = false;
}
emitConnectionStateIfChanged(nextState);
}
}
void IosController::vpnConfigurationDidChange(void *pNotification)
@@ -546,6 +549,16 @@ bool IosController::setupOpenVPN()
QJsonDocument openVPNConfigDoc(openVPNConfig);
QString openVPNConfigStr(openVPNConfigDoc.toJson(QJsonDocument::Compact));
QString openVPNConfigPreview = openVPNConfigStr.left(512);
QString ovpnPreview = ovpnConfig.left(512);
qDebug().noquote() << "IosController::setupOpenVPN payload"
<< "jsonBytes=" << openVPNConfigStr.toUtf8().size()
<< "ovpnChars=" << ovpnConfig.size()
<< "splitTunnelType=" << m_rawConfig[config_key::splitTunnelType].toInt()
<< "splitTunnelSites=" << splitTunnelSites;
qDebug().noquote() << "IosController::setupOpenVPN payload jsonPreview=" << openVPNConfigPreview;
qDebug().noquote() << "IosController::setupOpenVPN payload ovpnPreview=" << ovpnPreview;
return startOpenVPN(openVPNConfigStr);
}
@@ -684,6 +697,15 @@ bool IosController::setupXray()
QJsonObject finalConfig;
finalConfig.insert(config_key::dns1, m_rawConfig[config_key::dns1].toString());
finalConfig.insert(config_key::dns2, m_rawConfig[config_key::dns2].toString());
finalConfig.insert(config_key::splitTunnelType, m_rawConfig[config_key::splitTunnelType]);
QJsonArray splitTunnelSites = m_rawConfig[config_key::splitTunnelSites].toArray();
for(int index = 0; index < splitTunnelSites.count(); index++) {
splitTunnelSites[index] = splitTunnelSites[index].toString().remove(" ");
}
finalConfig.insert(config_key::splitTunnelSites, splitTunnelSites);
finalConfig.insert(config_key::config, xrayConfigStr);
QJsonDocument finalConfigDoc(finalConfig);
@@ -785,11 +807,59 @@ bool IosController::startOpenVPN(const QString &config)
NETunnelProviderProtocol *tunnelProtocol = [[NETunnelProviderProtocol alloc] init];
tunnelProtocol.providerBundleIdentifier = [NSString stringWithUTF8String:VPN_NE_BUNDLEID];
tunnelProtocol.providerConfiguration = @{@"ovpn": [[NSString stringWithUTF8String:config.toStdString().c_str()] dataUsingEncoding:NSUTF8StringEncoding]};
QByteArray configUtf8 = config.toUtf8();
NSData *ovpnConfigData = [NSData dataWithBytes:configUtf8.constData() length:configUtf8.size()];
tunnelProtocol.providerConfiguration = @{@"ovpn": ovpnConfigData};
tunnelProtocol.serverAddress = m_serverAddress;
if (@available(iOS 14.0, macOS 11.0, *)) {
int splitTunnelType = 0;
QJsonParseError parseError;
QJsonDocument doc = QJsonDocument::fromJson(config.toUtf8(), &parseError);
if (parseError.error == QJsonParseError::NoError && doc.isObject()) {
QJsonObject obj = doc.object();
splitTunnelType = obj.value(config_key::splitTunnelType).toInt(0);
}
#if defined(MACOS_NE)
// On macOS NE use route-based full tunnel. includeAllNetworks enables
// policy-based drop-all mode and causes enforceRoutes to be ignored.
tunnelProtocol.includeAllNetworks = NO;
if (splitTunnelType == 0) {
tunnelProtocol.enforceRoutes = YES;
if (@available(iOS 14.2, macOS 11.0, *)) {
tunnelProtocol.excludeLocalNetworks = YES;
}
}
#else
tunnelProtocol.includeAllNetworks = (splitTunnelType == 0);
if (@available(iOS 14.2, macOS 11.0, *)) {
// Keep existing iOS behavior.
if (splitTunnelType == 0) {
tunnelProtocol.excludeLocalNetworks = NO;
}
}
#endif
}
m_currentTunnel.protocolConfiguration = tunnelProtocol;
NETunnelProviderProtocol *appliedProtocol = (NETunnelProviderProtocol *)m_currentTunnel.protocolConfiguration;
NSData *ovpnPayload = appliedProtocol.providerConfiguration[@"ovpn"];
NSString *payloadPreview = @"";
if (ovpnPayload != nil) {
NSString *decodedPayload = [[NSString alloc] initWithData:ovpnPayload encoding:NSUTF8StringEncoding];
if (decodedPayload != nil) {
payloadPreview = [decodedPayload substringToIndex:MIN((NSUInteger)512, decodedPayload.length)];
}
}
qDebug().noquote() << "IosController::startOpenVPN protocolConfiguration"
<< "bundleId=" << QString::fromNSString(appliedProtocol.providerBundleIdentifier ?: @"")
<< "serverAddress=" << QString::fromNSString(appliedProtocol.serverAddress ?: @"")
<< "providerKeys=" << QString::fromNSString([[appliedProtocol.providerConfiguration.allKeys description] copy])
<< "ovpnBytes=" << (ovpnPayload != nil ? ovpnPayload.length : 0);
qDebug().noquote() << "IosController::startOpenVPN protocolConfiguration payloadPreview="
<< QString::fromNSString(payloadPreview);
startTunnel();
}
@@ -799,7 +869,9 @@ bool IosController::startWireGuard(const QString &config)
NETunnelProviderProtocol *tunnelProtocol = [[NETunnelProviderProtocol alloc] init];
tunnelProtocol.providerBundleIdentifier = [NSString stringWithUTF8String:VPN_NE_BUNDLEID];
tunnelProtocol.providerConfiguration = @{@"wireguard": [[NSString stringWithUTF8String:config.toStdString().c_str()] dataUsingEncoding:NSUTF8StringEncoding]};
QByteArray configUtf8 = config.toUtf8();
NSData *wgConfigData = [NSData dataWithBytes:configUtf8.constData() length:configUtf8.size()];
tunnelProtocol.providerConfiguration = @{@"wireguard": wgConfigData};
tunnelProtocol.serverAddress = m_serverAddress;
m_currentTunnel.protocolConfiguration = tunnelProtocol;
@@ -813,7 +885,9 @@ bool IosController::startXray(const QString &config)
NETunnelProviderProtocol *tunnelProtocol = [[NETunnelProviderProtocol alloc] init];
tunnelProtocol.providerBundleIdentifier = [NSString stringWithUTF8String:VPN_NE_BUNDLEID];
tunnelProtocol.providerConfiguration = @{@"xray": [[NSString stringWithUTF8String:config.toStdString().c_str()] dataUsingEncoding:NSUTF8StringEncoding]};
QByteArray configUtf8 = config.toUtf8();
NSData *xrayConfigData = [NSData dataWithBytes:configUtf8.constData() length:configUtf8.size()];
tunnelProtocol.providerConfiguration = @{@"xray": xrayConfigData};
tunnelProtocol.serverAddress = m_serverAddress;
m_currentTunnel.protocolConfiguration = tunnelProtocol;
@@ -835,39 +909,49 @@ void IosController::startTunnel()
m_rxBytes = 0;
m_txBytes = 0;
[m_currentTunnel setEnabled:YES];
NETunnelProviderManager *tunnel = m_currentTunnel;
[tunnel setEnabled:YES];
[m_currentTunnel saveToPreferencesWithCompletionHandler:^(NSError *saveError) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
dispatch_async(dispatch_get_main_queue(), ^{
[tunnel saveToPreferencesWithCompletionHandler:^(NSError *saveError) {
dispatch_async(dispatch_get_main_queue(), ^{
if (saveError) {
qDebug().nospace() << "IosController::startTunnel" << protocolName << ": Connect " << protocolName
<< " Tunnel Save Error" << saveError.localizedDescription.UTF8String << " domain:"
<< saveError.domain.UTF8String << " code:" << saveError.code;
emit connectionStateChanged(Vpn::ConnectionState::Error);
return;
}
if (saveError) {
qDebug().nospace() << "IosController::startTunnel" << protocolName << ": Connect " << protocolName << " Tunnel Save Error" << saveError.localizedDescription.UTF8String;
emit connectionStateChanged(Vpn::ConnectionState::Error);
return;
}
[tunnel loadFromPreferencesWithCompletionHandler:^(NSError *loadError) {
dispatch_async(dispatch_get_main_queue(), ^{
if (loadError) {
qDebug().nospace() << "IosController::startTunnel :" << tunnel.localizedDescription << protocolName
<< ": Connect " << protocolName << " Tunnel Load Error"
<< loadError.localizedDescription.UTF8String;
emit connectionStateChanged(Vpn::ConnectionState::Error);
return;
}
[m_currentTunnel loadFromPreferencesWithCompletionHandler:^(NSError *loadError) {
if (loadError) {
qDebug().nospace() << "IosController::startTunnel :" << m_currentTunnel.localizedDescription << protocolName << ": Connect " << protocolName << " Tunnel Load Error" << loadError.localizedDescription.UTF8String;
emit connectionStateChanged(Vpn::ConnectionState::Error);
return;
}
NSError *startError = nil;
qDebug() << iosStatusToState(tunnel.connection.status);
NSError *startError = nil;
qDebug() << iosStatusToState(m_currentTunnel.connection.status);
BOOL started = [tunnel.connection startVPNTunnelWithOptions:nil andReturnError:&startError];
BOOL started = [m_currentTunnel.connection startVPNTunnelWithOptions:nil andReturnError:&startError];
if (!started || startError) {
qDebug().nospace() << "IosController::startTunnel :" << m_currentTunnel.localizedDescription << protocolName << " : Connect " << protocolName << " Tunnel Start Error"
<< (startError ? startError.localizedDescription.UTF8String : "");
emit connectionStateChanged(Vpn::ConnectionState::Error);
} else {
qDebug().nospace() << "IosController::startTunnel :" << m_currentTunnel.localizedDescription << protocolName << " : Starting the tunnel succeeded";
}
}];
});
}];
if (!started || startError) {
qDebug().nospace() << "IosController::startTunnel :" << tunnel.localizedDescription << protocolName
<< " : Connect " << protocolName << " Tunnel Start Error"
<< (startError ? startError.localizedDescription.UTF8String : "");
emit connectionStateChanged(Vpn::ConnectionState::Error);
} else {
qDebug().nospace() << "IosController::startTunnel :" << tunnel.localizedDescription << protocolName
<< " : Starting the tunnel succeeded";
}
});
}];
});
}];
});
}
bool IosController::isOurManager(NETunnelProviderManager* manager) {
@@ -1122,14 +1206,26 @@ void IosController::fetchProducts(const QStringList &productIds,
NSArray<NSString *> * _Nonnull invalidIdentifiers,
NSError * _Nullable error) {
QList<QVariantMap> outProducts;
for (NSDictionary *p in products) {
QVariantMap m;
m["productId"] = QString::fromUtf8([p[@"productId"] UTF8String]);
m["title"] = QString::fromUtf8([p[@"title"] UTF8String]);
m["description"] = QString::fromUtf8([p[@"description"] UTF8String]);
m["price"] = QString::fromUtf8([p[@"price"] UTF8String]);
m["currencyCode"] = QString::fromUtf8([p[@"currencyCode"] UTF8String]);
outProducts.push_back(m);
for (NSDictionary *productInfo in products) {
QVariantMap productData;
productData["productId"] = QString::fromUtf8([productInfo[@"productId"] UTF8String]);
productData["title"] = QString::fromUtf8([productInfo[@"title"] UTF8String]);
productData["description"] = QString::fromUtf8([productInfo[@"description"] UTF8String]);
productData["price"] = QString::fromUtf8([productInfo[@"price"] UTF8String]);
if (productInfo[@"displayPrice"]) {
productData["displayPrice"] = QString::fromUtf8([productInfo[@"displayPrice"] UTF8String]);
}
productData["currencyCode"] = QString::fromUtf8([productInfo[@"currencyCode"] UTF8String]);
if (productInfo[@"priceAmount"]) {
productData["priceAmount"] = [productInfo[@"priceAmount"] doubleValue];
}
if (productInfo[@"subscriptionBillingMonths"]) {
productData["subscriptionBillingMonths"] = [productInfo[@"subscriptionBillingMonths"] doubleValue];
}
if (productInfo[@"displayPricePerMonth"]) {
productData["displayPricePerMonth"] = QString::fromUtf8([productInfo[@"displayPricePerMonth"] UTF8String]);
}
outProducts.push_back(productData);
}
QStringList invalid;

View File

@@ -164,8 +164,13 @@ bool LinuxRouteMonitor::rtmSendRoute(int action, int flags, int type,
}
if (rtm->rtm_type == RTN_THROW) {
QString gateway = NetworkUtilities::getGatewayAndIface().first;
if (gateway.isEmpty()) {
logger.warning() << "No default gateway available, skipping exclusion route";
return false;
}
struct in_addr ip4;
inet_pton(AF_INET, NetworkUtilities::getGatewayAndIface().first.toUtf8(), &ip4);
inet_pton(AF_INET, gateway.toUtf8(), &ip4);
nlmsg_append_attr(nlmsg, sizeof(buf), RTA_GATEWAY, &ip4, sizeof(ip4));
nlmsg_append_attr32(nlmsg, sizeof(buf), RTA_PRIORITY, 0);
rtm->rtm_type = RTN_UNICAST;

View File

@@ -44,6 +44,9 @@ void LinuxNetworkWatcher::initialize() {
connect(m_worker, &LinuxNetworkWatcherWorker::wakeup, this,
&NetworkWatcherImpl::wakeup);
connect(m_worker, &LinuxNetworkWatcherWorker::networkChanged, this,
[this]() { emit networkChanged(""); });
// Let's wait a few seconds to allow the UI to be fully loaded and shown.
// This is not strictly needed, but it's better for user experience because
// it makes the UI faster to appear, plus it gives a bit of delay between the

View File

@@ -37,6 +37,7 @@
enum NMState {
NM_STATE_UNKNOWN = 0,
NM_STATE_ASLEEP = 10,
NM_STATE_DISABLED = 10,
NM_STATE_DISCONNECTED = 20,
NM_STATE_DISCONNECTING = 30,
NM_STATE_CONNECTING = 40,
@@ -199,10 +200,11 @@ void LinuxNetworkWatcherWorker::checkDevices() {
void LinuxNetworkWatcherWorker::NMStateChanged(quint32 state)
{
if (state == NM_STATE_ASLEEP) {
emit wakeup();
}
logger.debug() << "NMStateChanged " << state;
}
logger.debug() << "NMStateChanged " << state;
if (state == NM_STATE_ASLEEP || state == NM_STATE_DISABLED) {
emit wakeup();
} else if (state == NM_STATE_CONNECTED_GLOBAL) {
emit networkChanged();
}
}

View File

@@ -24,6 +24,7 @@ class LinuxNetworkWatcherWorker final : public QObject {
signals:
void unsecuredNetwork(const QString& networkName, const QString& networkId);
void wakeup();
void networkChanged();
public slots:
void initialize();

View File

@@ -114,6 +114,8 @@ namespace amnezia
constexpr char nameOverriddenByUser[] = "nameOverriddenByUser";
constexpr char server_uuid[] = "server_uuid";
}
namespace protocols

View File

@@ -1,6 +1,7 @@
#include "xrayprotocol.h"
#include "core/ipcclient.h"
#include "core/serialization/serialization.h"
#include "ipc.h"
#include "utilities.h"
#include "core/networkUtilities.h"
@@ -14,6 +15,8 @@
#include <QtCore/qobjectdefs.h>
#include <QtCore/qprocess.h>
#include <exception>
#ifdef Q_OS_MACOS
static const QString tunName = "utun22";
#else
@@ -53,6 +56,19 @@ ErrorCode XrayProtocol::start()
{
qDebug() << "XrayProtocol::start()";
// Inject SOCKS5 auth into the inbound before starting xray.
// Re-uses existing credentials if the config already has them (e.g. imported config).
amnezia::serialization::inbounds::InboundCredentials creds;
try {
creds = amnezia::serialization::inbounds::EnsureInboundAuth(m_xrayConfig);
} catch (const std::exception &e) {
qCritical() << "EnsureInboundAuth failed:" << e.what();
return ErrorCode::InternalError;
}
m_socksUser = creds.username;
m_socksPassword = creds.password;
m_socksPort = creds.port;
return IpcClient::withInterface([&](QSharedPointer<IpcInterfaceReplica> iface) {
auto xrayStart = iface->xrayStart(QJsonDocument(m_xrayConfig).toJson());
if (!xrayStart.waitForFinished() || !xrayStart.returnValue()) {
@@ -121,8 +137,11 @@ ErrorCode XrayProtocol::startTun2Socks()
return ErrorCode::AmneziaServiceConnectionFailed;
}
const QString proxyUrl = QString("socks5://%1:%2@127.0.0.1:%3")
.arg(m_socksUser, m_socksPassword, QString::number(m_socksPort));
m_tun2socksProcess->setProgram(PermittedProcess::Tun2Socks);
m_tun2socksProcess->setArguments({"-device", QString("tun://%1").arg(tunName), "-proxy", "socks5://127.0.0.1:10808" });
m_tun2socksProcess->setArguments({"-device", QString("tun://%1").arg(tunName), "-proxy", proxyUrl});
connect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardOutput, this, [this]() {
auto readAllStandardOutput = m_tun2socksProcess->readAllStandardOutput();
@@ -136,7 +155,7 @@ ErrorCode XrayProtocol::startTun2Socks()
if (!line.contains("[TCP]") && !line.contains("[UDP]"))
qDebug() << "[tun2socks]:" << line;
if (line.contains("[STACK] tun://") && line.contains("<-> socks5://127.0.0.1")) {
if (line.contains("[STACK] tun://") && line.contains("<-> socks5://")) {
disconnect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardOutput, this, nullptr);
if (ErrorCode res = setupRouting(); res != ErrorCode::NoError) {

View File

@@ -26,6 +26,10 @@ private:
QList<QHostAddress> m_dnsServers;
QString m_remoteAddress;
QString m_socksUser;
QString m_socksPassword;
int m_socksPort = 10808;
QSharedPointer<IpcProcessInterfaceReplica> m_tun2socksProcess;
};

View File

@@ -27,10 +27,12 @@
<file>images/controls/folder-open.svg</file>
<file>images/controls/folder-search-2.svg</file>
<file>images/controls/gauge.svg</file>
<file>images/controls/globe-2.svg</file>
<file>images/controls/github.svg</file>
<file>images/controls/help-circle.svg</file>
<file>images/controls/history.svg</file>
<file>images/controls/home.svg</file>
<file>images/controls/infinity.svg</file>
<file>images/controls/info.svg</file>
<file>images/controls/mail.svg</file>
<file>images/controls/map-pin.svg</file>
@@ -55,6 +57,7 @@
<file>images/controls/settings-news.svg</file>
<file>images/controls/share-2.svg</file>
<file>images/controls/split-tunneling.svg</file>
<file>images/controls/smartphone.svg</file>
<file>images/controls/tag.svg</file>
<file>images/controls/telegram.svg</file>
<file>images/controls/text-cursor.svg</file>
@@ -133,8 +136,13 @@
<file>ui/qml/Components/HomeContainersListView.qml</file>
<file>ui/qml/Components/HomeSplitTunnelingDrawer.qml</file>
<file>ui/qml/Components/InstalledAppsDrawer.qml</file>
<file>ui/qml/Components/BenefitRow.qml</file>
<file>ui/qml/Components/BenefitsPanel.qml</file>
<file>ui/qml/Components/SubscriptionPlanCard.qml</file>
<file>ui/qml/Components/TermsAndPrivacyText.qml</file>
<file>ui/qml/Components/QuestionDrawer.qml</file>
<file>ui/qml/Components/SelectLanguageDrawer.qml</file>
<file>ui/qml/Components/SubscriptionExpiredDrawer.qml</file>
<file>ui/qml/Components/ServersListView.qml</file>
<file>ui/qml/Components/SettingsContainersListView.qml</file>
<file>ui/qml/Components/TransportProtoSelector.qml</file>
@@ -180,6 +188,7 @@
<file>ui/qml/Controls2/TextTypes/LabelTextType.qml</file>
<file>ui/qml/Controls2/TextTypes/ListItemTitleType.qml</file>
<file>ui/qml/Controls2/TextTypes/ParagraphTextType.qml</file>
<file>ui/qml/Controls2/TextTypes/BadgeTextType.qml</file>
<file>ui/qml/Controls2/TextTypes/SmallTextType.qml</file>
<file>ui/qml/Controls2/TopCloseButtonType.qml</file>
<file>ui/qml/Controls2/VerticalRadioButton.qml</file>
@@ -225,7 +234,9 @@
<file>ui/qml/Pages2/PageSettingsNewsDetail.qml</file>
<file>ui/qml/Pages2/PageProtocolAwgClientSettings.qml</file>
<file>ui/qml/Pages2/PageProtocolWireGuardClientSettings.qml</file>
<file>ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml</file>
<file>ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml</file>
<file>ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml</file>
<file>ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml</file>
<file>ui/qml/Pages2/PageSetupWizardApiServicesList.qml</file>
<file>ui/qml/Pages2/PageSetupWizardConfigSource.qml</file>
<file>ui/qml/Pages2/PageSetupWizardCredentials.qml</file>
@@ -251,6 +262,8 @@
<file>ui/qml/Components/AwgTextField.qml</file>
<file>ui/qml/Pages2/PageSettingsApiSubscriptionKey.qml</file>
<file>ui/qml/Components/SmartScroll.qml</file>
<file>ui/qml/Pages2/PageSettingsConnectionType.qml</file>
<file>ui/qml/Pages2/PageSettingsLocalProxy.qml</file>
</qresource>
<qresource prefix="/countriesFlags">
<file>images/flagKit/ZW.svg</file>

View File

@@ -1,5 +1,5 @@
sudo docker ps -a | grep amnezia | awk '{print $1}' | xargs sudo docker stop;\
sudo docker ps -a | grep amnezia | awk '{print $1}' | xargs sudo docker rm -fv;\
sudo docker images -a --format table | grep amnezia | awk '{print $3}' | xargs sudo docker rmi;\
sudo docker images -a --format table | grep amnezia | awk '{print $3, $1 ":" $2}' | xargs sudo docker rmi;\
sudo docker network ls | grep amnezia-dns-net | awk '{print $1}' | xargs sudo docker network rm;\
sudo rm -frd /opt/amnezia

View File

@@ -1,4 +1,4 @@
FROM 3proxy/3proxy:latest
FROM 3proxy/3proxy:0.9.5
LABEL maintainer="AmneziaVPN"
@@ -7,4 +7,4 @@ RUN echo -e "#!/bin/bash\ntail -f /dev/null" > /opt/amnezia/start.sh
RUN chmod a+x /opt/amnezia/start.sh
ENTRYPOINT [ "/bin/sh", "/opt/amnezia/start.sh" ]
CMD [ "" ]
CMD [ "" ]

View File

@@ -3,11 +3,14 @@
#include "QCoreApplication"
#include "QThread"
#include <limits>
#include "core/networkUtilities.h"
#include "version.h"
#include "containers/containers_defs.h"
#include "logger.h"
#include <QUuid>
namespace
{
@@ -43,6 +46,8 @@ Settings::Settings(QObject *parent) : QObject(parent), m_settings(ORGANIZATION_N
}
}
migrateServerUuids();
m_gatewayEndpoint = gatewayEndpoint;
}
@@ -62,8 +67,16 @@ QJsonObject Settings::server(int index) const
void Settings::addServer(const QJsonObject &server)
{
QJsonObject serverWithUuid = server;
if (!serverWithUuid.contains(config_key::server_uuid)) {
QString uuid = QUuid::createUuid().toString();
uuid.remove(0, 1);
uuid.chop(1);
serverWithUuid.insert(config_key::server_uuid, uuid);
}
QJsonArray servers = serversArray();
servers.append(server);
servers.append(serverWithUuid);
setServersArray(servers);
}
@@ -73,8 +86,15 @@ void Settings::removeServer(int index)
if (index >= servers.size())
return;
const QString removedUuid = servers.at(index).toObject().value(config_key::server_uuid).toString();
servers.removeAt(index);
setServersArray(servers);
if (!removedUuid.isEmpty() && removedUuid == localProxyOwnerUuid()) {
m_settings.setValue("Conf/localProxyHttpEnabled", false);
m_settings.setValue("Conf/localProxyOwnerUuid", "");
emit localProxySettingsChanged();
}
emit serverRemoved(index);
}
@@ -478,6 +498,31 @@ void Settings::setInstallationUuid(const QString &uuid)
m_settings.setValue("Conf/installationUuid", uuid);
}
void Settings::migrateServerUuids()
{
QJsonArray servers = serversArray();
bool hasChanges = false;
for (int i = 0; i < servers.size(); ++i) {
QJsonObject server = servers.at(i).toObject();
if (!server.contains(config_key::server_uuid)) {
QString uuid = QUuid::createUuid().toString();
qDebug() << "Migrating server uuid: " << uuid;
// Remove {} from uuid (as in getInstallationUuid)
uuid.remove(0, 1);
uuid.chop(1);
server.insert(config_key::server_uuid, uuid);
servers.replace(i, server);
qDebug() << "Server uuid migrated: " << server;
hasChanges = true;
}
}
if (hasChanges) {
setServersArray(servers);
}
}
ServerCredentials Settings::defaultServerCredentials() const
{
return serverCredentials(defaultServerIndex());
@@ -565,3 +610,59 @@ void Settings::setReadNewsIds(const QStringList &ids)
{
m_settings.setValue("News/readIds", ids);
}
QString Settings::localProxyOwnerUuid() const
{
return m_settings.value("Conf/localProxyOwnerUuid", "").toString();
}
void Settings::setLocalProxyOwnerUuid(const QString &uuid)
{
m_settings.setValue("Conf/localProxyOwnerUuid", uuid);
emit localProxySettingsChanged();
}
quint16 Settings::localProxyPort() const
{
return m_settings.value("Conf/localProxyPort", 10808).toUInt();
}
void Settings::setLocalProxyPort(quint16 port)
{
m_settings.setValue("Conf/localProxyPort", port);
emit localProxySettingsChanged();
}
bool Settings::isLocalProxyPortUserDefined() const
{
return m_settings.value("Conf/localProxyPortUserDefined", false).toBool();
}
void Settings::setLocalProxyPortUserDefined(bool userDefined)
{
m_settings.setValue("Conf/localProxyPortUserDefined", userDefined);
}
bool Settings::isLocalProxyHttpEnabled() const
{
return m_settings.value("Conf/localProxyHttpEnabled", false).toBool();
}
void Settings::setLocalProxyHttpEnabled(bool enabled)
{
m_settings.setValue("Conf/localProxyHttpEnabled", enabled);
emit localProxySettingsChanged();
}
int Settings::localProxyRestartToken() const
{
return m_settings.value("Conf/localProxyRestartToken", 0).toInt();
}
void Settings::bumpLocalProxyRestartToken()
{
const int current = localProxyRestartToken();
const int next = (current == std::numeric_limits<int>::max()) ? 0 : (current + 1);
m_settings.setValue("Conf/localProxyRestartToken", next);
emit localProxySettingsChanged();
}

View File

@@ -247,15 +247,31 @@ public:
QStringList readNewsIds() const;
void setReadNewsIds(const QStringList &ids);
// Local proxy settings
QString localProxyOwnerUuid() const;
void setLocalProxyOwnerUuid(const QString &uuid);
quint16 localProxyPort() const;
void setLocalProxyPort(quint16 port);
bool isLocalProxyPortUserDefined() const;
void setLocalProxyPortUserDefined(bool userDefined);
bool isLocalProxyHttpEnabled() const;
void setLocalProxyHttpEnabled(bool enabled);
int localProxyRestartToken() const;
void bumpLocalProxyRestartToken();
signals:
void saveLogsChanged(bool enabled);
void screenshotsEnabledChanged(bool enabled);
void serverRemoved(int serverIndex);
void settingsCleared();
void localProxySettingsChanged();
void localProxyStartFailed(const QString &message);
private:
void setInstallationUuid(const QString &uuid);
void migrateServerUuids();
mutable SecureQSettings m_settings;
QString m_gatewayEndpoint;

View File

@@ -52,18 +52,18 @@
<context>
<name>ApiAccountInfoModel</name>
<message>
<location filename="../ui/models/api/apiAccountInfoModel.cpp" line="31"/>
<location filename="../ui/models/api/apiAccountInfoModel.cpp" line="35"/>
<location filename="../ui/models/api/apiAccountInfoModel.cpp" line="32"/>
<location filename="../ui/models/api/apiAccountInfoModel.cpp" line="37"/>
<source>Active</source>
<translation>Активна</translation>
</message>
<message>
<location filename="../ui/models/api/apiAccountInfoModel.cpp" line="34"/>
<source>&lt;p&gt;&lt;a style=&quot;color: #EB5757;&quot;&gt;Inactive&lt;/a&gt;</source>
<location filename="../ui/models/api/apiAccountInfoModel.cpp" line="36"/>
<source>Inactive</source>
<translation>Не активна</translation>
</message>
<message>
<location filename="../ui/models/api/apiAccountInfoModel.cpp" line="48"/>
<location filename="../ui/models/api/apiAccountInfoModel.cpp" line="50"/>
<source>%1 out of %2</source>
<translation>%1 из %2</translation>
</message>
@@ -71,23 +71,51 @@
<context>
<name>ApiConfigsController</name>
<message>
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="514"/>
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="690"/>
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="859"/>
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="929"/>
<source>%1 installed successfully.</source>
<translation>%1 успешно установлен.</translation>
</message>
<message>
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="637"/>
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="810"/>
<source>Subscription restored successfully.</source>
<translation>Подписка успешно восстановлена.</translation>
</message>
<message>
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="751"/>
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="391"/>
<source>%1/mo</source>
<comment>IAP: price per month in plan subtitle</comment>
<translation>%1/мес</translation>
</message>
<message>
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="410"/>
<source>from %1 per month</source>
<comment>IAP: card footer minimum monthly price from StoreKit</comment>
<translation>от %1 в месяц</translation>
</message>
<message>
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="664"/>
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="802"/>
<source>This subscription has already been added</source>
<translation>Эта подписка уже добавлена</translation>
</message>
<message>
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="672"/>
<source>%1 has been added to the app</source>
<translation>%1 добавлено в приложение</translation>
</message>
<message>
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="897"/>
<source>This email address has already been used to activate a trial. If you like the service, you can upgrade to Premium</source>
<translation>Этот адрес электронной почты уже использовался для активации пробного периода. Если вам понравился сервис, вы можете оформить подписку Premium</translation>
</message>
<message>
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="998"/>
<source>API config reloaded</source>
<translation>Конфигурация API перезагружена</translation>
</message>
<message>
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="755"/>
<location filename="../ui/controllers/api/apiConfigsController.cpp" line="1002"/>
<source>Successfully changed the country of connection to %1</source>
<translation>Страна подключения изменена на %1</translation>
</message>
@@ -182,29 +210,24 @@
<translation>&lt;p&gt;&lt;a style=&quot;color: #EB5757;&quot;&gt;Недоступно в вашем регионе. Если у вас включен VPN, отключите его, вернитесь на предыдущий экран и попробуйте снова.&lt;/a&gt;</translation>
</message>
<message>
<location filename="../ui/models/api/apiServicesModel.cpp" line="95"/>
<source>%1 MBit/s</source>
<translation>%1 Мбит/с</translation>
<translation type="vanished">%1 Мбит/с</translation>
</message>
<message>
<location filename="../ui/models/api/apiServicesModel.cpp" line="102"/>
<source>%1 days</source>
<translation>%1 дней</translation>
<translation type="vanished">%1 дней</translation>
</message>
<message>
<location filename="../ui/models/api/apiServicesModel.cpp" line="113"/>
<source>Free</source>
<translation>Бесплатно</translation>
<translation type="vanished">Бесплатно</translation>
</message>
<message>
<location filename="../ui/models/api/apiServicesModel.cpp" line="116"/>
<source>%1 $</source>
<translation>%1 $</translation>
<translation type="vanished">%1 $</translation>
</message>
<message>
<location filename="../ui/models/api/apiServicesModel.cpp" line="118"/>
<source>%1 $/month</source>
<translation>%1 $/месяц</translation>
<translation type="vanished">%1 $/месяц</translation>
</message>
</context>
<context>
@@ -241,45 +264,45 @@
<context>
<name>ConnectionController</name>
<message>
<location filename="../ui/controllers/connectionController.cpp" line="81"/>
<location filename="../ui/controllers/connectionController.cpp" line="82"/>
<source>Connecting...</source>
<translation>Подключение...</translation>
</message>
<message>
<location filename="../ui/controllers/connectionController.cpp" line="86"/>
<location filename="../ui/controllers/connectionController.cpp" line="89"/>
<source>Connected</source>
<translation>Подключено</translation>
</message>
<message>
<location filename="../ui/controllers/connectionController.cpp" line="110"/>
<location filename="../ui/controllers/connectionController.cpp" line="113"/>
<source>Preparing...</source>
<translation>Подготовка...</translation>
</message>
<message>
<location filename="../ui/controllers/connectionController.cpp" line="132"/>
<location filename="../ui/controllers/connectionController.cpp" line="135"/>
<source>Settings updated successfully, reconnnection...</source>
<translation>Настройки успешно обновлены, переподключение...</translation>
</message>
<message>
<location filename="../ui/controllers/connectionController.cpp" line="135"/>
<location filename="../ui/controllers/connectionController.cpp" line="138"/>
<source>Settings updated successfully</source>
<translation>Настройки успешно обновлены</translation>
</message>
<message>
<location filename="../ui/controllers/connectionController.cpp" line="95"/>
<location filename="../ui/controllers/connectionController.cpp" line="98"/>
<source>Reconnecting...</source>
<translation>Переподключение...</translation>
</message>
<message>
<location filename="../ui/controllers/connectionController.h" line="70"/>
<location filename="../ui/controllers/connectionController.cpp" line="100"/>
<location filename="../ui/controllers/connectionController.cpp" line="115"/>
<location filename="../ui/controllers/connectionController.cpp" line="121"/>
<location filename="../ui/controllers/connectionController.cpp" line="103"/>
<location filename="../ui/controllers/connectionController.cpp" line="118"/>
<location filename="../ui/controllers/connectionController.cpp" line="124"/>
<source>Connect</source>
<translation>Подключиться</translation>
</message>
<message>
<location filename="../ui/controllers/connectionController.cpp" line="105"/>
<location filename="../ui/controllers/connectionController.cpp" line="108"/>
<source>Disconnecting...</source>
<translation>Отключение...</translation>
</message>
@@ -1697,17 +1720,32 @@ Thank you for staying with us!</source>
<context>
<name>PageSettingsApiAvailableCountries</name>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiAvailableCountries.qml" line="84"/>
<location filename="../ui/qml/Pages2/PageSettingsApiAvailableCountries.qml" line="129"/>
<source>Subscription expired</source>
<translation>Подписка закончилась</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiAvailableCountries.qml" line="129"/>
<source>Subscription expiring soon</source>
<translation>Подписка скоро закончится</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiAvailableCountries.qml" line="148"/>
<source>Renew subscription</source>
<translation>Продлить подписку</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiAvailableCountries.qml" line="162"/>
<source>Location for connection</source>
<translation>Страны для подключения</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiAvailableCountries.qml" line="123"/>
<location filename="../ui/qml/Pages2/PageSettingsApiAvailableCountries.qml" line="191"/>
<source>Unable change server location while trying to make an active connection</source>
<translation>Невозможно изменить локацию во время попытки соединения</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiAvailableCountries.qml" line="127"/>
<location filename="../ui/qml/Pages2/PageSettingsApiAvailableCountries.qml" line="195"/>
<source>Unable change server location while there is an active connection</source>
<translation>Невозможно изменить локацию во время активного соединения</translation>
</message>
@@ -1939,12 +1977,12 @@ Thank you for staying with us!</source>
<context>
<name>PageSettingsApiServerInfo</name>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="190"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="298"/>
<source>Configurations have been updated for some countries. Download and install the updated configuration files</source>
<translation>Сетевые адреса одного или нескольких серверов были обновлены. Пожалуйста, удалите старые конфигурацию и загрузите новые файлы</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="235"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="343"/>
<source>Manage configuration files</source>
<translation>Управление файлами конфигурации</translation>
</message>
@@ -1964,106 +2002,122 @@ Thank you for staying with us!</source>
<translation>Активные соединения</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="166"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="150"/>
<source>Subscription expired</source>
<translation>Подписка закончилась</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="151"/>
<source>Subscription expiring soon</source>
<translation>Подписка скоро закончится</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="181"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="246"/>
<source>Renew subscription</source>
<translation>Продлить подписку</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="270"/>
<source>Use VLESS protocol</source>
<translation>Использовать протокол VLESS</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="170"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="274"/>
<source>Cannot change protocol during active connection</source>
<translation>Невозможно изменить протокол во время активного соединения</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="211"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="319"/>
<source>Subscription Key</source>
<translation>Ключ для подключения</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="233"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="341"/>
<source>Configuration Files</source>
<translation>Файлы конфигурации</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="253"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="361"/>
<source>Active Devices</source>
<translation>Активные устройства</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="255"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="363"/>
<source>Manage currently connected devices</source>
<translation>Управление подключенными устройствами</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="272"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="380"/>
<source>Support</source>
<translation>Поддержка</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="287"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="395"/>
<source>How to connect on another device</source>
<translation>Как подключить другие устройства</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="312"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="420"/>
<source>Reload API config</source>
<translation>Перезагрузить конфигурацию API</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="315"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="423"/>
<source>Reload API config?</source>
<translation>Перезагрузить конфигурацию API?</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="316"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="354"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="391"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="424"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="462"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="499"/>
<source>Continue</source>
<translation>Продолжить</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="317"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="355"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="392"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="425"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="463"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="500"/>
<source>Cancel</source>
<translation>Отменить</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="321"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="429"/>
<source>Cannot reload API config during active connection</source>
<translation>Невозможно перзагрузить API конфигурацию при активном соединении</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="349"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="457"/>
<source>Unlink this device</source>
<translation>Отвязать это устройство</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="352"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="460"/>
<source>Are you sure you want to unlink this device?</source>
<translation>Вы уверены, что хотите отвязать это устройство?</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="353"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="461"/>
<source>This will unlink the device from your subscription. You can reconnect it anytime by pressing&#xa0;&quot;Reload API config&quot; in subscription settings on device.</source>
<translation>Это отключит устройство от вашей подписки. Вы можете повторно подключить его в любое время, нажав &quot;Перезагрузить конфигурацию API&quot; в настройках подписки на устройстве.</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="359"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="467"/>
<source>Cannot unlink device during active connection</source>
<translation>Невозможно отвязать устройство во время активного соединения</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="387"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="495"/>
<source>Remove from application</source>
<translation>Удалить из приложения</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="390"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="498"/>
<source>Remove from application?</source>
<translation>Удалить из приложения?</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="396"/>
<location filename="../ui/qml/Pages2/PageSettingsApiServerInfo.qml" line="504"/>
<source>Cannot remove server during active connection</source>
<translation>Невозможно удалить сервер во время активного соединения</translation>
</message>
@@ -3111,51 +3165,83 @@ Thank you for staying with us!</source>
</message>
</context>
<context>
<name>PageSetupWizardApiServiceInfo</name>
<name>PageSetupWizardApiFreeInfo</name>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml" line="113"/>
<location filename="../ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml" line="74"/>
<source>Free features</source>
<translation>Возможности Free</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiFreeInfo.qml" line="125"/>
<source>Continue</source>
<translation>Продолжить</translation>
</message>
</context>
<context>
<name>PageSetupWizardApiPremiumInfo</name>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml" line="91"/>
<source>Recommended</source>
<translation>Рекомендуется</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml" line="103"/>
<source>Premium features</source>
<translation>Возможности Premium</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml" line="132"/>
<source>Charged to your Apple ID at confirmation. Renews automatically unless auto-renew is turned off at least 24 hours before period end. Manage in Apple ID settings.</source>
<translation>Списание с Apple ID при подтверждении. Продление автоматическое, если автопродление не отключено минимум за 24 часа до окончания периода. Управление в настройках Apple ID.</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml" line="125"/>
<location filename="../ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml" line="169"/>
<source>Continue</source>
<translation>Продолжить</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiPremiumInfo.qml" line="171"/>
<source>Subscribe %1 for %2</source>
<translation>Подписаться %1 за %2</translation>
</message>
</context>
<context>
<name>PageSetupWizardApiServiceInfo</name>
<message>
<source>Charged to your Apple ID at confirmation. Renews automatically unless auto-renew is turned off at least 24 hours before period end. Manage in Apple ID settings.</source>
<translation type="vanished">Списание с Apple ID при подтверждении. Продление автоматическое, если автопродление не отключено минимум за 24 часа до окончания периода. Управление в настройках Apple ID.</translation>
</message>
<message>
<source>Subscribe Now</source>
<translation>Подписаться сейчас</translation>
<translation type="vanished">Подписаться сейчас</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml" line="158"/>
<source>By continuing, you agree to the &lt;a href=&quot;%1&quot; style=&quot;color: #FBB26A;&quot;&gt;Terms of Use&lt;/a&gt; and &lt;a href=&quot;%2&quot; style=&quot;color: #FBB26A;&quot;&gt;Privacy Policy&lt;/a&gt;</source>
<translation>Продолжая, вы соглашаетесь с &lt;a href=&quot;%1&quot; style=&quot;color: #FBB26A;&quot;&gt;Условиями использования&lt;/a&gt; и &lt;a href=&quot;%2&quot; style=&quot;color: #FBB26A;&quot;&gt;Политикой конфиденциальности&lt;/a&gt;</translation>
<translation type="vanished">Продолжая, вы соглашаетесь с &lt;a href=&quot;%1&quot; style=&quot;color: #FBB26A;&quot;&gt;Условиями использования&lt;/a&gt; и &lt;a href=&quot;%2&quot; style=&quot;color: #FBB26A;&quot;&gt;Политикой конфиденциальности&lt;/a&gt;</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml" line="186"/>
<source>For the region</source>
<translation>Для региона</translation>
<translation type="vanished">Для региона</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml" line="195"/>
<source>Price</source>
<translation>Цена</translation>
<translation type="vanished">Цена</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml" line="204"/>
<source>Work period</source>
<translation>Период работы</translation>
<translation type="vanished">Период работы</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml" line="213"/>
<source>Speed</source>
<translation>Скорость</translation>
<translation type="vanished">Скорость</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml" line="222"/>
<source>Features</source>
<translation>Особенности</translation>
<translation type="vanished">Особенности</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml" line="125"/>
<source>Connect</source>
<translation>Подключиться</translation>
<translation type="vanished">Подключиться</translation>
</message>
</context>
<context>
@@ -3170,11 +3256,50 @@ Thank you for staying with us!</source>
<source>Choose a VPN service that suits your needs.</source>
<translation>Выберите VPN-сервис, который подходит именно вам.</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiServicesList.qml" line="88"/>
<source>Recommended</source>
<translation>Рекомендуется</translation>
</message>
</context>
<context>
<name>PageSetupWizardApiTrialEmail</name>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml" line="65"/>
<source>Create an account</source>
<translation>Создайте учётную запись</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml" line="66"/>
<source>To manage your subscription</source>
<translation>Для управления подпиской</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml" line="77"/>
<location filename="../ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml" line="78"/>
<source>Email</source>
<translation>Электронная почта</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml" line="102"/>
<source>We will create an account for your trial subscription and send important subscription updates to this email address</source>
<translation>Мы создадим учётную запись для вашей пробной подписки и будем отправлять на этот адрес электронной почты важные уведомления о подписке</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml" line="118"/>
<source>Continue</source>
<translation>Продолжить</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardApiTrialEmail.qml" line="126"/>
<source>Enter a valid email address</source>
<translation>Введите корректный адрес электронной почты</translation>
</message>
</context>
<context>
<name>PageSetupWizardConfigSource</name>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="324"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="331"/>
<source>File with connection settings</source>
<translation>Файл с настройками подключения</translation>
</message>
@@ -3249,71 +3374,80 @@ Thank you for staying with us!</source>
<translation>Другие варианты подключения</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="253"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="226"/>
<source>Recommended</source>
<translation>Рекомендуется</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="256"/>
<source>Site Amnezia</source>
<translation>Сайт Amnezia</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="358"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="281"/>
<source>The easiest way to connect to the VPN</source>
<translation>Самый простой способ подключиться к VPN</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="367"/>
<source>Restore purchases</source>
<translation>Восстановить покупки</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="277"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="280"/>
<source>VPN by Amnezia</source>
<translation>VPN от Amnezia</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="278"/>
<source>Connect to classic paid and free VPN services from Amnezia</source>
<translation>Подключайтесь к классическим платным и бесплатным VPN-сервисам от Amnezia</translation>
<translation type="vanished">Подключайтесь к классическим платным и бесплатным VPN-сервисам от Amnezia</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="294"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="299"/>
<source>Self-hosted VPN</source>
<translation>Self-hosted VPN</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="295"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="300"/>
<source>Configure Amnezia VPN on your own server</source>
<translation>Настроить VPN на собственном сервере</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="306"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="312"/>
<source>Restore from backup</source>
<translation>Восстановить из резервной копии</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="307"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="325"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="344"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="359"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="373"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="313"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="332"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="352"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="368"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="383"/>
<source></source>
<translation></translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="311"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="317"/>
<source>Open backup file</source>
<translation>Открыть резервную копию</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="312"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="318"/>
<source>Backup files (*.backup)</source>
<translation>Файлы резервных копий (*.backup)</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="331"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="338"/>
<source>Open config file</source>
<translation>Открыть файл с конфигурацией</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="343"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="351"/>
<source>QR code</source>
<translation>QR-код</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="372"/>
<location filename="../ui/qml/Pages2/PageSetupWizardConfigSource.qml" line="382"/>
<source>I have nothing</source>
<translation>У меня ничего нет</translation>
</message>
@@ -3321,17 +3455,17 @@ Thank you for staying with us!</source>
<context>
<name>PageSetupWizardCredentials</name>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="194"/>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="206"/>
<source>Server IP address [:port]</source>
<translation>IP-адрес[:порт] сервера</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="100"/>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="112"/>
<source>Continue</source>
<translation>Продолжить</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="167"/>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="179"/>
<source>Enter the address in the format 255.255.255.255:88</source>
<translation>Введите адрес в формате 255.255.255.255:88</translation>
</message>
@@ -3341,48 +3475,54 @@ Thank you for staying with us!</source>
<translation>Настроить ваш сервер</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="195"/>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="207"/>
<source>255.255.255.255:22</source>
<translation>255.255.255.255:22</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="203"/>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="215"/>
<source>SSH Username</source>
<translation>Имя пользователя SSH</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="82"/>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="212"/>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="94"/>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="224"/>
<source>Password or SSH private key</source>
<translation>Пароль или закрытый ключ SSH</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="132"/>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="97"/>
<source>SSH key requirements: supported key types are ED25519 and RSA in PEM format. Paste the private key, including the BEGIN/END lines. If your key doesnt work, generate a compatible one</source>
<translation>Требования к SSH-ключу: поддерживаются ключи ED25519 и RSA в формате PEM. Вставьте закрытый ключ целиком, включая строки BEGIN/END. Если ваш ключ не подходит, создайте совместимый ключ</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="144"/>
<source>All data you enter will remain strictly confidential and will not be shared or disclosed to the Amnezia or any third parties</source>
<translation>Все данные, которые вы вводите, останутся строго конфиденциальными и не будут переданы или раскрыты Amnezia или каким-либо третьим лицам</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="143"/>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="155"/>
<source>How to run your VPN server</source>
<translation>Как создать VPN на собственном сервере</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="144"/>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="156"/>
<source>Where to get connection data, step-by-step instructions for buying a VPS</source>
<translation>Где взять данные для подключения, пошаговые инструкции по покупке VPS</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="164"/>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="176"/>
<source>Ip address cannot be empty</source>
<translation>Поле с IP-адресом не может быть пустым</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="172"/>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="184"/>
<source>Login cannot be empty</source>
<translation>Поле с логином не может быть пустым</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="178"/>
<location filename="../ui/qml/Pages2/PageSetupWizardCredentials.qml" line="190"/>
<source>Password/private key cannot be empty</source>
<translation>Поле с паролем/закрытым ключом не может быть пустым</translation>
</message>
@@ -3516,7 +3656,7 @@ Thank you for staying with us!</source>
<context>
<name>PageSetupWizardStart</name>
<message>
<location filename="../ui/qml/Pages2/PageSetupWizardStart.qml" line="41"/>
<location filename="../ui/qml/Pages2/PageSetupWizardStart.qml" line="42"/>
<source>Let&apos;s get started</source>
<translation>Приступим</translation>
</message>
@@ -4326,7 +4466,22 @@ Thank you for staying with us!</source>
<translation>Не удалось обработать покупку</translation>
</message>
<message>
<location filename="../core/errorstrings.cpp" line="97"/>
<location filename="../core/errorstrings.cpp" line="83"/>
<source>No active subscription found</source>
<translation>Активная подписка не найдена</translation>
</message>
<message>
<location filename="../core/errorstrings.cpp" line="84"/>
<source>No purchased subscriptions found. Please purchase a subscription first</source>
<translation>Платные подписки не найдены. Сначала оформите подписку</translation>
</message>
<message>
<location filename="../core/errorstrings.cpp" line="85"/>
<source>This email address has already been used to activate a trial</source>
<translation>Этот адрес электронной почты уже использовался для активации пробного периода</translation>
</message>
<message>
<location filename="../core/errorstrings.cpp" line="100"/>
<source>ErrorCode: %1. </source>
<translation>Код ошибки: %1. </translation>
</message>
@@ -4426,37 +4581,37 @@ Thank you for staying with us!</source>
<translation>Превышен лимит разрешенных конфигураций для одной подписки</translation>
</message>
<message>
<location filename="../core/errorstrings.cpp" line="85"/>
<location filename="../core/errorstrings.cpp" line="88"/>
<source>QFile error: The file could not be opened</source>
<translation>Ошибка QFile: не удалось открыть файл</translation>
</message>
<message>
<location filename="../core/errorstrings.cpp" line="86"/>
<location filename="../core/errorstrings.cpp" line="89"/>
<source>QFile error: An error occurred when reading from the file</source>
<translation>Ошибка QFile: произошла ошибка при чтении из файла</translation>
</message>
<message>
<location filename="../core/errorstrings.cpp" line="87"/>
<location filename="../core/errorstrings.cpp" line="90"/>
<source>QFile error: The file could not be accessed</source>
<translation>Ошибка QFile: не удалось получить доступ к файлу</translation>
</message>
<message>
<location filename="../core/errorstrings.cpp" line="88"/>
<location filename="../core/errorstrings.cpp" line="91"/>
<source>QFile error: An unspecified error occurred</source>
<translation>Ошибка QFile: произошла неизвестная ошибка</translation>
</message>
<message>
<location filename="../core/errorstrings.cpp" line="89"/>
<location filename="../core/errorstrings.cpp" line="92"/>
<source>QFile error: A fatal error occurred</source>
<translation>Ошибка QFile: произошла фатальная ошибка</translation>
</message>
<message>
<location filename="../core/errorstrings.cpp" line="90"/>
<location filename="../core/errorstrings.cpp" line="93"/>
<source>QFile error: The operation was aborted</source>
<translation>Ошибка QFile: операция была прервана</translation>
</message>
<message>
<location filename="../core/errorstrings.cpp" line="94"/>
<location filename="../core/errorstrings.cpp" line="97"/>
<source>Internal error</source>
<translation>Внутренняя ошибка</translation>
</message>
@@ -4985,7 +5140,17 @@ FileZilla или другие SFTP-клиенты, а также смонтир
<context>
<name>ServersListView</name>
<message>
<location filename="../ui/qml/Components/ServersListView.qml" line="79"/>
<location filename="../ui/qml/Components/ServersListView.qml" line="71"/>
<source>Subscription expired. Please renew</source>
<translation>Подписка закончилась. Пожалуйста, продлите её</translation>
</message>
<message>
<location filename="../ui/qml/Components/ServersListView.qml" line="71"/>
<source>Subscription expiring soon</source>
<translation>Подписка скоро закончится</translation>
</message>
<message>
<location filename="../ui/qml/Components/ServersListView.qml" line="84"/>
<source>Unable change server while there is an active connection</source>
<translation>Невозможно изменить сервер во время активного соединения</translation>
</message>
@@ -5007,12 +5172,17 @@ FileZilla или другие SFTP-клиенты, а также смонтир
<context>
<name>SettingsController</name>
<message>
<location filename="../ui/controllers/settingsController.cpp" line="270"/>
<location filename="../ui/controllers/settingsController.cpp" line="185"/>
<source>Can&apos;t open file: %1</source>
<translation>Невозможно открыть файл: %1</translation>
</message>
<message>
<location filename="../ui/controllers/settingsController.cpp" line="271"/>
<source>All settings have been reset to default values</source>
<translation>Все настройки сброшены до значений по умолчанию</translation>
</message>
<message>
<location filename="../ui/controllers/settingsController.cpp" line="247"/>
<location filename="../ui/controllers/settingsController.cpp" line="248"/>
<source>Backup file is corrupted</source>
<translation>Файл резервной копии поврежден</translation>
</message>
@@ -5065,6 +5235,29 @@ FileZilla или другие SFTP-клиенты, а также смонтир
<translation>Экспорт завершен</translation>
</message>
</context>
<context>
<name>SubscriptionExpiredDrawer</name>
<message>
<location filename="../ui/qml/Components/SubscriptionExpiredDrawer.qml" line="47"/>
<source>Amnezia Premium subscription has expired</source>
<translation>Подписка Amnezia Premium закончилась</translation>
</message>
<message>
<location filename="../ui/qml/Components/SubscriptionExpiredDrawer.qml" line="60"/>
<source>Renew to continue using VPN</source>
<translation>Продлите подписку, чтобы продолжить использовать VPN</translation>
</message>
<message>
<location filename="../ui/qml/Components/SubscriptionExpiredDrawer.qml" line="72"/>
<source>Renew</source>
<translation>Продлить</translation>
</message>
<message>
<location filename="../ui/qml/Components/SubscriptionExpiredDrawer.qml" line="96"/>
<source>Support</source>
<translation>Поддержка</translation>
</message>
</context>
<context>
<name>SystemTrayNotificationHandler</name>
<message>
@@ -5098,6 +5291,14 @@ FileZilla или другие SFTP-клиенты, а также смонтир
<translation>Закрыть</translation>
</message>
</context>
<context>
<name>TermsAndPrivacyText</name>
<message>
<location filename="../ui/qml/Components/TermsAndPrivacyText.qml" line="23"/>
<source>By continuing, you agree to the &lt;a href=&quot;%1&quot; style=&quot;color: %3;&quot;&gt;Terms of Use&lt;/a&gt; and &lt;a href=&quot;%2&quot; style=&quot;color: %3;&quot;&gt;Privacy Policy&lt;/a&gt;</source>
<translation>Продолжая, вы соглашаетесь с &lt;a href=&quot;%1&quot; style=&quot;color: %3;&quot;&gt;Условиями использования&lt;/a&gt; и &lt;a href=&quot;%2&quot; style=&quot;color: %3;&quot;&gt;Политикой конфиденциальности&lt;/a&gt;</translation>
</message>
</context>
<context>
<name>TextFieldWithHeaderType</name>
<message>
@@ -5173,12 +5374,12 @@ FileZilla или другие SFTP-клиенты, а также смонтир
<context>
<name>main2</name>
<message>
<location filename="../ui/qml/main2.qml" line="230"/>
<location filename="../ui/qml/main2.qml" line="247"/>
<source>Private key passphrase</source>
<translation>Парольная фраза для закрытого ключа</translation>
</message>
<message>
<location filename="../ui/qml/main2.qml" line="251"/>
<location filename="../ui/qml/main2.qml" line="268"/>
<source>Save</source>
<translation>Сохранить</translation>
</message>

View File

@@ -9,9 +9,14 @@
#include "ui/controllers/systemController.h"
#include "version.h"
#include <QClipboard>
#include <QCoreApplication>
#include <QDebug>
#include <QEventLoop>
#include <QHash>
#include <QJsonArray>
#include <QSet>
#include <QVariantMap>
#include <limits>
#include "platforms/ios/ios_controller.h"
@@ -39,6 +44,15 @@ namespace
constexpr char serviceInfo[] = "service_info";
constexpr char serviceProtocol[] = "service_protocol";
constexpr char services[] = "services";
constexpr char serviceDescription[] = "service_description";
constexpr char subscriptionPlans[] = "subscription_plans";
constexpr char storeProductId[] = "store_product_id";
constexpr char priceLabel[] = "price_label";
constexpr char subtitle[] = "subtitle";
constexpr char isTrial[] = "is_trial";
constexpr char minPriceLabel[] = "min_price_label";
constexpr char apiPayload[] = "api_payload";
constexpr char keyPayload[] = "key_payload";
@@ -47,9 +61,6 @@ namespace
constexpr char config[] = "config";
constexpr char subscription[] = "subscription";
constexpr char endDate[] = "end_date";
constexpr char isConnectEvent[] = "is_connect_event";
}
@@ -241,13 +252,190 @@ namespace
return ErrorCode::NoError;
}
#if defined(Q_OS_IOS) || defined(MACOS_NE)
struct StoreKitPlanQuote {
QString displayPrice;
double priceAmount = 0.0;
double subscriptionBillingMonths = 0.0;
QString displayPricePerMonth;
};
constexpr double kOneMonthThreshold = 1.0 + 1e-6;
constexpr double kMonthsFallbackThreshold = 1e-6;
constexpr double kMonthlyPriceEpsilon = 1e-9;
QStringList collectPremiumStoreProductIds(const QJsonArray &services)
{
QStringList productIds;
QSet<QString> seenProductIds;
for (const QJsonValue &serviceValue : services) {
const QJsonObject serviceObject = serviceValue.toObject();
if (serviceObject.value(configKey::serviceType).toString() != serviceType::amneziaPremium) {
continue;
}
const QJsonArray subscriptionPlans =
serviceObject.value(configKey::serviceDescription).toObject().value(configKey::subscriptionPlans).toArray();
for (const QJsonValue &planValue : subscriptionPlans) {
if (!planValue.isObject()) {
continue;
}
const QString storeProductId = planValue.toObject().value(configKey::storeProductId).toString();
if (storeProductId.isEmpty() || seenProductIds.contains(storeProductId)) {
continue;
}
seenProductIds.insert(storeProductId);
productIds.append(storeProductId);
}
}
return productIds;
}
QHash<QString, StoreKitPlanQuote> buildStoreKitQuoteMap(const QList<QVariantMap> &fetchedProducts)
{
QHash<QString, StoreKitPlanQuote> quotesByProductId;
quotesByProductId.reserve(fetchedProducts.size());
for (const QVariantMap &productInfo : fetchedProducts) {
const QString productId = productInfo.value(QStringLiteral("productId")).toString();
if (productId.isEmpty()) {
continue;
}
QString displayPrice = productInfo.value(QStringLiteral("displayPrice")).toString();
if (displayPrice.isEmpty()) {
const QString price = productInfo.value(QStringLiteral("price")).toString();
const QString currencyCode = productInfo.value(QStringLiteral("currencyCode")).toString();
displayPrice = currencyCode.isEmpty() ? price : (price + QLatin1Char(' ') + currencyCode);
}
StoreKitPlanQuote quote;
quote.displayPrice = displayPrice;
quote.priceAmount = productInfo.value(QStringLiteral("priceAmount")).toDouble();
quote.subscriptionBillingMonths = productInfo.value(QStringLiteral("subscriptionBillingMonths")).toDouble();
quote.displayPricePerMonth = productInfo.value(QStringLiteral("displayPricePerMonth")).toString();
quotesByProductId.insert(productId, quote);
}
return quotesByProductId;
}
void mergeStoreKitPricesIntoPremiumPlans(QJsonObject &data)
{
QJsonArray services = data.value(configKey::services).toArray();
if (services.isEmpty()) {
return;
}
const QStringList productIds = collectPremiumStoreProductIds(services);
if (productIds.isEmpty()) {
qInfo().noquote() << "[IAP] No store_product_id in premium plans; skip StoreKit merge into services payload";
return;
}
QList<QVariantMap> fetchedProducts;
QEventLoop loop;
IosController::Instance()->fetchProducts(productIds,
[&](const QList<QVariantMap> &products, const QStringList &invalidIds,
const QString &errorString) {
if (!errorString.isEmpty()) {
qWarning().noquote() << "[IAP] StoreKit merge fetch:" << errorString;
}
if (!invalidIds.isEmpty()) {
qWarning().noquote() << "[IAP] Unknown App Store product ids:" << invalidIds;
}
fetchedProducts = products;
loop.quit();
});
loop.exec();
const QHash<QString, StoreKitPlanQuote> quotesByProductId = buildStoreKitQuoteMap(fetchedProducts);
for (int serviceIndex = 0; serviceIndex < services.size(); ++serviceIndex) {
QJsonObject serviceObject = services.at(serviceIndex).toObject();
if (serviceObject.value(configKey::serviceType).toString() != serviceType::amneziaPremium) {
continue;
}
QJsonObject descriptionObject = serviceObject.value(configKey::serviceDescription).toObject();
const QJsonArray sourcePlans = descriptionObject.value(configKey::subscriptionPlans).toArray();
QJsonArray mergedPlans;
double minMonthlyAmount = std::numeric_limits<double>::infinity();
QString minMonthlyDisplay;
for (const QJsonValue &planValue : sourcePlans) {
if (!planValue.isObject()) {
continue;
}
QJsonObject planObject = planValue.toObject();
const QString storeProductId = planObject.value(configKey::storeProductId).toString();
if (storeProductId.isEmpty()) {
continue;
}
const auto quoteIterator = quotesByProductId.constFind(storeProductId);
if (quoteIterator == quotesByProductId.cend()) {
continue;
}
const bool isTrialPlan = planObject.value(configKey::isTrial).toBool();
const StoreKitPlanQuote &quote = *quoteIterator;
planObject.insert(configKey::priceLabel, quote.displayPrice);
const double months = quote.subscriptionBillingMonths;
if (!isTrialPlan && months > kOneMonthThreshold && !quote.displayPricePerMonth.isEmpty()) {
planObject.insert(
configKey::subtitle,
QCoreApplication::translate("ApiConfigsController", "%1/mo", "IAP: price per month in plan subtitle")
.arg(quote.displayPricePerMonth));
}
if (!isTrialPlan && quote.priceAmount > 0.0) {
const double monthsForMin = months > kMonthsFallbackThreshold ? months : 1.0;
const double monthly = quote.priceAmount / monthsForMin;
if (monthly < minMonthlyAmount - kMonthlyPriceEpsilon) {
minMonthlyAmount = monthly;
minMonthlyDisplay = !quote.displayPricePerMonth.isEmpty() ? quote.displayPricePerMonth : quote.displayPrice;
}
}
mergedPlans.append(planObject);
}
descriptionObject.insert(configKey::subscriptionPlans, mergedPlans);
if (minMonthlyAmount < std::numeric_limits<double>::infinity() && !minMonthlyDisplay.isEmpty()) {
descriptionObject.insert(configKey::minPriceLabel,
QCoreApplication::translate("ApiConfigsController", "from %1 per month",
"IAP: card footer minimum monthly price from StoreKit")
.arg(minMonthlyDisplay));
}
serviceObject.insert(configKey::serviceDescription, descriptionObject);
services.replace(serviceIndex, serviceObject);
}
data.insert(configKey::services, services);
}
#endif
}
ApiConfigsController::ApiConfigsController(const QSharedPointer<ServersModel> &serversModel,
const QSharedPointer<ApiServicesModel> &apiServicesModel,
const QSharedPointer<ApiSubscriptionPlansModel> &subscriptionPlansModel,
const QSharedPointer<ApiBenefitsModel> &benefitsModel,
const std::shared_ptr<Settings> &settings, QObject *parent)
: QObject(parent), m_serversModel(serversModel), m_apiServicesModel(apiServicesModel), m_settings(settings)
: QObject(parent)
, m_serversModel(serversModel)
, m_apiServicesModel(apiServicesModel)
, m_subscriptionPlansModel(subscriptionPlansModel)
, m_benefitsModel(benefitsModel)
, m_settings(settings)
{
connect(m_apiServicesModel.data(), &ApiServicesModel::serviceSelectionChanged, this, [this]() {
const ApiServicesModel::ApiServicesData serviceData = m_apiServicesModel->selectedServiceData();
m_subscriptionPlansModel->updateModel(serviceData.subscriptionPlansJson);
m_benefitsModel->updateModel(serviceData.benefits);
});
}
bool ApiConfigsController::exportVpnKey(const QString &fileName)
@@ -366,6 +554,8 @@ bool ApiConfigsController::fillAvailableServices()
{
QJsonObject apiPayload;
apiPayload[configKey::osVersion] = QSysInfo::productType();
apiPayload[configKey::appVersion] = QString(APP_VERSION);
apiPayload[apiDefs::key::cliName] = QString(APPLICATION_NAME);
apiPayload[apiDefs::key::appLanguage] = m_settings->getAppLanguage().name().split("_").first();
QByteArray responseBody;
@@ -382,51 +572,11 @@ bool ApiConfigsController::fillAvailableServices()
}
QJsonObject data = QJsonDocument::fromJson(responseBody).object();
#if defined(Q_OS_IOS) || defined(MACOS_NE)
QEventLoop waitProducts;
bool productsFetched = false;
QString productPrice;
QString productCurrency;
IosController::Instance()->fetchProducts(QStringList() << QStringLiteral("amnezia_premium_6_month"),
[&](const QList<QVariantMap> &products,
const QStringList &invalidIds,
const QString &errorString) {
if (!errorString.isEmpty() || products.isEmpty()) {
qWarning().noquote() << "[IAP] Failed to fetch product price:" << errorString;
} else {
const auto &product = products.first();
productPrice = product.value("price").toString();
productCurrency = product.value("currencyCode").toString();
productsFetched = true;
qInfo().noquote() << "[IAP] Fetched product price:" << productPrice << productCurrency;
}
waitProducts.quit();
});
waitProducts.exec();
if (productsFetched && !productPrice.isEmpty()) {
QJsonArray services = data.value("services").toArray();
for (int i = 0; i < services.size(); ++i) {
QJsonObject service = services[i].toObject();
if (service.value(configKey::serviceType).toString() == serviceType::amneziaPremium) {
QJsonObject serviceInfo = service.value(configKey::serviceInfo).toObject();
QString formattedPrice = productPrice;
if (!productCurrency.isEmpty()) {
formattedPrice += " " + productCurrency;
}
serviceInfo["price"] = formattedPrice;
service[configKey::serviceInfo] = serviceInfo;
services[i] = service;
data["services"] = services;
qInfo().noquote() << "[IAP] Updated premium service price in data:" << formattedPrice;
break;
}
}
}
mergeStoreKitPricesIntoPremiumPlans(data);
#endif
m_apiServicesModel->updateModel(data);
if (m_apiServicesModel->rowCount() > 0) {
m_apiServicesModel->setServiceIndex(0);
@@ -437,39 +587,42 @@ bool ApiConfigsController::fillAvailableServices()
bool ApiConfigsController::importService()
{
#if defined(Q_OS_IOS) || defined(MACOS_NE)
bool isIosOrMacOsNe = true;
const bool isIosOrMacOsNe = true;
#else
bool isIosOrMacOsNe = false;
const bool isIosOrMacOsNe = false;
#endif
if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaPremium) {
if (isIosOrMacOsNe) {
importSerivceFromAppStore();
return true;
return importPremiumFromAppStore(QString());
}
} else {
importServiceFromGateway();
return true;
} else if (m_apiServicesModel->getSelectedServiceType() == serviceType::amneziaFree) {
return importFreeFromGateway();
}
return false;
}
bool ApiConfigsController::importSerivceFromAppStore()
bool ApiConfigsController::importPremiumFromAppStore(const QString &storeProductId)
{
#if defined(Q_OS_IOS) || defined(MACOS_NE)
QString productId = storeProductId.trimmed();
if (productId.isEmpty()) {
productId = QStringLiteral("amnezia_premium_6_month");
}
bool purchaseOk = false;
QString originalTransactionId;
QString storeTransactionId;
QString storeProductId;
QString purchasedStoreProductId;
QString purchaseError;
QEventLoop waitPurchase;
IosController::Instance()->purchaseProduct(QStringLiteral("amnezia_premium_6_month"),
[&](bool success, const QString &txId, const QString &purchasedProductId,
const QString &originalTxId, const QString &errorString) {
IosController::Instance()->purchaseProduct(productId,
[&](bool success, const QString &transactionId, const QString &purchasedProductId,
const QString &originalTransactionIdResponse, const QString &errorString) {
purchaseOk = success;
originalTransactionId = originalTxId;
storeTransactionId = txId;
storeProductId = purchasedProductId;
originalTransactionId = originalTransactionIdResponse;
storeTransactionId = transactionId;
purchasedStoreProductId = purchasedProductId;
purchaseError = errorString;
waitPurchase.quit();
});
@@ -481,7 +634,7 @@ bool ApiConfigsController::importSerivceFromAppStore()
return false;
}
qInfo().noquote() << "[IAP] Purchase success. transactionId =" << storeTransactionId
<< "originalTransactionId =" << originalTransactionId << "productId =" << storeProductId;
<< "originalTransactionId =" << originalTransactionId << "productId =" << purchasedStoreProductId;
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
QString(APP_VERSION),
@@ -505,18 +658,26 @@ bool ApiConfigsController::importSerivceFromAppStore()
return false;
}
errorCode = importServiceFromBilling(responseBody, isTestPurchase);
int duplicateServerIndex = -1;
errorCode = importServiceFromBilling(responseBody, isTestPurchase, duplicateServerIndex);
if (errorCode == ErrorCode::ApiConfigAlreadyAdded) {
emit installServerFromApiFinished(tr("This subscription has already been added"), duplicateServerIndex);
return true;
}
if (errorCode != ErrorCode::NoError) {
emit errorOccurred(errorCode);
return false;
}
emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName()));
#endif
emit installServerFromApiFinished(
tr("%1 has been added to the app").arg(m_apiServicesModel->getSelectedServiceName()));
return true;
#else
Q_UNUSED(storeProductId);
return false;
#endif
}
bool ApiConfigsController::restoreSerivceFromAppStore()
bool ApiConfigsController::restoreServiceFromAppStore()
{
#if defined(Q_OS_IOS) || defined(MACOS_NE)
const QString premiumServiceType = QStringLiteral("amnezia-premium");
@@ -532,20 +693,12 @@ bool ApiConfigsController::restoreSerivceFromAppStore()
return false;
}
// Ensure we have a valid premium selection for gateway requests
bool premiumSelected = false;
for (int i = 0; i < m_apiServicesModel->rowCount(); ++i) {
m_apiServicesModel->setServiceIndex(i);
if (m_apiServicesModel->getSelectedServiceType() == premiumServiceType) {
premiumSelected = true;
break;
}
}
if (!premiumSelected) {
const int premiumServiceIndex = m_apiServicesModel->serviceIndexForType(premiumServiceType);
if (premiumServiceIndex < 0) {
emit errorOccurred(ErrorCode::ApiServicesMissingError);
return false;
}
m_apiServicesModel->setServiceIndex(premiumServiceIndex);
bool restoreSuccess = false;
QList<QVariantMap> restoredTransactions;
@@ -567,15 +720,23 @@ bool ApiConfigsController::restoreSerivceFromAppStore()
}
if (restoredTransactions.isEmpty()) {
qInfo().noquote() << "[IAP] Restore completed, but no transactions were returned";
emit errorOccurred(ErrorCode::ApiPurchaseError);
qInfo().noquote() << "[IAP] Restore completed, but no active entitlements found";
emit errorOccurred(ErrorCode::ApiNoPurchasedSubscriptionsError);
return false;
}
const bool isTestPurchase = IosController::Instance()->isTestFlight();
const QString serviceType = m_apiServicesModel->getSelectedServiceType();
const QString serviceProtocol = m_apiServicesModel->getSelectedServiceProtocol();
const QString countryCode = m_apiServicesModel->getCountryCode();
const QString appLanguage = m_settings->getAppLanguage().name().split("_").first();
const QString installationUuid = m_settings->getInstallationUuid(true);
bool hasInstalledConfig = false;
bool duplicateConfigAlreadyPresent = false;
int duplicateCount = 0;
QSet<QString> processedTransactions;
int duplicateServerIndex = -1;
QSet<QString> processedOriginalTransactionIds;
for (const QVariantMap &transaction : restoredTransactions) {
const QString originalTransactionId = transaction.value(QStringLiteral("originalTransactionId")).toString();
const QString transactionId = transaction.value(QStringLiteral("transactionId")).toString();
@@ -586,28 +747,28 @@ bool ApiConfigsController::restoreSerivceFromAppStore()
continue;
}
if (processedTransactions.contains(originalTransactionId)) {
duplicateCount++;
if (processedOriginalTransactionIds.contains(originalTransactionId)) {
qInfo().noquote() << "[IAP] Skipping duplicate restored transaction" << originalTransactionId;
continue;
}
processedTransactions.insert(originalTransactionId);
processedOriginalTransactionIds.insert(originalTransactionId);
qInfo().noquote() << "[IAP] Restoring subscription. transactionId =" << transactionId
<< "originalTransactionId =" << originalTransactionId << "productId =" << productId;
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
QString(APP_VERSION),
m_settings->getAppLanguage().name().split("_").first(),
m_settings->getInstallationUuid(true),
m_apiServicesModel->getCountryCode(),
appLanguage,
installationUuid,
countryCode,
"",
m_apiServicesModel->getSelectedServiceType(),
m_apiServicesModel->getSelectedServiceProtocol(),
serviceType,
serviceProtocol,
QJsonObject() };
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
apiPayload[apiDefs::key::transactionId] = originalTransactionId;
auto isTestPurchase = IosController::Instance()->isTestFlight();
QByteArray responseBody;
ErrorCode errorCode = executeRequest(QString("%1v1/subscriptions"), apiPayload, responseBody, isTestPurchase);
if (errorCode != ErrorCode::NoError) {
@@ -616,34 +777,42 @@ bool ApiConfigsController::restoreSerivceFromAppStore()
continue;
}
ErrorCode installError = importServiceFromBilling(responseBody, isTestPurchase);
int currentDuplicateServerIndex = -1;
errorCode = importServiceFromBilling(responseBody, isTestPurchase, currentDuplicateServerIndex);
if (errorCode == ErrorCode::ApiConfigAlreadyAdded) {
duplicateConfigAlreadyPresent = true;
qInfo().noquote() << "[IAP] Skipping restored transaction" << originalTransactionId
<< "because subscription config with the same vpn_key already exists";
} else if (errorCode != ErrorCode::NoError) {
qWarning().noquote() << "[IAP] Failed to process restored subscription response for transaction" << originalTransactionId;
} else {
hasInstalledConfig = true;
if (duplicateServerIndex < 0) {
duplicateServerIndex = currentDuplicateServerIndex;
}
qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists" << originalTransactionId;
continue;
}
if (errorCode != ErrorCode::NoError) {
qWarning().noquote() << "[IAP] Failed to process restored subscription response for transaction" << originalTransactionId
<< "errorCode =" << static_cast<int>(errorCode);
continue;
}
hasInstalledConfig = true;
}
if (!hasInstalledConfig) {
const ErrorCode restoreError = duplicateConfigAlreadyPresent ? ErrorCode::ApiConfigAlreadyAdded : ErrorCode::ApiPurchaseError;
emit errorOccurred(restoreError);
if (duplicateConfigAlreadyPresent) {
emit installServerFromApiFinished(tr("This subscription has already been added"), duplicateServerIndex);
return true;
}
emit errorOccurred(ErrorCode::ApiPurchaseError);
return false;
}
emit installServerFromApiFinished(tr("Subscription restored successfully."));
if (duplicateCount > 0) {
qInfo().noquote() << "[IAP] Skipped" << duplicateCount
<< "duplicate restored transactions for original transaction IDs already processed";
}
#endif
return true;
}
bool ApiConfigsController::importServiceFromGateway()
bool ApiConfigsController::importFreeFromGateway()
{
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
QString(APP_VERSION),
@@ -695,9 +864,76 @@ bool ApiConfigsController::importServiceFromGateway()
}
}
bool ApiConfigsController::importTrialFromGateway(const QString &email)
{
emit trialEmailError(QString());
const QString trimmedEmail = email.trimmed();
if (trimmedEmail.isEmpty()) {
emit errorOccurred(ErrorCode::ApiConfigEmptyError);
return false;
}
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
QString(APP_VERSION),
m_settings->getAppLanguage().name().split("_").first(),
m_settings->getInstallationUuid(true),
m_apiServicesModel->getCountryCode(),
"",
m_apiServicesModel->getSelectedServiceType(),
m_apiServicesModel->getSelectedServiceProtocol(),
QJsonObject() };
ProtocolData protocolData = generateProtocolData(gatewayRequestData.serviceProtocol);
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
appendProtocolDataToApiPayload(gatewayRequestData.serviceProtocol, protocolData, apiPayload);
apiPayload.insert(apiDefs::key::email, trimmedEmail);
QByteArray responseBody;
ErrorCode errorCode = executeRequest(QString("%1v1/trial"), apiPayload, responseBody);
if (errorCode != ErrorCode::NoError) {
if (errorCode == ErrorCode::ApiTrialAlreadyUsedError) {
emit trialEmailError(tr("This email address has already been used to activate a trial. If you like the service, you can upgrade to Premium"));
return false;
}
emit errorOccurred(errorCode);
return false;
}
QJsonObject responseObject = QJsonDocument::fromJson(responseBody).object();
QString key = responseObject.value(apiDefs::key::config).toString();
if (key.isEmpty()) {
qWarning().noquote() << "[Trial] trial response does not contain config field";
emit errorOccurred(ErrorCode::ApiConfigEmptyError);
return false;
}
key.replace(QStringLiteral("vpn://"), QString());
QByteArray configBytes = QByteArray::fromBase64(key.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
QByteArray uncompressed = qUncompress(configBytes);
if (!uncompressed.isEmpty()) {
configBytes = uncompressed;
}
if (configBytes.isEmpty()) {
qWarning().noquote() << "[Trial] trial response config payload is empty";
emit errorOccurred(ErrorCode::ApiConfigEmptyError);
return false;
}
QJsonObject configObject = QJsonDocument::fromJson(configBytes).object();
quint16 crc = qChecksum(QJsonDocument(configObject).toJson());
configObject.insert(config_key::crc, crc);
m_serversModel->addServer(configObject);
emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName()));
return true;
}
bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const QString &newCountryCode, const QString &newCountryName,
bool reloadServiceConfig)
{
const QString serverUuid = m_serversModel->getServerUuid(serverIndex);
auto serverConfig = m_serversModel->getServerConfig(serverIndex);
auto apiConfig = serverConfig.value(configKey::apiConfig).toObject();
@@ -721,6 +957,7 @@ bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const
}
bool isTestPurchase = apiConfig.value(apiDefs::key::isTestPurchase).toBool(false);
bool wasSubscriptionExpired = m_serversModel->data(serverIndex, ServersModel::IsSubscriptionExpiredRole).toBool();
QByteArray responseBody;
ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody, isTestPurchase);
@@ -737,16 +974,36 @@ bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const
newApiConfig.insert(configKey::serviceType, apiConfig.value(configKey::serviceType));
newApiConfig.insert(configKey::serviceProtocol, apiConfig.value(configKey::serviceProtocol));
newApiConfig.insert(apiDefs::key::vpnKey, apiConfig.value(apiDefs::key::vpnKey));
if (apiConfig.contains(apiDefs::key::isInAppPurchase)) {
newApiConfig.insert(apiDefs::key::isInAppPurchase, apiConfig.value(apiDefs::key::isInAppPurchase));
}
if (apiConfig.contains(apiDefs::key::isTestPurchase)) {
newApiConfig.insert(apiDefs::key::isTestPurchase, apiConfig.value(apiDefs::key::isTestPurchase));
}
newServerConfig.insert(configKey::apiConfig, newApiConfig);
newServerConfig.insert(configKey::authData, gatewayRequestData.authData);
newServerConfig.insert(config_key::crc, serverConfig.value(config_key::crc));
newServerConfig.insert(config_key::server_uuid, serverConfig.value(config_key::server_uuid));
if (serverConfig.value(config_key::nameOverriddenByUser).toBool()) {
newServerConfig.insert(config_key::name, serverConfig.value(config_key::name));
newServerConfig.insert(config_key::nameOverriddenByUser, true);
}
m_serversModel->editServer(newServerConfig, serverIndex);
if (wasSubscriptionExpired) {
emit subscriptionRefreshNeeded();
}
if (!serverUuid.isEmpty()
&& m_settings
&& m_settings->isLocalProxyHttpEnabled()
&& m_settings->localProxyOwnerUuid() == serverUuid) {
m_settings->bumpLocalProxyRestartToken();
}
if (reloadServiceConfig) {
emit reloadServerFromApiFinished(tr("API config reloaded"));
} else if (newCountryName.isEmpty()) {
@@ -756,7 +1013,18 @@ bool ApiConfigsController::updateServiceFromGateway(const int serverIndex, const
}
return true;
} else {
emit errorOccurred(errorCode);
if (errorCode == ErrorCode::ApiSubscriptionExpiredError) {
if (!apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false)) {
apiConfig.insert(apiDefs::key::subscriptionExpiredByServer, true);
serverConfig.insert(configKey::apiConfig, apiConfig);
m_serversModel->editServer(serverConfig, serverIndex);
emit subscriptionExpiredOnServer();
} else {
emit errorOccurred(errorCode);
}
} else {
emit errorOccurred(errorCode);
}
return false;
}
}
@@ -772,6 +1040,7 @@ bool ApiConfigsController::updateServiceFromTelegram(const int serverIndex)
m_settings->isStrictKillSwitchEnabled());
auto serverConfig = m_serversModel->getServerConfig(serverIndex);
const QString serverUuid = m_serversModel->getServerUuid(serverIndex);
auto installationUuid = m_settings->getInstallationUuid(true);
QString serviceProtocol = serverConfig.value(configKey::protocol).toString();
@@ -796,6 +1065,14 @@ bool ApiConfigsController::updateServiceFromTelegram(const int serverIndex)
}
m_serversModel->editServer(serverConfig, serverIndex);
if (!serverUuid.isEmpty()
&& m_settings
&& m_settings->isLocalProxyHttpEnabled()
&& m_settings->localProxyOwnerUuid() == serverUuid) {
m_settings->bumpLocalProxyRestartToken();
}
emit updateServerFromApiFinished();
return true;
} else {
@@ -942,43 +1219,63 @@ QString ApiConfigsController::getVpnKey()
return m_vpnKey;
}
ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase)
ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase,
int &duplicateServerIndex)
{
#ifdef Q_OS_IOS
#if defined(Q_OS_IOS) || defined(MACOS_NE)
duplicateServerIndex = -1;
QJsonObject responseObject = QJsonDocument::fromJson(responseBody).object();
QString key = responseObject.value(QStringLiteral("key")).toString();
if (key.isEmpty()) {
const QString rawVpnKey = responseObject.value(QStringLiteral("key")).toString();
if (rawVpnKey.isEmpty()) {
qWarning().noquote() << "[IAP] Subscription response does not contain a key field";
return ErrorCode::ApiPurchaseError;
}
if (m_serversModel->hasServerWithVpnKey(key)) {
QString normalizedVpnKey = rawVpnKey;
normalizedVpnKey.replace(QStringLiteral("vpn://"), QString());
duplicateServerIndex = m_serversModel->indexOfServerWithVpnKey(normalizedVpnKey);
if (duplicateServerIndex >= 0) {
qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists";
return ErrorCode::ApiConfigAlreadyAdded;
}
QString normalizedKey = key;
normalizedKey.replace(QStringLiteral("vpn://"), QString());
QByteArray configString = QByteArray::fromBase64(normalizedKey.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
QByteArray configUncompressed = qUncompress(configString);
if (!configUncompressed.isEmpty()) {
configString = configUncompressed;
QByteArray configPayload =
QByteArray::fromBase64(normalizedVpnKey.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
QByteArray configUncompressed = qUncompress(configPayload);
const bool payloadWasCompressed = !configUncompressed.isEmpty();
if (payloadWasCompressed) {
configPayload = configUncompressed;
}
if (configString.isEmpty()) {
if (configPayload.isEmpty()) {
qWarning().noquote() << "[IAP] Subscription response config payload is empty";
return ErrorCode::ApiPurchaseError;
}
QJsonObject configObject = QJsonDocument::fromJson(configString).object();
QJsonObject configObject = QJsonDocument::fromJson(configPayload).object();
auto apiConfig = configObject.value(apiDefs::key::apiConfig).toObject();
apiConfig.insert(apiDefs::key::isTestPurchase, isTestPurchase);
apiConfig.insert(apiDefs::key::isInAppPurchase, true);
configObject.insert(apiDefs::key::apiConfig, apiConfig);
configPayload = QJsonDocument(configObject).toJson();
if (payloadWasCompressed) {
configPayload = qCompress(configPayload, 8);
}
normalizedVpnKey = QString(configPayload.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
duplicateServerIndex = m_serversModel->indexOfServerWithVpnKey(normalizedVpnKey);
if (duplicateServerIndex >= 0) {
qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists";
return ErrorCode::ApiConfigAlreadyAdded;
}
apiConfig.insert(apiDefs::key::vpnKey, normalizedVpnKey);
configObject.insert(apiDefs::key::apiConfig, apiConfig);
quint16 crc = qChecksum(QJsonDocument(configObject).toJson());
auto apiConfig = configObject.value(apiDefs::key::apiConfig).toObject();
apiConfig[apiDefs::key::vpnKey] = normalizedKey;
apiConfig[apiDefs::key::isTestPurchase] = isTestPurchase;
configObject.insert(apiDefs::key::apiConfig, apiConfig);
configObject.insert(config_key::crc, crc);
m_serversModel->addServer(configObject);
@@ -986,6 +1283,7 @@ ErrorCode ApiConfigsController::importServiceFromBilling(const QByteArray &respo
#else
Q_UNUSED(responseBody)
Q_UNUSED(isTestPurchase)
duplicateServerIndex = -1;
return ErrorCode::NoError;
#endif
}

View File

@@ -1,10 +1,13 @@
#ifndef APICONFIGSCONTROLLER_H
#define APICONFIGSCONTROLLER_H
#include <QList>
#include <QObject>
#include "configurators/openvpn_configurator.h"
#include "ui/models/api/apiBenefitsModel.h"
#include "ui/models/api/apiServicesModel.h"
#include "ui/models/api/apiSubscriptionPlansModel.h"
#include "ui/models/servers_model.h"
class ApiConfigsController : public QObject
@@ -12,7 +15,9 @@ class ApiConfigsController : public QObject
Q_OBJECT
public:
ApiConfigsController(const QSharedPointer<ServersModel> &serversModel, const QSharedPointer<ApiServicesModel> &apiServicesModel,
const std::shared_ptr<Settings> &settings, QObject *parent = nullptr);
const QSharedPointer<ApiSubscriptionPlansModel> &subscriptionPlansModel,
const QSharedPointer<ApiBenefitsModel> &benefitsModel, const std::shared_ptr<Settings> &settings,
QObject *parent = nullptr);
Q_PROPERTY(QList<QString> qrCodes READ getQrCodes NOTIFY vpnKeyExportReady)
Q_PROPERTY(int qrCodesCount READ getQrCodesCount NOTIFY vpnKeyExportReady)
@@ -27,9 +32,10 @@ public slots:
bool fillAvailableServices();
bool importService();
bool importSerivceFromAppStore();
bool restoreSerivceFromAppStore();
bool importServiceFromGateway();
bool importPremiumFromAppStore(const QString &storeProductId);
bool restoreServiceFromAppStore();
bool importFreeFromGateway();
bool importTrialFromGateway(const QString &email);
bool updateServiceFromGateway(const int serverIndex, const QString &newCountryCode, const QString &newCountryName,
bool reloadServiceConfig = false);
bool updateServiceFromTelegram(const int serverIndex);
@@ -43,8 +49,11 @@ public slots:
signals:
void errorOccurred(ErrorCode errorCode);
void trialEmailError(const QString &message);
void subscriptionExpiredOnServer();
void subscriptionRefreshNeeded();
void installServerFromApiFinished(const QString &message);
void installServerFromApiFinished(const QString &message, int preferredDefaultServerIndex = -1);
void changeApiCountryFinished(const QString &message);
void reloadServerFromApiFinished(const QString &message);
void updateServerFromApiFinished();
@@ -57,7 +66,7 @@ private:
QString getVpnKey();
ErrorCode executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody, bool isTestPurchase = false);
ErrorCode importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase);
ErrorCode importServiceFromBilling(const QByteArray &responseBody, const bool isTestPurchase, int &duplicateServerIndex);
QList<QString> m_qrCodes;
QString m_vpnKey;
@@ -65,6 +74,9 @@ private:
QSharedPointer<ServersModel> m_serversModel;
QSharedPointer<ApiServicesModel> m_apiServicesModel;
std::shared_ptr<Settings> m_settings;
QSharedPointer<ApiSubscriptionPlansModel> m_subscriptionPlansModel;
QSharedPointer<ApiBenefitsModel> m_benefitsModel;
};
#endif // APICONFIGSCONTROLLER_H
#endif

View File

@@ -1,6 +1,7 @@
#include "apiSettingsController.h"
#include <QEventLoop>
#include <QJsonDocument>
#include <QTimer>
#include "core/api/apiUtils.h"
@@ -22,6 +23,19 @@ namespace
}
const int requestTimeoutMsecs = 12 * 1000; // 12 secs
QString getSubscriptionStatusForRenewal(const QSharedPointer<ApiAccountInfoModel> &accountInfoModel)
{
if (!accountInfoModel.isNull() && accountInfoModel->data(QStringLiteral("isSubscriptionExpired")).toBool()) {
return QStringLiteral("expired");
}
if (!accountInfoModel.isNull() && accountInfoModel->data(QStringLiteral("isSubscriptionExpiringSoon")).toBool()) {
return QStringLiteral("expire_soon");
}
return QStringLiteral("active");
}
}
ApiSettingsController::ApiSettingsController(const QSharedPointer<ServersModel> &serversModel,
@@ -85,6 +99,43 @@ bool ApiSettingsController::getAccountInfo(bool reload)
return true;
}
void ApiSettingsController::getRenewalLink()
{
auto processedIndex = m_serversModel->getProcessedServerIndex();
auto serverConfig = m_serversModel->getServerConfig(processedIndex);
auto apiConfig = serverConfig.value(configKey::apiConfig).toObject();
auto authData = serverConfig.value(configKey::authData).toObject();
bool isTestPurchase = apiConfig.value(apiDefs::key::isTestPurchase).toBool(false);
auto gatewayController = QSharedPointer<GatewayController>::create(m_settings->getGatewayEndpoint(isTestPurchase),
m_settings->isDevGatewayEnv(isTestPurchase),
requestTimeoutMsecs,
m_settings->isStrictKillSwitchEnabled());
QJsonObject apiPayload;
apiPayload[configKey::userCountryCode] = apiConfig.value(configKey::userCountryCode).toString();
apiPayload[configKey::serviceType] = apiConfig.value(configKey::serviceType).toString();
apiPayload[configKey::authData] = authData;
apiPayload[apiDefs::key::cliVersion] = QString(APP_VERSION);
apiPayload[apiDefs::key::appLanguage] = m_settings->getAppLanguage().name().split("_").first();
apiPayload[apiDefs::key::subscriptionStatus] = getSubscriptionStatusForRenewal(m_apiAccountInfoModel);
auto future = gatewayController->postAsync(QString("%1v1/renewal_link"), apiPayload);
future.then(this, [this, gatewayController](QPair<ErrorCode, QByteArray> result) {
auto [errorCode, responseBody] = result;
if (errorCode != ErrorCode::NoError) {
emit errorOccurred(errorCode);
return;
}
QJsonObject responseJson = QJsonDocument::fromJson(responseBody).object();
QString url = responseJson.value("renewal_url").toString();
if (!url.isEmpty()) {
emit renewalLinkReceived(url);
}
});
}
void ApiSettingsController::updateApiCountryModel()
{
m_apiCountryModel->updateModel(m_apiAccountInfoModel->getAvailableCountries(), "");

View File

@@ -21,9 +21,11 @@ public slots:
bool getAccountInfo(bool reload);
void updateApiCountryModel();
void updateApiDevicesModel();
void getRenewalLink();
signals:
void errorOccurred(ErrorCode errorCode);
void renewalLinkReceived(const QString &url);
private:
QSharedPointer<ServersModel> m_serversModel;

View File

@@ -6,6 +6,7 @@
#include <QApplication>
#endif
#include "amnezia_application.h"
#include "utilities.h"
#include "core/controllers/vpnConfigurationController.h"
#include "version.h"
@@ -33,6 +34,11 @@ ConnectionController::ConnectionController(const QSharedPointer<ServersModel> &s
void ConnectionController::openConnection()
{
if (m_settings->isLocalProxyHttpEnabled()) {
m_settings->setLocalProxyHttpEnabled(false);
emit localProxyStoppedBecauseVpnTurnedOn(tr("Local proxy stopped because VPN was turned on"));
}
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE)
if (!Utils::processIsRunning(Utils::executable(SERVICE_NAME, false), true))
{
@@ -81,6 +87,8 @@ void ConnectionController::onConnectionStateChanged(Vpn::ConnectionState state)
m_connectionStateText = tr("Connecting...");
switch (state) {
case Vpn::ConnectionState::Connected: {
amnApp->networkManager()->clearConnectionCache();
m_isConnectionInProgress = false;
m_isConnected = true;
m_connectionStateText = tr("Connected");

View File

@@ -44,6 +44,7 @@ signals:
void connectToVpn(int serverIndex, const ServerCredentials &credentials, DockerContainer container, const QJsonObject &vpnConfiguration);
void disconnectFromVpn();
void connectionStateChanged();
void localProxyStoppedBecauseVpnTurnedOn(const QString &message);
void connectionErrorOccurred(ErrorCode errorCode);
void reconnectWithUpdatedContainer(const QString &message);

View File

@@ -42,6 +42,8 @@ namespace PageLoader
PageSettingsApiDevices,
PageSettingsApiSubscriptionKey,
PageSettingsKillSwitchExceptions,
PageSettingsConnectionType,
PageSettingsLocalProxy,
PageServiceSftpSettings,
PageServiceTorWebsiteSettings,
@@ -59,7 +61,7 @@ namespace PageLoader
PageSetupWizardViewConfig,
PageSetupWizardQrReader,
PageSetupWizardApiServicesList,
PageSetupWizardApiServiceInfo,
PageSetupWizardApiFreeInfo,
PageProtocolOpenVpnSettings,
PageProtocolShadowSocksSettings,
@@ -76,6 +78,9 @@ namespace PageLoader
PageShareFullAccess,
PageShareConnection,
PageSetupWizardApiPremiumInfo,
PageSetupWizardApiTrialEmail,
PageDevMenu
};
Q_ENUM_NS(PageEnum)

View File

@@ -4,6 +4,9 @@
#include <QOperatingSystemVersion>
#include "logger.h"
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS)
#include "core/local-proxy/portavailabilityhelper.h"
#endif
#include "systemController.h"
#include "ui/qautostart.h"
#include "amnezia_application.h"
@@ -16,6 +19,12 @@
#include <AmneziaVPN-Swift.h>
#endif
namespace {
constexpr int kDefaultProxyPort = 10808;
constexpr int kLocalProxyPortMin = 1024;
constexpr int kLocalProxyPortMax = 65535;
}
SettingsController::SettingsController(const QSharedPointer<ServersModel> &serversModel,
const QSharedPointer<ContainersModel> &containersModel,
const QSharedPointer<LanguageModel> &languageModel,
@@ -45,10 +54,15 @@ SettingsController::SettingsController(const QSharedPointer<ServersModel> &serve
emit safeAreaBottomMarginChanged();
emit safeAreaTopMarginChanged();
});
connect(AndroidController::instance(), &AndroidController::activityPaused, this, &SettingsController::activityPaused);
connect(AndroidController::instance(), &AndroidController::activityResumed, this, &SettingsController::activityResumed);
#endif
m_isDevModeEnabled = m_settings->isDevGatewayEnv();
toggleDevGatewayEnv(m_isDevModeEnabled);
connect(m_settings.get(), &Settings::localProxySettingsChanged, this, &SettingsController::localProxySettingsUpdated);
connect(m_settings.get(), &Settings::localProxyStartFailed, this, &SettingsController::localProxyStartFailed);
}
QString getPlatformName()
@@ -531,3 +545,115 @@ void SettingsController::disableHomeAdLabel()
m_settings->disableHomeAdLabel();
emit isHomeAdLabelVisibleChanged(false);
}
bool SettingsController::isLocalProxySupported() const
{
#ifdef AMNEZIA_DESKTOP
return true;
#else
return false;
#endif
}
bool SettingsController::isLocalProxyHttpEnabled() const
{
return m_settings->isLocalProxyHttpEnabled();
}
int SettingsController::localProxyPort() const
{
return static_cast<int>(m_settings->localProxyPort());
}
QString SettingsController::localProxyOwnerUuid() const
{
return m_settings->localProxyOwnerUuid();
}
bool SettingsController::setLocalProxyPort(int port)
{
if (port < kLocalProxyPortMin || port > kLocalProxyPortMax) {
return false;
}
if (m_settings->localProxyPort() == static_cast<quint16>(port)) {
m_settings->setLocalProxyPortUserDefined(true);
return true;
}
m_settings->setLocalProxyPort(static_cast<quint16>(port));
m_settings->setLocalProxyPortUserDefined(true);
return true;
}
bool SettingsController::isLocalProxyPortBusy(int port) const
{
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS)
return !PortAvailabilityHelper::isPortAvailable(port);
#else
Q_UNUSED(port);
return false;
#endif
}
bool SettingsController::isLocalProxyPortUserDefined() const
{
return m_settings->isLocalProxyPortUserDefined();
}
int SettingsController::findFirstAvailableLocalProxyPort(int startPort) const
{
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS)
const auto port = PortAvailabilityHelper::findFirstAvailablePort(startPort, kLocalProxyPortMax);
return port ? *port : -1;
#else
Q_UNUSED(startPort);
return -1;
#endif
}
bool SettingsController::enableLocalProxy(const QString &ownerUuid, int port)
{
#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS)
Q_UNUSED(ownerUuid);
Q_UNUSED(port);
return false;
#else
if (port < kLocalProxyPortMin || port > kLocalProxyPortMax || ownerUuid.isEmpty()) {
return false;
}
if (m_settings->isLocalProxyHttpEnabled() && m_settings->localProxyOwnerUuid() != ownerUuid) {
return false;
}
int selectedPort = port;
const bool isUserDefinedPort = m_settings->isLocalProxyPortUserDefined();
if (isUserDefinedPort) {
if (!PortAvailabilityHelper::isPortAvailable(selectedPort)) {
return false;
}
} else if (selectedPort != kDefaultProxyPort && !PortAvailabilityHelper::isPortAvailable(selectedPort)) {
return false;
}
if (m_settings->localProxyPort() != static_cast<quint16>(selectedPort)) {
m_settings->setLocalProxyPort(static_cast<quint16>(selectedPort));
}
m_settings->setLocalProxyPortUserDefined(isUserDefinedPort);
m_settings->setLocalProxyOwnerUuid(ownerUuid);
m_settings->setLocalProxyHttpEnabled(true);
return true;
#endif
}
void SettingsController::disableLocalProxy()
{
if (m_settings->isLocalProxyHttpEnabled()) {
m_settings->setLocalProxyHttpEnabled(false);
}
}

View File

@@ -36,6 +36,10 @@ public:
Q_PROPERTY(int safeAreaTopMargin READ getSafeAreaTopMargin NOTIFY safeAreaTopMarginChanged)
Q_PROPERTY(int safeAreaBottomMargin READ getSafeAreaBottomMargin NOTIFY safeAreaBottomMarginChanged)
Q_PROPERTY(int imeHeight READ getImeHeight NOTIFY imeHeightChanged)
Q_PROPERTY(bool isLocalProxySupported READ isLocalProxySupported CONSTANT)
Q_PROPERTY(bool isLocalProxyHttpEnabled READ isLocalProxyHttpEnabled NOTIFY localProxySettingsUpdated)
Q_PROPERTY(int localProxyPort READ localProxyPort WRITE setLocalProxyPort NOTIFY localProxySettingsUpdated)
Q_PROPERTY(QString localProxyOwnerUuid READ localProxyOwnerUuid NOTIFY localProxySettingsUpdated)
public slots:
void toggleAmneziaDns(bool enable);
@@ -112,6 +116,17 @@ public slots:
bool isHomeAdLabelVisible();
void disableHomeAdLabel();
bool isLocalProxySupported() const;
bool isLocalProxyHttpEnabled() const;
int localProxyPort() const;
QString localProxyOwnerUuid() const;
bool setLocalProxyPort(int port);
bool isLocalProxyPortBusy(int port) const;
bool isLocalProxyPortUserDefined() const;
int findFirstAvailableLocalProxyPort(int startPort) const;
bool enableLocalProxy(const QString &ownerUuid, int port);
void disableLocalProxy();
signals:
void primaryDnsChanged();
void secondaryDnsChanged();
@@ -141,8 +156,13 @@ signals:
void safeAreaTopMarginChanged();
void safeAreaBottomMarginChanged();
void activityPaused();
void activityResumed();
void isHomeAdLabelVisibleChanged(bool visible);
void startMinimizedChanged();
void localProxySettingsUpdated();
void localProxyStartFailed(const QString &message);
private:
QSharedPointer<ServersModel> m_serversModel;

View File

@@ -1,5 +1,6 @@
#include "apiAccountInfoModel.h"
#include <QDateTime>
#include <QJsonObject>
#include "core/api/apiUtils.h"
@@ -31,8 +32,9 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const
return tr("Active");
}
return apiUtils::isSubscriptionExpired(m_accountInfoData.subscriptionEndDate) ? tr("<p><a style=\"color: #EB5757;\">Inactive</a>")
: tr("Active");
return apiUtils::isSubscriptionExpired(m_accountInfoData.subscriptionEndDate)
? QStringLiteral("<p><a style=\"color: #EB5757;\">%1</a>").arg(tr("Inactive"))
: QStringLiteral("<p><a style=\"color: #28c840;\">%1</a>").arg(tr("Active"));
}
case EndDateRole: {
if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) {
@@ -52,7 +54,11 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const
}
case IsComponentVisibleRole: {
return m_accountInfoData.configType == apiDefs::ConfigType::AmneziaPremiumV2
|| m_accountInfoData.configType == apiDefs::ConfigType::ExternalPremium;
|| m_accountInfoData.configType == apiDefs::ConfigType::ExternalPremium
|| m_accountInfoData.configType == apiDefs::ConfigType::ExternalTrial;
}
case IsSubscriptionRenewalAvailableRole: {
return m_accountInfoData.isRenewalAvailable;
}
case HasExpiredWorkerRole: {
for (int i = 0; i < m_issuedConfigsInfo.size(); i++) {
@@ -73,6 +79,33 @@ QVariant ApiAccountInfoModel::data(const QModelIndex &index, int role) const
}
return false;
}
case IsSubscriptionExpiredRole: {
if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) {
return false;
}
if (m_accountInfoData.isInAppPurchase) {
return false;
}
if (m_accountInfoData.subscriptionEndDate.isEmpty()) {
return false;
}
return apiUtils::isSubscriptionExpired(m_accountInfoData.subscriptionEndDate);
}
case IsSubscriptionExpiringSoonRole: {
if (m_accountInfoData.configType == apiDefs::ConfigType::AmneziaFreeV3) {
return false;
}
if (m_accountInfoData.isInAppPurchase) {
return false;
}
if (m_accountInfoData.subscriptionEndDate.isEmpty()) {
return false;
}
return apiUtils::isSubscriptionExpiringSoon(m_accountInfoData.subscriptionEndDate);
}
case IsInAppPurchaseRole: {
return m_accountInfoData.isInAppPurchase;
}
}
return QVariant();
@@ -93,7 +126,11 @@ void ApiAccountInfoModel::updateModel(const QJsonObject &accountInfoObject, cons
accountInfoData.configType = apiUtils::getConfigType(serverConfig);
const QJsonObject apiConfig = serverConfig.value(apiDefs::key::apiConfig).toObject();
accountInfoData.isInAppPurchase = apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false);
accountInfoData.subscriptionDescription = accountInfoObject.value(apiDefs::key::subscriptionDescription).toString();
accountInfoData.isRenewalAvailable = accountInfoObject.value(apiDefs::key::isRenewalAvailable).toBool(false);
for (const auto &protocol : accountInfoObject.value(apiDefs::key::supportedProtocols).toArray()) {
accountInfoData.supportedProtocols.push_back(protocol.toString());
@@ -162,8 +199,12 @@ QHash<int, QByteArray> ApiAccountInfoModel::roleNames() const
roles[ConnectedDevicesRole] = "connectedDevices";
roles[ServiceDescriptionRole] = "serviceDescription";
roles[IsComponentVisibleRole] = "isComponentVisible";
roles[IsSubscriptionRenewalAvailableRole] = "isSubscriptionRenewalAvailable";
roles[HasExpiredWorkerRole] = "hasExpiredWorker";
roles[IsProtocolSelectionSupportedRole] = "isProtocolSelectionSupported";
roles[IsSubscriptionExpiredRole] = "isSubscriptionExpired";
roles[IsSubscriptionExpiringSoonRole] = "isSubscriptionExpiringSoon";
roles[IsInAppPurchaseRole] = "isInAppPurchase";
return roles;
}

View File

@@ -18,8 +18,12 @@ public:
ServiceDescriptionRole,
EndDateRole,
IsComponentVisibleRole,
IsSubscriptionRenewalAvailableRole,
HasExpiredWorkerRole,
IsProtocolSelectionSupportedRole
IsProtocolSelectionSupportedRole,
IsSubscriptionExpiredRole,
IsSubscriptionExpiringSoonRole,
IsInAppPurchaseRole
};
explicit ApiAccountInfoModel(QObject *parent = nullptr);
@@ -31,7 +35,6 @@ public:
public slots:
void updateModel(const QJsonObject &accountInfoObject, const QJsonObject &serverConfig);
QVariant data(const QString &roleString);
QJsonArray getAvailableCountries();
QJsonArray getIssuedConfigsInfo();
@@ -56,6 +59,9 @@ private:
QStringList supportedProtocols;
QString subscriptionDescription;
bool isInAppPurchase = false;
bool isRenewalAvailable = false;
};
AccountInfoData m_accountInfoData;

View File

@@ -0,0 +1,116 @@
#include "apiBenefitsModel.h"
#include <QHash>
#include <utility>
#include <QJsonObject>
#include <QJsonValue>
namespace
{
namespace configKey
{
constexpr char title[] = "title";
constexpr char body[] = "body";
constexpr char icon[] = "icon";
constexpr char link[] = "link";
}
QString gatewayIconKeyToUrl(const QString &iconKey)
{
if (iconKey.startsWith(QLatin1String("qrc:"))) {
return iconKey;
}
static const QHash<QString, QString> map = {
{ QStringLiteral("globe-2"), QStringLiteral("qrc:/images/controls/globe-2.svg") },
{ QStringLiteral("smartphone"), QStringLiteral("qrc:/images/controls/smartphone.svg") },
{ QStringLiteral("gauge"), QStringLiteral("qrc:/images/controls/gauge.svg") },
{ QStringLiteral("infinity"), QStringLiteral("qrc:/images/controls/infinity.svg") },
{ QStringLiteral("tag"), QStringLiteral("qrc:/images/controls/tag.svg") },
{ QStringLiteral("history"), QStringLiteral("qrc:/images/controls/history.svg") },
{ QStringLiteral("info"), QStringLiteral("qrc:/images/controls/info.svg") },
{ QStringLiteral("app"), QStringLiteral("qrc:/images/controls/app.svg") },
{ QStringLiteral("download"), QStringLiteral("qrc:/images/controls/download.svg") },
{ QStringLiteral("help-circle"), QStringLiteral("qrc:/images/controls/help-circle.svg") },
};
return map.value(iconKey, QStringLiteral("qrc:/images/controls/info.svg"));
}
}
ApiBenefitsModel::ApiBenefitsModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int ApiBenefitsModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_serviceBenefits.size();
}
QVariant ApiBenefitsModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_serviceBenefits.size()) {
return {};
}
const ServiceBenefitItem &item = m_serviceBenefits.at(index.row());
switch (role) {
case IconRole:
return item.icon;
case TitleRole:
return item.title;
case BodyRole:
return item.body;
case LinkRole:
return item.link;
default:
return {};
}
}
QHash<int, QByteArray> ApiBenefitsModel::roleNames() const
{
return {
{ IconRole, "icon" },
{ TitleRole, "title" },
{ BodyRole, "body" },
{ LinkRole, "link" },
};
}
void ApiBenefitsModel::updateModel(const QJsonArray &benefits)
{
beginResetModel();
m_serviceBenefits.clear();
for (const QJsonValue &benefitValue : benefits) {
if (!benefitValue.isObject()) {
continue;
}
const QJsonObject benefitObject = benefitValue.toObject();
QString title = benefitObject.value(configKey::title).toString();
QString body = benefitObject.value(configKey::body).toString();
const bool isLink = benefitObject.value(configKey::link).toBool();
const QString iconKey = benefitObject.value(configKey::icon).toString();
if (isLink) {
body = body.trimmed();
}
if (title.isEmpty() && body.isEmpty()) {
continue;
}
ServiceBenefitItem item;
item.icon = gatewayIconKeyToUrl(iconKey);
item.title = std::move(title);
item.body = std::move(body);
item.link = isLink;
m_serviceBenefits.append(std::move(item));
}
endResetModel();
}
void ApiBenefitsModel::clear()
{
beginResetModel();
m_serviceBenefits.clear();
endResetModel();
}

View File

@@ -0,0 +1,43 @@
#ifndef APIBENEFITSMODEL_H
#define APIBENEFITSMODEL_H
#include <QAbstractListModel>
#include <QJsonArray>
#include <QString>
#include <QVector>
class ApiBenefitsModel : public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
IconRole = Qt::UserRole + 1,
TitleRole,
BodyRole,
LinkRole
};
Q_ENUM(Roles)
explicit ApiBenefitsModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
void updateModel(const QJsonArray &benefits);
void clear();
private:
struct ServiceBenefitItem
{
QString icon;
QString title;
QString body;
bool link = false;
};
QVector<ServiceBenefitItem> m_serviceBenefits;
};
#endif

View File

@@ -1,7 +1,11 @@
#include "apiServicesModel.h"
#include <QDateTime>
#include <QHash>
#include <QJsonArray>
#include <QJsonObject>
#include "core/api/apiDefs.h"
#include "logger.h"
namespace
@@ -17,15 +21,9 @@ namespace
constexpr char serviceProtocol[] = "service_protocol";
constexpr char serviceDescription[] = "service_description";
constexpr char name[] = "name";
constexpr char price[] = "price";
constexpr char speed[] = "speed";
constexpr char timelimit[] = "timelimit";
constexpr char region[] = "region";
constexpr char description[] = "description";
constexpr char cardDescription[] = "card_description";
constexpr char features[] = "features";
constexpr char serviceName[] = "service_name";
constexpr char availableCountries[] = "available_countries";
@@ -33,8 +31,9 @@ namespace
constexpr char isAvailable[] = "is_available";
constexpr char subscription[] = "subscription";
constexpr char endDate[] = "end_date";
constexpr char subscriptionPlans[] = "subscription_plans";
constexpr char minPriceLabel[] = "min_price_label";
constexpr char benefits[] = "benefits";
}
namespace serviceType
@@ -44,7 +43,9 @@ namespace
}
}
ApiServicesModel::ApiServicesModel(QObject *parent) : QAbstractListModel(parent)
ApiServicesModel::ApiServicesModel(QObject *parent)
: QAbstractListModel(parent)
, m_selectedServiceIndex(0)
{
}
@@ -68,9 +69,8 @@ QVariant ApiServicesModel::data(const QModelIndex &index, int role) const
return apiServiceData.serviceInfo.name;
}
case CardDescriptionRole: {
auto speed = apiServiceData.serviceInfo.speed;
if (serviceType == serviceType::amneziaPremium) {
return apiServiceData.serviceInfo.cardDescription.arg(speed);
return apiServiceData.serviceInfo.cardDescription;
} else if (serviceType == serviceType::amneziaFree) {
QString description = apiServiceData.serviceInfo.cardDescription;
if (!isServiceAvailable) {
@@ -91,42 +91,35 @@ QVariant ApiServicesModel::data(const QModelIndex &index, int role) const
}
return true;
}
case SpeedRole: {
return tr("%1 MBit/s").arg(apiServiceData.serviceInfo.speed);
case IsPremiumRole: {
return serviceType == serviceType::amneziaPremium;
}
case TimeLimitRole: {
auto timeLimit = apiServiceData.serviceInfo.timeLimit;
if (timeLimit == "0") {
return "";
}
return tr("%1 days").arg(timeLimit);
}
case RegionRole: {
return apiServiceData.serviceInfo.region;
}
case FeaturesRole: {
return apiServiceData.serviceInfo.features;
case HasSubscriptionPlansRole: {
return !apiServiceData.subscriptionPlansJson.isEmpty();
}
case PriceRole: {
auto price = apiServiceData.serviceInfo.price;
if (price == "free") {
return tr("Free");
}
#if defined(Q_OS_IOS) || defined(MACOS_NE)
return tr("%1 $").arg(price);
#else
return tr("%1 $/month").arg(price);
#endif
return apiServiceData.minPriceLabel;
}
case EndDateRole: {
return QDateTime::fromString(apiServiceData.subscription.endDate, Qt::ISODate).toLocalTime().toString("d MMM yyyy");
}
case TermsOfUseUrlRole: {
return apiServiceData.serviceInfo.termsOfUseUrl;
}
case PrivacyPolicyUrlRole: {
return apiServiceData.serviceInfo.privacyPolicyUrl;
}
case ShowRecommendedRole: {
return serviceType == serviceType::amneziaPremium;
}
case OrderRole: {
if (serviceType == serviceType::amneziaPremium) {
return 0;
} else if (serviceType == serviceType::amneziaFree) {
}
if (serviceType == serviceType::amneziaFree) {
return 1;
}
return QVariant();
}
}
@@ -152,12 +145,27 @@ void ApiServicesModel::updateModel(const QJsonObject &data)
}
}
if (!m_services.isEmpty() && m_selectedServiceIndex >= m_services.size()) {
m_selectedServiceIndex = 0;
}
endResetModel();
emit serviceSelectionChanged();
}
void ApiServicesModel::setServiceIndex(const int index)
{
m_selectedServiceIndex = index;
emit serviceSelectionChanged();
}
ApiServicesModel::ApiServicesData ApiServicesModel::selectedServiceData() const
{
if (m_services.isEmpty() || m_selectedServiceIndex < 0 || m_selectedServiceIndex >= m_services.size()) {
return {};
}
return m_services.at(m_selectedServiceIndex);
}
QJsonObject ApiServicesModel::getSelectedServiceInfo()
@@ -214,6 +222,16 @@ QVariant ApiServicesModel::getSelectedServiceData(const QString roleString)
return {};
}
int ApiServicesModel::serviceIndexForType(const QString &type) const
{
for (int serviceIndex = 0; serviceIndex < m_services.size(); ++serviceIndex) {
if (m_services.at(serviceIndex).type == type) {
return serviceIndex;
}
}
return -1;
}
QHash<int, QByteArray> ApiServicesModel::roleNames() const
{
QHash<int, QByteArray> roles;
@@ -221,12 +239,13 @@ QHash<int, QByteArray> ApiServicesModel::roleNames() const
roles[CardDescriptionRole] = "cardDescription";
roles[ServiceDescriptionRole] = "serviceDescription";
roles[IsServiceAvailableRole] = "isServiceAvailable";
roles[SpeedRole] = "speed";
roles[TimeLimitRole] = "timeLimit";
roles[RegionRole] = "region";
roles[FeaturesRole] = "features";
roles[IsPremiumRole] = "isPremium";
roles[HasSubscriptionPlansRole] = "hasSubscriptionPlans";
roles[PriceRole] = "price";
roles[EndDateRole] = "endDate";
roles[TermsOfUseUrlRole] = "termsOfUseUrl";
roles[PrivacyPolicyUrlRole] = "privacyPolicyUrl";
roles[ShowRecommendedRole] = "showRecommended";
roles[OrderRole] = "order";
return roles;
@@ -240,18 +259,22 @@ ApiServicesModel::ApiServicesData ApiServicesModel::getApiServicesData(const QJs
auto availableCountries = data.value(configKey::availableCountries).toArray();
auto serviceDescription = data.value(configKey::serviceDescription).toObject();
auto subscriptionObject = data.value(configKey::subscription).toObject();
auto subscriptionObject = data.value(apiDefs::key::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.serviceInfo.name = serviceDescription.value(configKey::serviceName).toString();
serviceData.serviceInfo.cardDescription = serviceDescription.value(configKey::cardDescription).toString();
serviceData.serviceInfo.description = serviceDescription.value(configKey::description).toString();
serviceData.serviceInfo.features = serviceDescription.value(configKey::features).toString();
serviceData.serviceInfo.termsOfUseUrl = serviceDescription.value(apiDefs::key::termsOfUseUrl).toString();
serviceData.serviceInfo.privacyPolicyUrl = serviceDescription.value(apiDefs::key::privacyPolicyUrl).toString();
serviceData.subscriptionPlansJson = serviceDescription.value(configKey::subscriptionPlans).toArray();
serviceData.benefits = serviceDescription.value(configKey::benefits).toArray();
serviceData.minPriceLabel = serviceDescription.value(configKey::minPriceLabel).toString().trimmed();
serviceData.supportInfo = data.value(apiDefs::key::supportInfo).toObject();
serviceData.type = serviceType;
serviceData.protocol = serviceProtocol;
@@ -267,7 +290,7 @@ ApiServicesModel::ApiServicesData ApiServicesModel::getApiServicesData(const QJs
serviceData.serviceInfo.object = serviceInfo;
serviceData.availableCountries = availableCountries;
serviceData.subscription.endDate = subscriptionObject.value(configKey::endDate).toString();
serviceData.subscription.endDate = subscriptionObject.value(apiDefs::key::endDate).toString();
return serviceData;
}

View File

@@ -4,65 +4,23 @@
#include <QAbstractListModel>
#include <QJsonArray>
#include <QJsonObject>
#include <QVector>
class ApiServicesModel : public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
NameRole = Qt::UserRole + 1,
CardDescriptionRole,
ServiceDescriptionRole,
IsServiceAvailableRole,
SpeedRole,
TimeLimitRole,
RegionRole,
FeaturesRole,
PriceRole,
EndDateRole,
OrderRole
};
explicit ApiServicesModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
public slots:
void updateModel(const QJsonObject &data);
void setServiceIndex(const int index);
QJsonObject getSelectedServiceInfo();
QString getSelectedServiceType();
QString getSelectedServiceProtocol();
QString getSelectedServiceName();
QJsonArray getSelectedServiceCountries();
QString getCountryCode();
QString getStoreEndpoint();
QVariant getSelectedServiceData(const QString roleString);
protected:
QHash<int, QByteArray> roleNames() const override;
private:
struct ServiceInfo
{
QString name;
QString speed;
QString timeLimit;
QString region;
QString price;
QString description;
QString features;
QString cardDescription;
QString termsOfUseUrl;
QString privacyPolicyUrl;
QJsonObject object;
};
@@ -80,11 +38,66 @@ private:
QString storeEndpoint;
ServiceInfo serviceInfo;
QJsonObject supportInfo;
Subscription subscription;
QJsonArray availableCountries;
QJsonArray subscriptionPlansJson;
QJsonArray benefits;
QString minPriceLabel;
};
enum Roles {
NameRole = Qt::UserRole + 1,
CardDescriptionRole,
ServiceDescriptionRole,
IsServiceAvailableRole,
IsPremiumRole,
HasSubscriptionPlansRole,
PriceRole,
EndDateRole,
TermsOfUseUrlRole,
PrivacyPolicyUrlRole,
ShowRecommendedRole,
OrderRole
};
explicit ApiServicesModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
ApiServicesData selectedServiceData() const;
public slots:
void updateModel(const QJsonObject &data);
void setServiceIndex(const int index);
QJsonObject getSelectedServiceInfo();
QString getSelectedServiceType();
QString getSelectedServiceProtocol();
QString getSelectedServiceName();
QJsonArray getSelectedServiceCountries();
QString getCountryCode();
QString getStoreEndpoint();
QVariant getSelectedServiceData(const QString roleString);
Q_INVOKABLE int serviceIndexForType(const QString &type) const;
signals:
void serviceSelectionChanged();
protected:
QHash<int, QByteArray> roleNames() const override;
private:
ApiServicesData getApiServicesData(const QJsonObject &data);
QString m_countryCode;
@@ -93,4 +106,4 @@ private:
int m_selectedServiceIndex;
};
#endif // APISERVICESMODEL_H
#endif

View File

@@ -0,0 +1,131 @@
#include "apiSubscriptionPlansModel.h"
#include <QJsonObject>
#include <QJsonValue>
#include <QModelIndex>
#include <utility>
namespace
{
namespace configKey
{
constexpr char billingPeriod[] = "billing_period";
constexpr char priceLabel[] = "price_label";
constexpr char subtitle[] = "subtitle";
constexpr char recommended[] = "recommended";
constexpr char checkoutUrl[] = "checkout_url";
constexpr char isTrial[] = "is_trial";
constexpr char serviceProtocol[] = "service_protocol";
constexpr char storeProductId[] = "store_product_id";
}
}
ApiSubscriptionPlansModel::ApiSubscriptionPlansModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int ApiSubscriptionPlansModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_subscriptionPlans.size();
}
QVariant ApiSubscriptionPlansModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_subscriptionPlans.size()) {
return {};
}
const SubscriptionPlanItem &plan = m_subscriptionPlans.at(index.row());
switch (role) {
case BillingPeriodRole:
return plan.billingPeriod;
case PriceLabelRole:
return plan.priceLabel;
case SubtitleRole:
return plan.subtitle;
case RecommendedRole:
return plan.recommended;
case CheckoutUrlRole:
return plan.checkoutUrl;
case IsTrialRole:
return plan.isTrial;
case ServiceProtocolRole:
return plan.serviceProtocol;
case StoreProductIdRole:
return plan.storeProductId;
default:
return {};
}
}
QHash<int, QByteArray> ApiSubscriptionPlansModel::roleNames() const
{
return {
{ BillingPeriodRole, "billingPeriod" },
{ PriceLabelRole, "priceLabel" },
{ SubtitleRole, "subtitle" },
{ RecommendedRole, "recommended" },
{ CheckoutUrlRole, "checkoutUrl" },
{ IsTrialRole, "isTrial" },
{ ServiceProtocolRole, "serviceProtocol" },
{ StoreProductIdRole, "storeProductId" },
};
}
void ApiSubscriptionPlansModel::updateModel(const QJsonArray &arr)
{
beginResetModel();
m_subscriptionPlans.clear();
m_subscriptionPlans.reserve(arr.size());
for (const QJsonValue &planValue : arr) {
if (!planValue.isObject()) {
continue;
}
const QJsonObject planObject = planValue.toObject();
SubscriptionPlanItem subscriptionPlan;
subscriptionPlan.billingPeriod = planObject.value(configKey::billingPeriod).toString();
subscriptionPlan.priceLabel = planObject.value(configKey::priceLabel).toString();
subscriptionPlan.subtitle = planObject.value(configKey::subtitle).toString();
subscriptionPlan.recommended = planObject.value(configKey::recommended).toBool();
subscriptionPlan.checkoutUrl = planObject.value(configKey::checkoutUrl).toString();
subscriptionPlan.isTrial = planObject.value(configKey::isTrial).toBool();
subscriptionPlan.serviceProtocol = planObject.value(configKey::serviceProtocol).toString();
subscriptionPlan.storeProductId = planObject.value(configKey::storeProductId).toString();
m_subscriptionPlans.append(std::move(subscriptionPlan));
}
endResetModel();
}
void ApiSubscriptionPlansModel::clear()
{
beginResetModel();
m_subscriptionPlans.clear();
endResetModel();
}
QVariantMap ApiSubscriptionPlansModel::planAt(int row) const
{
if (row < 0 || row >= m_subscriptionPlans.size()) {
return {};
}
const QModelIndex modelIndex = index(row, 0);
QVariantMap planMap;
const QHash<int, QByteArray> roles = roleNames();
for (auto roleIt = roles.cbegin(); roleIt != roles.cend(); ++roleIt) {
planMap.insert(QString::fromUtf8(roleIt.value()), data(modelIndex, roleIt.key()));
}
return planMap;
}
int ApiSubscriptionPlansModel::recommendedRowIndex() const
{
for (int planIndex = 0; planIndex < m_subscriptionPlans.size(); ++planIndex) {
if (m_subscriptionPlans.at(planIndex).recommended) {
return planIndex;
}
}
return 0;
}

View File

@@ -0,0 +1,53 @@
#ifndef APISUBSCRIPTIONPLANSMODEL_H
#define APISUBSCRIPTIONPLANSMODEL_H
#include <QAbstractListModel>
#include <QJsonArray>
#include <QVector>
class ApiSubscriptionPlansModel : public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
BillingPeriodRole = Qt::UserRole + 1,
PriceLabelRole,
SubtitleRole,
RecommendedRole,
CheckoutUrlRole,
IsTrialRole,
ServiceProtocolRole,
StoreProductIdRole
};
Q_ENUM(Roles)
explicit ApiSubscriptionPlansModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
void updateModel(const QJsonArray &arr);
void clear();
Q_INVOKABLE QVariantMap planAt(int row) const;
Q_INVOKABLE int recommendedRowIndex() const;
private:
struct SubscriptionPlanItem
{
QString billingPeriod;
QString priceLabel;
QString subtitle;
bool recommended = false;
QString checkoutUrl;
bool isTrial = false;
QString serviceProtocol;
QString storeProductId;
};
QVector<SubscriptionPlanItem> m_subscriptionPlans;
};
#endif

View File

@@ -179,6 +179,43 @@ QVariant ServersModel::data(const QModelIndex &index, int role) const
case AdEndpointRole: {
return apiConfig.value(apiDefs::key::serviceInfo).toObject().value(apiDefs::key::adEndpoint).toString();
}
case IsRenewalAvailableRole: {
return apiConfig.value(apiDefs::key::serviceInfo).toObject().value(apiDefs::key::isRenewalAvailable).toBool(false);
}
case IsSubscriptionExpiredRole: {
if (configVersion != apiDefs::ConfigSource::AmneziaGateway) {
return false;
}
if (apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false)) {
return false;
}
if (apiConfig.value(apiDefs::key::subscriptionExpiredByServer).toBool(false)) {
return true;
}
const QString endDate =
apiConfig.value(apiDefs::key::subscription).toObject().value(apiDefs::key::endDate).toString();
if (endDate.isEmpty()) {
return false;
}
return apiUtils::isSubscriptionExpired(endDate);
}
case IsSubscriptionExpiringSoonRole: {
if (configVersion != apiDefs::ConfigSource::AmneziaGateway) {
return false;
}
if (apiConfig.value(apiDefs::key::isInAppPurchase).toBool(false)) {
return false;
}
const QString endDate =
apiConfig.value(apiDefs::key::subscription).toObject().value(apiDefs::key::endDate).toString();
if (endDate.isEmpty()) {
return false;
}
return apiUtils::isSubscriptionExpiringSoon(endDate);
}
case ServerUuidRole: {
return server.value(config_key::server_uuid).toString();
}
}
return QVariant();
@@ -442,6 +479,12 @@ QHash<int, QByteArray> ServersModel::roleNames() const
roles[AdHeaderRole] = "adHeader";
roles[AdDescriptionRole] = "adDescription";
roles[AdEndpointRole] = "adEndpoint";
roles[IsRenewalAvailableRole] = "isRenewalAvailable";
roles[IsSubscriptionExpiredRole] = "isSubscriptionExpired";
roles[IsSubscriptionExpiringSoonRole] = "isSubscriptionExpiringSoon";
roles[ServerUuidRole] = "serverUuid";
return roles;
}
@@ -727,21 +770,21 @@ bool ServersModel::isServerFromApiAlreadyExists(const QString &userCountryCode,
return false;
}
bool ServersModel::hasServerWithVpnKey(const QString &vpnKey) const
int ServersModel::indexOfServerWithVpnKey(const QString &vpnKey) const
{
const QString normalizedInput = normalizeVpnKey(vpnKey);
if (normalizedInput.isEmpty()) {
return false;
return -1;
}
for (const auto &server : std::as_const(m_servers)) {
const auto apiConfig = server.toObject().value(configKey::apiConfig).toObject();
for (int i = 0; i < m_servers.size(); ++i) {
const auto apiConfig = m_servers.at(i).toObject().value(configKey::apiConfig).toObject();
const QString existingKey = normalizeVpnKey(apiConfig.value(apiDefs::key::vpnKey).toString());
if (!existingKey.isEmpty() && existingKey == normalizedInput) {
return true;
return i;
}
}
return false;
return -1;
}
bool ServersModel::serverHasInstalledContainers(const int serverIndex) const
@@ -972,3 +1015,18 @@ QString ServersModel::adDescription()
{
return data(m_defaultServerIndex, AdDescriptionRole).toString();
}
QString ServersModel::getServerUuid(int index) const
{
if (index < 0 || index >= m_servers.size())
return QString();
return m_servers.at(index).toObject().value(config_key::server_uuid).toString();
}
QString ServersModel::getProcessedServerUuid() const
{
qDebug() << "getProcessedServerIndex" << m_processedServerIndex;
qDebug() << "getServerUuid" << getServerUuid(m_processedServerIndex);
return getServerUuid(m_processedServerIndex);
}

View File

@@ -51,8 +51,14 @@ public:
AdHeaderRole,
AdDescriptionRole,
AdEndpointRole,
IsRenewalAvailableRole,
HasAmneziaDns
IsSubscriptionExpiredRole,
IsSubscriptionExpiringSoonRole,
HasAmneziaDns,
ServerUuidRole
};
ServersModel(std::shared_ptr<Settings> settings, QObject *parent = nullptr);
@@ -82,6 +88,7 @@ public:
Q_PROPERTY(int processedIndex READ getProcessedServerIndex WRITE setProcessedServerIndex NOTIFY processedServerIndexChanged)
Q_PROPERTY(bool processedServerIsPremium READ processedServerIsPremium NOTIFY processedServerChanged)
Q_PROPERTY(QString processedServerUuid READ getProcessedServerUuid NOTIFY processedServerChanged)
Q_PROPERTY(bool isAdVisible READ isAdVisible NOTIFY defaultServerIndexChanged)
Q_PROPERTY(QString adHeader READ adHeader NOTIFY defaultServerIndexChanged)
@@ -140,7 +147,7 @@ public slots:
bool isServerFromApiAlreadyExists(const quint16 crc);
bool isServerFromApiAlreadyExists(const QString &userCountryCode, const QString &serviceType, const QString &serviceProtocol);
bool hasServerWithVpnKey(const QString &vpnKey) const;
int indexOfServerWithVpnKey(const QString &vpnKey) const;
QVariant getDefaultServerData(const QString roleString);
@@ -156,6 +163,10 @@ public slots:
bool isAdVisible();
QString adHeader();
QString adDescription();
QString getServerUuid(int index) const;
QString getProcessedServerUuid() const;
protected:
QHash<int, QByteArray> roleNames() const override;

View File

@@ -0,0 +1,64 @@
import QtQuick
import QtQuick.Layouts
import Style 1.0
import "../Controls2/TextTypes"
RowLayout {
id: root
property string iconSource: ""
property string titleText: ""
property string bodyText: ""
property bool link: false
readonly property string bodyLineText: root.link && root.bodyText.length > 0 ? "@" + root.bodyText : root.bodyText
readonly property bool bodyClickable: root.link && root.bodyText.length > 0
spacing: 12
Image {
Layout.alignment: Qt.AlignTop
Layout.preferredWidth: 22
Layout.preferredHeight: 22
source: root.iconSource
fillMode: Image.PreserveAspectFit
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
LabelTextType {
Layout.fillWidth: true
text: root.titleText
color: AmneziaStyle.color.paleGray
font.pixelSize: 16
font.weight: Font.DemiBold
wrapMode: Text.Wrap
}
Item {
Layout.fillWidth: true
implicitHeight: bodyLabel.implicitHeight
LabelTextType {
id: bodyLabel
width: parent.width
text: root.bodyLineText
color: root.link ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.mutedGray
font.pixelSize: 14
wrapMode: Text.Wrap
}
MouseArea {
anchors.fill: bodyLabel
visible: root.bodyClickable
cursorShape: Qt.PointingHandCursor
onClicked: Qt.openUrlExternally("https://t.me/" + root.bodyText)
}
}
}
}

View File

@@ -0,0 +1,40 @@
import QtQuick
import QtQuick.Layouts
import "."
import Style 1.0
Rectangle {
id: root
property var benefitsModel: null
visible: benefitsModel && benefitsModel.rowCount() > 0
radius: 16
color: AmneziaStyle.color.benefitsPanelBackground
implicitHeight: inner.implicitHeight + 24
ColumnLayout {
id: inner
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 12
spacing: 20
Repeater {
model: benefitsModel
delegate: BenefitRow {
Layout.fillWidth: true
iconSource: model.icon
titleText: model.title
bodyText: model.body
link: !!model.link
}
}
}
}

View File

@@ -67,7 +67,12 @@ ListViewType {
Layout.fillWidth: true
text: name
descriptionText: serverDescription
descriptionText: isServerFromGatewayApi && (isSubscriptionExpired || isSubscriptionExpiringSoon)
? (isSubscriptionExpired ? qsTr("Subscription expired. Please renew") : qsTr("Subscription expiring soon"))
: serverDescription
descriptionColor: isServerFromGatewayApi && (isSubscriptionExpired || isSubscriptionExpiringSoon)
? (isSubscriptionExpired ? AmneziaStyle.color.vibrantRed : AmneziaStyle.color.goldenApricot)
: AmneziaStyle.color.mutedGray
checked: index === root.selectedIndex
checkable: !ConnectionController.isConnected

View File

@@ -0,0 +1,108 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import PageEnum 1.0
import Style 1.0
import "../Controls2"
import "../Controls2/TextTypes"
DrawerType2 {
id: root
property bool isRenewalAvailable: false
onOpened: {
isRenewalAvailable = ServersModel.getProcessedServerData("isRenewalAvailable") && !ApiAccountInfoModel.data("isInAppPurchase")
}
expandedStateContent: ColumnLayout {
id: content
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
spacing: 0
onImplicitHeightChanged: {
root.expandedHeight = content.implicitHeight + 32 + SettingsController.safeAreaBottomMargin
}
Item {
Layout.fillWidth: true
Layout.topMargin: 24
Layout.rightMargin: 16
Layout.leftMargin: 16
implicitHeight: titleText.implicitHeight
Header2TextType {
id: titleText
anchors.left: parent.left
anchors.right: parent.right
text: ServersModel.getProcessedServerData("name") + qsTr(" subscription has expired")
horizontalAlignment: Text.AlignLeft
}
}
ParagraphTextType {
visible: root.isRenewalAvailable
Layout.fillWidth: true
Layout.topMargin: 8
Layout.rightMargin: 16
Layout.leftMargin: 16
text: qsTr("Renew to continue using VPN")
horizontalAlignment: Text.AlignLeft
}
BasicButtonType {
visible: root.isRenewalAvailable
Layout.fillWidth: true
Layout.topMargin: 16
Layout.rightMargin: 16
Layout.leftMargin: 16
text: qsTr("Renew")
defaultColor: AmneziaStyle.color.paleGray
hoveredColor: AmneziaStyle.color.lightGray
pressedColor: AmneziaStyle.color.mutedGray
textColor: AmneziaStyle.color.midnightBlack
clickedFunc: function() {
ApiSettingsController.getRenewalLink()
}
}
BasicButtonType {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 8
Layout.bottomMargin: 8
implicitHeight: 25
defaultColor: AmneziaStyle.color.transparent
hoveredColor: AmneziaStyle.color.translucentWhite
pressedColor: AmneziaStyle.color.sheerWhite
textColor: AmneziaStyle.color.goldenApricot
text: qsTr("Support")
clickedFunc: function() {
PageController.showBusyIndicator(true)
let result = ApiSettingsController.getAccountInfo(false)
PageController.showBusyIndicator(false)
if (result) {
root.closeTriggered()
PageController.goToPage(PageEnum.PageSettingsApiSupport)
}
}
}
}
}

View File

@@ -0,0 +1,94 @@
import QtQuick
import QtQuick.Layouts
import Style 1.0
import "../Controls2/TextTypes"
Rectangle {
id: root
property bool selected: false
property string billingPeriod: ""
property string priceLabel: ""
property string subtitle: ""
property bool showRecommendedBadge: false
property string recommendedText: "Recommended"
signal selectRequested
implicitHeight: cardLayout.implicitHeight + 28
radius: 16
color: AmneziaStyle.color.transparent
border.width: selected ? 2 : 1
border.color: selected ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.charcoalGray
ColumnLayout {
id: cardLayout
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 16
anchors.rightMargin: 16
spacing: 8
RowLayout {
Layout.fillWidth: true
LabelTextType {
Layout.fillWidth: true
text: root.billingPeriod
color: root.selected ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.paleGray
font.pixelSize: 17
font.weight: Font.DemiBold
wrapMode: Text.Wrap
}
LabelTextType {
text: root.priceLabel
color: root.selected ? AmneziaStyle.color.goldenApricot : AmneziaStyle.color.paleGray
font.pixelSize: 17
font.weight: Font.DemiBold
}
}
RowLayout {
Layout.fillWidth: true
visible: root.subtitle.length > 0 || root.showRecommendedBadge
LabelTextType {
Layout.fillWidth: true
text: root.subtitle
color: AmneziaStyle.color.mutedGray
font.pixelSize: 13
wrapMode: Text.Wrap
}
Rectangle {
visible: root.showRecommendedBadge
Layout.alignment: Qt.AlignVCenter
radius: 10
color: AmneziaStyle.color.softViolet
implicitHeight: recLabel.implicitHeight + 8
implicitWidth: recLabel.implicitWidth + 16
LabelTextType {
id: recLabel
anchors.centerIn: parent
text: root.recommendedText
color: AmneziaStyle.color.midnightBlack
font.pixelSize: 11
font.weight: Font.Medium
}
}
}
}
MouseArea {
anchors.fill: parent
onClicked: root.selectRequested()
}
}

View File

@@ -0,0 +1,37 @@
import QtQuick
import QtQuick.Layouts
import Style 1.0
import "../Controls2/TextTypes"
ParagraphTextType {
id: root
property string termsUrl: ""
property string privacyUrl: ""
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
textFormat: Text.RichText
color: AmneziaStyle.color.mutedGray
font.pixelSize: 12
lineHeight: 1.35
lineHeightMode: Text.ProportionalHeight
text: qsTr("By continuing, you agree to the <a href=\"%1\" style=\"color: %3;\">Terms of Use</a> and <a href=\"%2\" style=\"color: %3;\">Privacy Policy</a>")
.arg(root.termsUrl)
.arg(root.privacyUrl)
.arg(AmneziaStyle.color.goldenApricotString)
onLinkActivated: function(link) {
Qt.openUrlExternally(link)
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
}
}

View File

@@ -12,6 +12,7 @@ Item {
property int headerTextMaximumLineCount: 2
property int headerTextElide: Qt.ElideRight
property string descriptionText
property string descriptionColor: AmneziaStyle.color.mutedGray
property alias headerRow: headerRow
implicitWidth: content.implicitWidth
@@ -38,7 +39,7 @@ Item {
Layout.topMargin: 16
Layout.fillWidth: true
text: root.descriptionText
color: AmneziaStyle.color.mutedGray
color: root.descriptionColor
visible: root.descriptionText !== ""
}
}

View File

@@ -13,6 +13,11 @@ Button {
property string bodyText
property string footerText
property color headerTextColor: AmneziaStyle.color.paleGray
property color bodyTextColor: AmneziaStyle.color.mutedGray
property bool showRecommendedBadge: false
property string recommendedText: ""
property string hoveredColor: AmneziaStyle.color.slateGray
property string defaultColor: AmneziaStyle.color.onyxBlack
@@ -23,11 +28,12 @@ Button {
property string leftImageSource
property real textOpacity: 1.0
property alias focusItem: rightImage
hoverEnabled: true
clip: false
readonly property real cardTextOpacity: !enabled ? 1.0 : pressed ? 0.7 : hovered ? 0.8 : 1.0
background: Rectangle {
id: backgroundRect
@@ -35,7 +41,7 @@ Button {
anchors.fill: parent
radius: 16
color: defaultColor
color: root.hovered && root.enabled ? root.hoveredColor : root.defaultColor
Behavior on color {
PropertyAnimation { duration: 200 }
@@ -43,143 +49,157 @@ Button {
}
contentItem: Item {
id: contentRoot
z: 1
anchors.left: parent.left
anchors.right: parent.right
implicitHeight: content.implicitHeight
readonly property bool badgeVisible: root.showRecommendedBadge && root.recommendedText !== ""
RowLayout {
id: content
implicitHeight: layoutCol.implicitHeight
anchors.fill: parent
ColumnLayout {
id: layoutCol
Image {
id: leftImage
source: leftImageSource
anchors.left: parent.left
anchors.right: parent.right
spacing: 0
visible: leftImageSource !== ""
Item {
id: badgeTopSpacer
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
Layout.topMargin: 24
Layout.bottomMargin: 24
Layout.leftMargin: 24
}
ColumnLayout {
ListItemTitleType {
text: root.headerText
visible: text !== ""
Layout.fillWidth: true
Layout.rightMargin: 16
Layout.leftMargin: 16
Layout.topMargin: 16
Layout.bottomMargin: root.bodyText !== "" ? 0 : 16
opacity: root.textOpacity
}
CaptionTextType {
text: root.bodyText
visible: text !== ""
color: AmneziaStyle.color.mutedGray
textFormat: Text.RichText
Layout.fillWidth: true
Layout.rightMargin: 16
Layout.leftMargin: 16
Layout.bottomMargin: root.footerText !== "" ? 0 : 16
opacity: root.textOpacity
}
ButtonTextType {
text: root.footerText
visible: text !== ""
color: AmneziaStyle.color.mutedGray
Layout.fillWidth: true
Layout.rightMargin: 16
Layout.leftMargin: 16
Layout.topMargin: 16
Layout.bottomMargin: 16
opacity: root.textOpacity
}
}
ImageButtonType {
id: rightImage
implicitWidth: 40
implicitHeight: 40
hoverEnabled: false
image: rightImageSource
imageColor: rightImageColor
visible: rightImageSource ? true : false
Layout.alignment: Qt.AlignRight | Qt.AlignTop
Layout.topMargin: 16
Layout.bottomMargin: 16
Layout.rightMargin: 16
Layout.fillWidth: true
Layout.preferredHeight: contentRoot.badgeVisible ? (recBadge.height / 2 + 8) : 0
Rectangle {
id: rightImageBackground
id: recBadge
anchors.fill: parent
radius: 12
color: "transparent"
visible: contentRoot.badgeVisible
z: 2
Behavior on color {
PropertyAnimation { duration: 200 }
anchors.left: parent.left
anchors.leftMargin: 20
anchors.verticalCenter: parent.top
radius: 10
color: AmneziaStyle.color.softViolet
implicitHeight: recLabel.implicitHeight + 8
implicitWidth: recLabel.implicitWidth + 16
width: implicitWidth
height: implicitHeight
BadgeTextType {
id: recLabel
anchors.centerIn: parent
text: root.recommendedText
}
}
}
RowLayout {
id: content
Layout.fillWidth: true
Image {
id: leftImage
source: leftImageSource
visible: leftImageSource !== ""
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
Layout.topMargin: 24
Layout.bottomMargin: 24
Layout.leftMargin: 24
}
ColumnLayout {
ListItemTitleType {
text: root.headerText
visible: text !== ""
color: root.headerTextColor
Layout.fillWidth: true
Layout.rightMargin: 16
Layout.leftMargin: 16
Layout.topMargin: contentRoot.badgeVisible ? 0 : 16
Layout.bottomMargin: root.bodyText !== "" ? 0 : 16
opacity: root.cardTextOpacity
}
CaptionTextType {
text: root.bodyText
visible: text !== ""
color: root.bodyTextColor
textFormat: Text.RichText
onLinkActivated: function(link) {
Qt.openUrlExternally(link)
}
Layout.fillWidth: true
Layout.rightMargin: 16
Layout.leftMargin: 16
Layout.bottomMargin: root.footerText !== "" ? 0 : 8
opacity: root.cardTextOpacity
}
ButtonTextType {
text: root.footerText
visible: text !== ""
color: AmneziaStyle.color.mutedGray
Layout.fillWidth: true
Layout.rightMargin: 16
Layout.leftMargin: 16
Layout.topMargin: 16
Layout.bottomMargin: 16
opacity: root.cardTextOpacity
}
}
onClicked: {
root.clicked()
ImageButtonType {
id: rightImage
implicitWidth: 40
implicitHeight: 40
hoverEnabled: false
image: rightImageSource
imageColor: rightImageColor
visible: rightImageSource ? true : false
Layout.alignment: Qt.AlignRight | Qt.AlignTop
Layout.topMargin: 16
Layout.bottomMargin: 16
Layout.rightMargin: 16
Rectangle {
id: rightImageBackground
anchors.fill: parent
radius: 12
color: root.pressed ? rightImage.pressedColor : root.hovered ? rightImage.hoveredColor : rightImage.defaultColor
Behavior on color {
PropertyAnimation { duration: 200 }
}
}
onClicked: {
root.clicked()
}
}
}
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
enabled: root.enabled
onEntered: {
backgroundRect.color = root.hoveredColor
if (rightImageSource) {
rightImageBackground.color = rightImage.hoveredColor
}
root.textOpacity = 0.8
}
onExited: {
backgroundRect.color = root.defaultColor
if (rightImageSource) {
rightImageBackground.color = rightImage.defaultColor
}
root.textOpacity = 1
}
onPressedChanged: {
if (rightImageSource) {
rightImageBackground.color = pressed ? rightImage.pressedColor : entered ? rightImage.hoveredColor : rightImage.defaultColor
}
root.textOpacity = 0.7
}
onClicked: {
root.clicked()
}
}
}

View File

@@ -1,6 +1,7 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Qt5Compat.GraphicalEffects
import Style 1.0
@@ -37,6 +38,7 @@ Item {
property int borderFocusedWidth: 1
property string rightImageColor: AmneziaStyle.color.paleGray
property string leftImageColor: ""
property bool descriptionOnTop: false
property bool hideDescription: true
@@ -140,6 +142,14 @@ Item {
anchors.centerIn: parent
source: leftImageSource
visible: leftImageColor === ""
}
ColorOverlay {
anchors.fill: leftImage
source: leftImage
color: leftImageColor
visible: leftImageColor !== ""
}
}

View File

@@ -153,7 +153,7 @@ Switch {
Keys.onSpacePressed: event => handleSwitch(event)
function handleSwitch(event) {
if (!event.isAutoRepeat) {
if (root.enabled && !event.isAutoRepeat) {
root.checked = !root.checked
root.toggled()
}

View File

@@ -1,228 +1,232 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Style 1.0
import "TextTypes"
Item {
id: root
property string headerText
property string headerTextDisabledColor: AmneziaStyle.color.charcoalGray
property string headerTextColor: AmneziaStyle.color.mutedGray
property alias errorText: errorField.text
property bool checkEmptyText: false
property bool rightButtonClickedOnEnter: false
property string buttonText
property string buttonImageSource
property var clickedFunc
property alias textField: textField
property string textFieldTextColor: AmneziaStyle.color.paleGray
property string textFieldTextDisabledColor: AmneziaStyle.color.mutedGray
property bool textFieldEditable: true
property string borderColor: AmneziaStyle.color.slateGray
property string borderFocusedColor: AmneziaStyle.color.paleGray
property string backgroundColor: AmneziaStyle.color.onyxBlack
property string backgroundDisabledColor: AmneziaStyle.color.transparent
property string bgBorderHoveredColor: AmneziaStyle.color.charcoalGray
implicitWidth: content.implicitWidth
implicitHeight: content.implicitHeight
Keys.onTabPressed: {
FocusController.nextKeyTabItem()
}
Keys.onBacktabPressed: {
FocusController.previousKeyTabItem()
}
Keys.onUpPressed: {
FocusController.nextKeyUpItem()
}
Keys.onDownPressed: {
FocusController.nextKeyDownItem()
}
ColumnLayout {
id: content
anchors.fill: parent
Rectangle {
id: backgroud
Layout.fillWidth: true
Layout.preferredHeight: input.implicitHeight
color: root.enabled ? root.backgroundColor : root.backgroundDisabledColor
radius: 16
border.color: getBackgroundBorderColor(root.borderColor)
border.width: 1
Behavior on border.color {
PropertyAnimation { duration: 200 }
}
RowLayout {
id: input
anchors.fill: backgroud
ColumnLayout {
Layout.margins: 16
LabelTextType {
text: root.headerText
color: root.enabled ? root.headerTextColor : root.headerTextDisabledColor
visible: text !== ""
Layout.fillWidth: true
}
TextField {
id: textField
property bool isFocusable: true
Keys.onTabPressed: {
FocusController.nextKeyTabItem()
}
Keys.onBacktabPressed: {
FocusController.previousKeyTabItem()
}
enabled: root.textFieldEditable
color: root.enabled ? root.textFieldTextColor : root.textFieldTextDisabledColor
inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhSensitiveData | Qt.ImhNoPredictiveText
placeholderTextColor: AmneziaStyle.color.charcoalGray
selectionColor: AmneziaStyle.color.richBrown
selectedTextColor: AmneziaStyle.color.paleGray
font.pixelSize: 16
font.weight: 400
font.family: "PT Root UI VF"
height: 24
Layout.fillWidth: true
topPadding: 0
rightPadding: 0
leftPadding: 0
bottomPadding: 0
background: Rectangle {
anchors.fill: parent
color: root.backgroundDisabledColor
}
onTextChanged: {
root.errorText = ""
}
onActiveFocusChanged: {
if (root.checkEmptyText && text === "") {
root.errorText = qsTr("The field can't be empty")
}
}
ContextMenu.menu: ContextMenuType {
textObj: textField
}
onFocusChanged: {
backgroud.border.color = getBackgroundBorderColor(root.borderColor)
}
}
}
}
}
SmallTextType {
id: errorField
text: root.errorText
visible: root.errorText !== ""
color: AmneziaStyle.color.vibrantRed
Layout.fillWidth: true
}
}
MouseArea {
anchors.fill: root
cursorShape: Qt.IBeamCursor
hoverEnabled: true
onPressed: function(mouse) {
textField.forceActiveFocus()
mouse.accepted = false
backgroud.border.color = getBackgroundBorderColor(root.borderColor)
}
onEntered: {
backgroud.border.color = getBackgroundBorderColor(bgBorderHoveredColor)
}
onExited: {
backgroud.border.color = getBackgroundBorderColor(root.borderColor)
}
}
BasicButtonType {
visible: (root.buttonText !== "") || (root.buttonImageSource !== "")
focusPolicy: Qt.NoFocus
text: root.buttonText
leftImageSource: root.buttonImageSource
anchors.top: content.top
anchors.bottom: content.bottom
anchors.right: content.right
height: content.implicitHeight
width: content.implicitHeight
squareLeftSide: true
clickedFunc: function() {
if (root.clickedFunc && typeof root.clickedFunc === "function") {
root.clickedFunc()
}
}
}
function getBackgroundBorderColor(noneFocusedColor) {
return textField.focus ? root.borderFocusedColor : noneFocusedColor
}
Keys.onEnterPressed: {
if (root.rightButtonClickedOnEnter && root.clickedFunc && typeof root.clickedFunc === "function") {
clickedFunc()
}
// if (KeyNavigation.tab) {
// KeyNavigation.tab.forceActiveFocus();
// }
}
Keys.onReturnPressed: {
if (root.rightButtonClickedOnEnter &&root.clickedFunc && typeof root.clickedFunc === "function") {
clickedFunc()
}
// if (KeyNavigation.tab) {
// KeyNavigation.tab.forceActiveFocus();
// }
}
}
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Style 1.0
import "TextTypes"
Item {
id: root
property string headerText
property string headerTextDisabledColor: AmneziaStyle.color.charcoalGray
property string headerTextColor: AmneziaStyle.color.mutedGray
property alias errorText: errorField.text
property bool clearErrorOnTextChanged: true
property bool checkEmptyText: false
property bool rightButtonClickedOnEnter: false
property string buttonText
property string buttonImageSource
property var clickedFunc
property alias textField: textField
property string textFieldTextColor: AmneziaStyle.color.paleGray
property string textFieldTextDisabledColor: AmneziaStyle.color.mutedGray
property bool textFieldEditable: true
property string borderColor: AmneziaStyle.color.slateGray
property string borderFocusedColor: AmneziaStyle.color.paleGray
property string backgroundColor: AmneziaStyle.color.onyxBlack
property string backgroundDisabledColor: AmneziaStyle.color.transparent
property string bgBorderHoveredColor: AmneziaStyle.color.charcoalGray
implicitWidth: content.implicitWidth
implicitHeight: content.implicitHeight
Keys.onTabPressed: {
FocusController.nextKeyTabItem()
}
Keys.onBacktabPressed: {
FocusController.previousKeyTabItem()
}
Keys.onUpPressed: {
FocusController.nextKeyUpItem()
}
Keys.onDownPressed: {
FocusController.nextKeyDownItem()
}
ColumnLayout {
id: content
anchors.fill: parent
Rectangle {
id: backgroud
Layout.fillWidth: true
Layout.preferredHeight: input.implicitHeight
color: root.enabled ? root.backgroundColor : root.backgroundDisabledColor
radius: 16
border.color: getBackgroundBorderColor(root.borderColor)
border.width: 1
Behavior on border.color {
PropertyAnimation { duration: 200 }
}
RowLayout {
id: input
anchors.fill: backgroud
ColumnLayout {
Layout.margins: 16
LabelTextType {
text: root.headerText
color: root.enabled ? root.headerTextColor : root.headerTextDisabledColor
visible: text !== ""
Layout.fillWidth: true
}
TextField {
id: textField
property bool isFocusable: true
Keys.onTabPressed: {
FocusController.nextKeyTabItem()
}
Keys.onBacktabPressed: {
FocusController.previousKeyTabItem()
}
enabled: root.textFieldEditable
color: root.enabled ? root.textFieldTextColor : root.textFieldTextDisabledColor
inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhSensitiveData | Qt.ImhNoPredictiveText
placeholderTextColor: AmneziaStyle.color.charcoalGray
selectionColor: AmneziaStyle.color.richBrown
selectedTextColor: AmneziaStyle.color.paleGray
font.pixelSize: 16
font.weight: 400
font.family: "PT Root UI VF"
height: 24
Layout.fillWidth: true
topPadding: 0
rightPadding: 0
leftPadding: 0
bottomPadding: 0
background: Rectangle {
anchors.fill: parent
color: root.backgroundDisabledColor
}
onTextChanged: {
if (root.clearErrorOnTextChanged) {
root.errorText = ""
}
}
onActiveFocusChanged: {
if (root.checkEmptyText && text === "") {
root.errorText = qsTr("The field can't be empty")
}
}
ContextMenu.menu: ContextMenuType {
textObj: textField
}
onFocusChanged: {
backgroud.border.color = getBackgroundBorderColor(root.borderColor)
}
}
}
}
}
SmallTextType {
id: errorField
text: root.errorText
visible: root.errorText !== ""
color: AmneziaStyle.color.vibrantRed
Layout.fillWidth: true
}
}
MouseArea {
anchors.fill: root
cursorShape: Qt.IBeamCursor
hoverEnabled: true
onPressed: function(mouse) {
textField.forceActiveFocus()
mouse.accepted = false
backgroud.border.color = getBackgroundBorderColor(root.borderColor)
}
onEntered: {
backgroud.border.color = getBackgroundBorderColor(bgBorderHoveredColor)
}
onExited: {
backgroud.border.color = getBackgroundBorderColor(root.borderColor)
}
}
BasicButtonType {
visible: (root.buttonText !== "") || (root.buttonImageSource !== "")
parent: backgroud
focusPolicy: Qt.NoFocus
text: root.buttonText
leftImageSource: root.buttonImageSource
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.right: parent.right
height: parent.height
width: Math.max(height, implicitWidth)
squareLeftSide: true
clickedFunc: function() {
if (root.clickedFunc && typeof root.clickedFunc === "function") {
root.clickedFunc()
}
}
}
function getBackgroundBorderColor(noneFocusedColor) {
return textField.focus ? root.borderFocusedColor : noneFocusedColor
}
Keys.onEnterPressed: {
if (root.rightButtonClickedOnEnter && root.clickedFunc && typeof root.clickedFunc === "function") {
clickedFunc()
}
// if (KeyNavigation.tab) {
// KeyNavigation.tab.forceActiveFocus();
// }
}
Keys.onReturnPressed: {
if (root.rightButtonClickedOnEnter &&root.clickedFunc && typeof root.clickedFunc === "function") {
clickedFunc()
}
// if (KeyNavigation.tab) {
// KeyNavigation.tab.forceActiveFocus();
// }
}
}

View File

@@ -0,0 +1,15 @@
import QtQuick
import Style 1.0
Text {
lineHeight: 10 + LanguageModel.getLineHeightAppend()
lineHeightMode: Text.FixedHeight
color: AmneziaStyle.color.midnightBlack
font.pixelSize: 11
font.weight: Font.Medium
font.family: "PT Root UI VF"
wrapMode: Text.NoWrap
}

View File

@@ -12,13 +12,17 @@ QtObject {
readonly property color slateGray: '#2C2D30'
readonly property color onyxBlack: '#1C1D21'
readonly property color midnightBlack: '#0E0E11'
readonly property color goldenApricot: '#FBB26A'
readonly property color goldenApricot: goldenApricotString
readonly property color benefitsPanelBackground: '#1C1C1E'
readonly property color softViolet: '#A87BE2'
readonly property color burntOrange: '#A85809'
readonly property color mutedBrown: '#84603D'
readonly property color richBrown: '#633303'
readonly property color deepBrown: '#402102'
readonly property color vibrantRed: '#EB5757'
readonly property color darkCharcoal: '#261E1A'
readonly property color pearlGray: '#EAEAEC'
readonly property color sheerWhite: Qt.rgba(1, 1, 1, 0.12)
readonly property color translucentWhite: Qt.rgba(1, 1, 1, 0.08)
readonly property color barelyTranslucentWhite: Qt.rgba(1, 1, 1, 0.05)
@@ -26,9 +30,10 @@ QtObject {
readonly property color softGoldenApricot: Qt.rgba(251/255, 178/255, 106/255, 0.3)
readonly property color mistyGray: Qt.rgba(215/255, 216/255, 219/255, 0.8)
readonly property color cloudyGray: Qt.rgba(215/255, 216/255, 219/255, 0.65)
readonly property color pearlGray: '#EAEAEC'
readonly property color translucentRichBrown: Qt.rgba(99/255, 51/255, 3/255, 0.26)
readonly property color translucentSlateGray: Qt.rgba(85/255, 86/255, 92/255, 0.13)
readonly property color translucentOnyxBlack: Qt.rgba(28/255, 29/255, 33/255, 0.13)
readonly property string goldenApricotString: '#FBB26A'
}
}

Some files were not shown because too many files have changed in this diff Show More